feat: refine canvas workflows and unify application UI

This commit is contained in:
134 2026-07-10 12:57:01 +08:00
parent adbbe4442a
commit 3252cbd2c8
92 changed files with 18716 additions and 1533 deletions

View File

@ -0,0 +1,698 @@
"use client";
import {
Activity,
AlertTriangle,
Ban,
Clock3,
Globe2,
LogIn,
Plus,
RefreshCw,
Search,
ShieldAlert,
Unlock,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, fetcher } from "@/lib/utils";
type Severity = "info" | "watch" | "high" | "critical";
type AccessLogEvent = {
id: string;
eventType: string;
ip: string;
userEmail: string | null;
userRole: string | null;
method: string | null;
path: string | null;
routeGroup: string | null;
statusCode: number | null;
outcome: string;
severity: Severity;
reasons: string[] | null;
userAgent: string | null;
createdAt: string;
};
type AccessLogTopIp = {
ip: string;
total: number;
uniqueUsers: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
loginSuccessCount: number;
suspiciousCount: number;
firstSeen: string;
lastSeen: string;
severity: Severity;
failureRate: number;
reasons: string[];
};
type AccessLogResponse = {
overview: {
totalEvents: number;
uniqueIps: number;
abnormalEvents: number;
abnormalIps: number;
rateLimitedEvents: number;
manualBlockedEvents: number;
loginSuccessEvents: number;
unauthorizedEvents: number;
};
topIps: AccessLogTopIp[];
hourlyStats: Array<{
hour: string;
count: number;
abnormalCount: number;
}>;
pathStats: Array<{
path: string | null;
routeGroup: string | null;
count: number;
abnormalCount: number;
}>;
recentEvents: AccessLogEvent[];
rules: string[];
};
type AccessBlocklistEntry = {
id: string;
ip: string;
reason: string | null;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
createdByEmail: string | null;
};
const severityLabels: Record<Severity, string> = {
info: "正常",
watch: "关注",
high: "异常",
critical: "高危",
};
const severityClassNames: Record<Severity, string> = {
info: "border-emerald-200 bg-emerald-50 text-emerald-700",
watch: "border-amber-200 bg-amber-50 text-amber-700",
high: "border-orange-200 bg-orange-50 text-orange-700",
critical: "border-red-200 bg-red-50 text-red-700",
};
function formatNumber(value: number | null | undefined) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function formatPercent(value: number | null | undefined) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
function formatTime(value: string | null | undefined) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getStatusClassName(statusCode: number | null) {
if (!statusCode) return "text-muted-foreground";
if (statusCode >= 500) return "text-red-700";
if (statusCode >= 400) return "text-orange-700";
if (statusCode >= 300) return "text-sky-700";
return "text-emerald-700";
}
function AccessSeverityBadge({ severity }: { severity: Severity }) {
return (
<Badge
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
variant="outline"
>
{severityLabels[severity]}
</Badge>
);
}
export function AccessSecurityDashboard() {
const [hours, setHours] = useState("24");
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
const [blockIp, setBlockIp] = useState("");
const [blockReason, setBlockReason] = useState("");
const [isSavingBlock, setIsSavingBlock] = useState(false);
const accessLogUrl = useMemo(() => {
const params = new URLSearchParams({
hours,
severity,
limit: "160",
});
if (query.trim()) params.set("q", query.trim());
return `/api/admin/access-logs?${params.toString()}`;
}, [hours, query, severity]);
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
accessLogUrl,
fetcher,
{ refreshInterval: 30_000 }
);
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
entries: AccessBlocklistEntry[];
}>("/api/admin/access-blocklist", fetcher);
const activeBlocklistEntries = useMemo(
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
[blocklistData?.entries]
);
const blockedRules = useMemo(
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
[activeBlocklistEntries]
);
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
const ip = String(targetIp || blockIp).trim();
const reason = String(targetReason ?? blockReason).trim();
if (!ip) {
toast.error("请输入要拉黑的 IP");
return;
}
setIsSavingBlock(true);
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ip, reason }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "拉黑失败");
}
toast.success(`${ip} 已加入手动黑名单`);
setBlockIp("");
setBlockReason("");
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "拉黑失败");
} finally {
setIsSavingBlock(false);
}
};
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: entry.id,
isActive,
reason: entry.reason || "",
expiresAt: entry.expiresAt || null,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "更新失败");
}
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const overviewCards = [
{
key: "events",
label: "访问记录",
value: formatNumber(data?.overview.totalEvents),
note: `${hours} 小时窗口`,
icon: Activity,
className: "border-slate-200 bg-slate-50 text-slate-900",
},
{
key: "ips",
label: "独立 IP",
value: formatNumber(data?.overview.uniqueIps),
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
icon: Globe2,
className: "border-sky-200 bg-sky-50 text-sky-900",
},
{
key: "abnormal",
label: "异常事件",
value: formatNumber(data?.overview.abnormalEvents),
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
icon: ShieldAlert,
className: "border-orange-200 bg-orange-50 text-orange-900",
},
{
key: "login",
label: "成功登录",
value: formatNumber(data?.overview.loginSuccessEvents),
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
icon: LogIn,
className: "border-emerald-200 bg-emerald-50 text-emerald-900",
},
];
const maxHourlyCount = Math.max(
1,
...(data?.hourlyStats || []).map((item) => item.count)
);
const maxPathCount = Math.max(
1,
...(data?.pathStats || []).map((item) => item.count)
);
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle className="text-xl">访问安全分析</CardTitle>
<CardDescription>
IP 访问记录、登录来源、异常标注与请求趋势
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative sm:w-[260px]">
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索 IP、路径、用户"
value={query}
/>
</div>
<Select onValueChange={setHours} value={hours}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">近 1 小时</SelectItem>
<SelectItem value="6">近 6 小时</SelectItem>
<SelectItem value="24">近 24 小时</SelectItem>
<SelectItem value="72">近 3 天</SelectItem>
<SelectItem value="168">近 7 天</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setSeverity} value={severity}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="abnormal">仅异常</SelectItem>
<SelectItem value="watch">关注</SelectItem>
<SelectItem value="high">异常</SelectItem>
<SelectItem value="critical">高危</SelectItem>
</SelectContent>
</Select>
<Button
className="h-9 gap-2"
onClick={() => mutate()}
size="sm"
variant="outline"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
刷新
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((item) => {
const Icon = item.icon;
return (
<div
className={cn("rounded-lg border p-4", item.className)}
key={item.key}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-current/70">{item.label}</div>
<Icon className="h-4 w-4" />
</div>
<div className="mt-3 font-semibold text-3xl tracking-tight">
{item.value}
</div>
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
</div>
);
})}
</div>
<div className="rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-sm">
<Clock3 className="h-4 w-4" />
小时趋势
</div>
<div className="text-muted-foreground text-xs">
橙色表示异常事件占比
</div>
</div>
<div className="flex h-[180px] items-end gap-1">
{(data?.hourlyStats || []).map((item) => {
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
const abnormalHeight =
item.count > 0
? Math.max(0, (item.abnormalCount / item.count) * height)
: 0;
return (
<div
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200"
key={item.hour}
style={{ height: `${height}%` }}
>
<div
className="w-full bg-orange-400"
style={{ height: `${abnormalHeight}%` }}
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
{formatNumber(item.abnormalCount)}
</div>
</div>
);
})}
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
暂无访问记录
</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
<CardDescription>
管理员手动加入后,命中 IP 会被阻断并写入访问日志
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
<Input
className="h-9"
onChange={(event) => setBlockIp(event.target.value)}
placeholder="IP 或 IPv4 CIDR"
value={blockIp}
/>
<Input
className="h-9"
onChange={(event) => setBlockReason(event.target.value)}
placeholder="拉黑原因"
value={blockReason}
/>
<Button
className="h-9 gap-2"
disabled={isSavingBlock}
onClick={() => saveBlockEntry()}
size="sm"
>
<Plus className="h-4 w-4" />
加入黑名单
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{activeBlocklistEntries.map((entry) => (
<div
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3"
key={entry.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ban className="h-4 w-4 text-red-600" />
<span className="truncate font-mono font-semibold text-sm text-red-900">
{entry.ip}
</span>
</div>
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs">
{entry.reason || "未填写原因"}
</div>
<div className="mt-2 text-red-700/70 text-xs">
{formatTime(entry.updatedAt)}
</div>
</div>
<Button
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50"
onClick={() => updateBlockEntry(entry, false)}
size="sm"
variant="outline"
>
<Unlock className="h-3.5 w-3.5" />
解除
</Button>
</div>
</div>
))}
{activeBlocklistEntries.length === 0 ? (
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
暂无手动拉黑 IP
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
<CardDescription>
按限流、失败率、认证异常、敏感路径和请求量综合排序
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(data?.topIps || []).map((item) => (
<div
className="rounded-lg border bg-background px-4 py-3"
key={item.ip}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-semibold text-sm">
{item.ip}
</span>
<AccessSeverityBadge severity={item.severity} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
<span>{formatNumber(item.total)} 次访问</span>
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
<span>失败率 {formatPercent(item.failureRate)}</span>
<span>最近 {formatTime(item.lastSeen)}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1 md:justify-end">
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
(reason) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-700"
key={reason}
variant="outline"
>
{reason}
</Badge>
)
)}
<Button
className="h-7 gap-1 px-2 text-xs"
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
onClick={() =>
saveBlockEntry(
item.ip,
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
)
}
size="sm"
variant="outline"
>
<Ban className="h-3.5 w-3.5" />
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
</Button>
</div>
</div>
</div>
))}
{(!data?.topIps || data.topIps.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无 IP 聚合数据
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">访问路径</CardTitle>
<CardDescription>按请求数量排序</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.pathStats || []).map((item) => {
const abnormalRate =
item.count > 0 ? item.abnormalCount / item.count : 0;
return (
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate font-medium">
{item.path || "/"}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatNumber(item.count)}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full rounded-full bg-zinc-900"
style={{
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
}}
/>
<div
className="-mt-2 h-full rounded-full bg-orange-400"
style={{
width: `${Math.round(abnormalRate * 100)}%`,
}}
/>
</div>
</div>
);
})}
{(!data?.pathStats || data.pathStats.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无路径统计
</div>
)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">访问日志列表</CardTitle>
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{(data?.rules || []).map((rule) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-600"
key={rule}
variant="outline"
>
{rule}
</Badge>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<div className="min-w-[980px]">
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs">
<div>时间</div>
<div>IP</div>
<div>状态</div>
<div>路径</div>
<div>用户</div>
<div>标注</div>
</div>
{(data?.recentEvents || []).map((event) => (
<div
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
key={event.id}
>
<div className="text-muted-foreground text-xs">
{formatTime(event.createdAt)}
</div>
<div className="font-mono text-xs">{event.ip}</div>
<div
className={cn(
"font-semibold text-xs",
getStatusClassName(event.statusCode)
)}
>
{event.statusCode || "--"}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{event.method || "GET"} {event.path || "/"}
</div>
<div className="mt-1 truncate text-muted-foreground text-xs">
{event.reasons?.join("、") || event.outcome}
</div>
</div>
<div className="min-w-0">
<div className="truncate text-xs">
{event.userEmail || "匿名"}
</div>
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<UserCheck className="h-3 w-3" />
{event.userRole || "unknown"}
</div>
</div>
<div className="flex items-center gap-2">
{event.severity === "high" || event.severity === "critical" ? (
<AlertTriangle className="h-4 w-4 text-orange-500" />
) : null}
<AccessSeverityBadge severity={event.severity} />
</div>
</div>
))}
{(!data?.recentEvents || data.recentEvents.length === 0) && (
<div className="py-10 text-center text-muted-foreground text-sm">
暂无访问日志
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -33,8 +33,8 @@ function SettingsQuickAction({
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black" ? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50"; : "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
const layoutClassName = stretched const layoutClassName = stretched
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4" ? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
: "h-9 shrink-0 gap-2 rounded-full border px-3.5"; : "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
if (item.href) { if (item.href) {
return ( return (
@ -45,7 +45,7 @@ function SettingsQuickAction({
variant="ghost" variant="ghost"
> >
<Link href={item.href}> <Link href={item.href}>
<Icon className="h-4 w-4" /> <Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link> </Link>
</Button> </Button>
@ -59,7 +59,7 @@ function SettingsQuickAction({
size="sm" size="sm"
variant="ghost" variant="ghost"
> >
<Icon className="h-4 w-4" /> <Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button> </Button>
); );
@ -84,11 +84,14 @@ export function AdminSettingsNavigation({
}; };
return ( return (
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm"> <nav
<div className="flex flex-col gap-2 md:hidden"> aria-label="系统设置导航"
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
>
<div className="md:hidden">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}> <Select onValueChange={handleSelectChange} value={activeTab}>
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none"> <SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
<SelectValue placeholder={activeItem?.label || "选择设置项"} /> <SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -102,7 +105,7 @@ export function AdminSettingsNavigation({
<Button <Button
asChild asChild
className="h-9 rounded-full border-border/70 px-3 text-[13px]" className="h-8 rounded-md border-border/70 px-2.5 text-xs"
size="sm" size="sm"
variant="outline" variant="outline"
> >
@ -113,35 +116,22 @@ export function AdminSettingsNavigation({
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-1">
{ALL_SETTINGS_ITEMS.map((item) => (
<SettingsQuickAction
activeTab={activeTab}
item={item}
key={item.value}
onTabChange={onTabChange}
/>
))}
</div>
</div> </div>
<div className="hidden min-w-0 items-center gap-3 md:flex"> <div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto"> {ALL_SETTINGS_ITEMS.map((item) => (
{ALL_SETTINGS_ITEMS.map((item) => ( <SettingsQuickAction
<div className="min-w-[118px] flex-1" key={item.value}> activeTab={activeTab}
<SettingsQuickAction item={item}
activeTab={activeTab} key={item.value}
item={item} onTabChange={onTabChange}
onTabChange={onTabChange} stretched
stretched />
/> ))}
</div>
))}
</div>
<Button <Button
asChild asChild
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]" className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
size="sm" size="sm"
variant="outline" variant="outline"
> >
@ -151,6 +141,6 @@ export function AdminSettingsNavigation({
</Link> </Link>
</Button> </Button>
</div> </div>
</div> </nav>
); );
} }

View File

@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
onSetAllExpanded: (expanded: boolean) => void; onSetAllExpanded: (expanded: boolean) => void;
}) { }) {
return ( return (
<div className="overflow-hidden rounded-xl border bg-background"> <div className="overflow-hidden rounded-lg border bg-background">
<div className="border-b bg-muted/20 px-5 py-4"> <div className="border-b bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1.5"> <div className="space-y-1">
<div className="font-semibold text-muted-foreground text-xs"> <div className="font-semibold text-[10px] text-muted-foreground">
SYSTEM INTEGRATIONS SYSTEM INTEGRATIONS
</div> </div>
<h2 className="font-semibold text-2xl tracking-normal"> <h2 className="font-semibold text-lg">
高级能力与外部服务 高级能力与外部服务
</h2> </h2>
<p className="max-w-3xl text-muted-foreground text-sm leading-6"> <p className="max-w-3xl text-muted-foreground text-xs leading-5">
把商品策划、图像处理、3D/CAD、实时画布和开放 API 把商品策划、图像处理、3D/CAD、实时画布和开放 API
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。 按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
<SettingStatusBadge tone="neutral"> <SettingStatusBadge tone="neutral">
已展开 {openSectionCount} 个模块 已展开 {openSectionCount} 个模块
</SettingStatusBadge> </SettingStatusBadge>
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
模型 {modelCount} 模型 {modelCount}
</SettingStatusBadge> </SettingStatusBadge>
<Button <Button
className="h-7 rounded-md px-2 text-xs" className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(true)} onClick={() => onSetAllExpanded(true)}
size="sm" size="sm"
type="button" type="button"
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
全部展开 全部展开
</Button> </Button>
<Button <Button
className="h-7 rounded-md px-2 text-xs" className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(false)} onClick={() => onSetAllExpanded(false)}
size="sm" size="sm"
type="button" type="button"
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button <button
className="group bg-background p-4 text-left transition-colors hover:bg-muted/30" className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
key={item.key} key={item.key}
onClick={() => onFocusSection(item.key)} onClick={() => onFocusSection(item.key)}
type="button" type="button"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-sm"> <div className="truncate font-semibold text-xs">
{item.title} {item.title}
</div> </div>
<div className="mt-0.5 truncate text-muted-foreground text-xs"> <div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{item.description} {item.description}
</div> </div>
</div> </div>
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
); );
})} })}
</div> </div>
<div className="border-t bg-muted/10 px-5 py-4"> <div className="border-t bg-muted/10 px-4 py-3">
<div className="grid gap-4 lg:grid-cols-4"> <div className="grid gap-3 lg:grid-cols-4">
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => ( {LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
<div className="space-y-2" key={group}> <div className="space-y-1.5" key={group}>
<div className="font-semibold text-muted-foreground text-xs"> <div className="font-semibold text-[11px] text-muted-foreground">
{group} {group}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{quickLinks {quickLinks
.filter((item) => item.group === group) .filter((item) => item.group === group)
.map((item) => ( .map((item) => (
<button <button
className={cn( className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs transition-colors hover:bg-background", "inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
isSectionExpanded(item.key) isSectionExpanded(item.key)
? "border-foreground/20 bg-background text-foreground" ? "border-foreground/20 bg-background text-foreground"
: "border-border bg-transparent text-muted-foreground" : "border-border bg-transparent text-muted-foreground"

View File

@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
description: string; description: string;
}) { }) {
return ( return (
<div className="rounded-lg border bg-muted/20 px-4 py-3"> <div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]"> <div className="font-semibold text-[10px] text-muted-foreground uppercase">
{eyebrow} {eyebrow}
</div> </div>
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between"> <div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
<h3 className="font-semibold text-base text-foreground">{title}</h3> <h3 className="font-semibold text-sm text-foreground">{title}</h3>
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p> <p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
</div> </div>
</div> </div>
); );
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
}>; }>;
}) { }) {
return ( return (
<div className="flex flex-wrap gap-2 pt-1"> <div className="flex flex-wrap gap-1.5 pt-1">
{items.map((item) => ( {items.map((item) => (
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}> <SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
{item.label} {item.label}
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
return ( return (
<Button <Button
aria-expanded={expanded} aria-expanded={expanded}
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs" className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
onClick={onClick} onClick={onClick}
size="sm" size="sm"
type="button" type="button"
variant="outline" variant="outline"
> >
{expanded ? ( {expanded ? (
<ArrowUp className="h-3.5 w-3.5" /> <ArrowUp className="h-3 w-3" />
) : ( ) : (
<ArrowDown className="h-3.5 w-3.5" /> <ArrowDown className="h-3 w-3" />
)} )}
{expanded ? "收起配置" : "展开配置"} {expanded ? "收起配置" : "展开配置"}
</Button> </Button>
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
}) { }) {
return ( return (
<Card id={id}> <Card id={id}>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between"> <CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5"> <div className="space-y-1">
<CardTitle className="text-xl">{title}</CardTitle> <CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
{summary} {summary}
</div> </div>
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
</CardHeader> </CardHeader>
<CardContent <CardContent
className={cn( className={cn(
"local-ai-config-card-content space-y-6 pt-6", "local-ai-config-card-content space-y-4 pt-4",
!expanded && "hidden" !expanded && "hidden"
)} )}
> >

View File

@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6 为 AI详情生成 单独配置 Kimi Code 编程套餐
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。 模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</> </>
} }
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
]} ]}
/> />
} }
title={<>Moonshot 官方 Kimi K2.6 配置</>} title={<>Kimi Code 编程套餐配置</>}
> >
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotProviderName">提供商名称</Label> <Label htmlFor="moonshotProviderName">提供商名称</Label>
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
providerName: e.target.value, providerName: e.target.value,
})) }))
} }
placeholder="Moonshot AI (Official)" placeholder="Kimi Code 编程套餐"
value={moonshotDetailAgentConfig.providerName} value={moonshotDetailAgentConfig.providerName}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label> <Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
<Input <Input
id="moonshotBaseUrl" id="moonshotBaseUrl"
onChange={(e) => onChange={(e) =>
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.baseUrl} value={moonshotDetailAgentConfig.baseUrl}
/> />
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 ` 根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}` {DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
</div> </div>
</div> </div>
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
placeholder={ placeholder={
moonshotDetailAgentConfig.providerId moonshotDetailAgentConfig.providerId
? "留空则不修改当前 Key" ? "留空则不修改当前 Key"
: "请输入 Moonshot 官方 API Key" : "请输入 Kimi Code 控制台 API Key"
} }
type="password" type="password"
value={moonshotDetailAgentConfig.apiKey} value={moonshotDetailAgentConfig.apiKey}
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label> <Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
<Input <Input
id="moonshotModelId" id="moonshotModelId"
onChange={(e) => onChange={(e) =>
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.modelId} value={moonshotDetailAgentConfig.modelId}
/> />
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
已按你提供的 Moonshot 官方文档默认填写 Kimi Code 编程套餐专用接口固定使用
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。 `kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
</div> </div>
</div> </div>
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}> <Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
保存 Moonshot Kimi K2.6 配置 保存 Kimi Code 编程套餐配置
</Button> </Button>
</div> </div>
</LocalAiSectionShell> </LocalAiSectionShell>

View File

@ -15,7 +15,7 @@ export function SettingStatusBadge({
return ( return (
<Badge <Badge
className={cn( className={cn(
"h-6 rounded-md border px-2 font-medium text-[11px]", "h-5 rounded-md border px-1.5 font-medium text-[10px]",
tone === "enabled" && tone === "enabled" &&
"border-emerald-200 bg-emerald-50 text-emerald-700", "border-emerald-200 bg-emerald-50 text-emerald-700",
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500", tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",

View File

@ -9,6 +9,7 @@ import {
MessageSquare, MessageSquare,
Scissors, Scissors,
Settings, Settings,
ShieldAlert,
Sparkles, Sparkles,
User, User,
} from "lucide-react"; } from "lucide-react";
@ -37,6 +38,12 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
description: "用量、趋势与运营指标", description: "用量、趋势与运营指标",
icon: BarChart3, icon: BarChart3,
}, },
{
value: "access-security",
label: "访问安全",
description: "访问 IP、异常标注与登录来源",
icon: ShieldAlert,
},
], ],
}, },
{ {

View File

@ -12,6 +12,8 @@ import {
Calendar, Calendar,
Check, Check,
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
Copy, Copy,
Crop, Crop,
@ -155,6 +157,8 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID, DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotConfig, normalizeDetailAgentLocalMoonshotConfig,
} from "@/lib/ai/detail-agent-model-config"; } from "@/lib/ai/detail-agent-model-config";
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models"; import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
@ -303,6 +307,7 @@ import {
} from "@/lib/worldlabs/key-pool-config-shared"; } from "@/lib/worldlabs/key-pool-config-shared";
import type { AgentWorkflowUiConfig } from "@/types/agent"; import type { AgentWorkflowUiConfig } from "@/types/agent";
import { AdminSettingsNavigation } from "./_components/admin-settings-navigation"; import { AdminSettingsNavigation } from "./_components/admin-settings-navigation";
import { AccessSecurityDashboard } from "./_components/access-security-dashboard";
import { LocalAiCommandCenter } from "./_components/local-ai-command-center"; import { LocalAiCommandCenter } from "./_components/local-ai-command-center";
import { LocalAiGroupHeading } from "./_components/local-ai-section-shell"; import { LocalAiGroupHeading } from "./_components/local-ai-section-shell";
import { LocalAiImagePipelineSections } from "./_components/local-ai-sections/image-pipeline-sections"; import { LocalAiImagePipelineSections } from "./_components/local-ai-sections/image-pipeline-sections";
@ -1699,10 +1704,7 @@ function isBltcyBflBaseUrl(value?: string | null) {
} }
function isMoonshotOfficialBaseUrl(value?: string | null) { function isMoonshotOfficialBaseUrl(value?: string | null) {
const normalized = String(value || "") return isDetailAgentLocalMoonshotBaseUrl(value);
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return normalized === DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
} }
export function AdminSettingsClient() { export function AdminSettingsClient() {
@ -2671,7 +2673,7 @@ export function AdminSettingsClient() {
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({ const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
providerId: "", providerId: "",
modelEntryId: "", modelEntryId: "",
providerName: "Moonshot AI (Official)", providerName: "Kimi Code 编程套餐",
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL, baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
apiKey: "", apiKey: "",
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
@ -7053,8 +7055,9 @@ export function AdminSettingsClient() {
const handleSaveMoonshotDetailAgentConfig = async () => { const handleSaveMoonshotDetailAgentConfig = async () => {
try { try {
const trimmedBaseUrl = const trimmedBaseUrl =
moonshotDetailAgentConfig.baseUrl.trim() || normalizeDetailAgentLocalMoonshotBaseUrl(
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL; moonshotDetailAgentConfig.baseUrl
);
const trimmedModelId = const trimmedModelId =
moonshotDetailAgentConfig.modelId.trim() || moonshotDetailAgentConfig.modelId.trim() ||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID; DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
@ -7063,17 +7066,17 @@ export function AdminSettingsClient() {
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME; DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
const trimmedProviderName = const trimmedProviderName =
moonshotDetailAgentConfig.providerName.trim() || moonshotDetailAgentConfig.providerName.trim() ||
"Moonshot AI (Official)"; "Kimi Code 编程套餐";
if (!isMoonshotOfficialBaseUrl(trimmedBaseUrl)) { if (!isMoonshotOfficialBaseUrl(trimmedBaseUrl)) {
toast.error( toast.error(
"请使用 Moonshot 官方 API 地址:https://api.moonshot.cn/v1" `请使用 Kimi Code 编程套餐专用 API 地址:${DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
); );
return; return;
} }
if (!trimmedModelId) { if (!trimmedModelId) {
toast.error("请填写官方 Kimi K2.6 的 API 模型 ID"); toast.error("请填写 Kimi Code API 模型 ID");
return; return;
} }
@ -7081,7 +7084,7 @@ export function AdminSettingsClient() {
!moonshotDetailAgentConfig.providerId && !moonshotDetailAgentConfig.providerId &&
!moonshotDetailAgentConfig.apiKey.trim() !moonshotDetailAgentConfig.apiKey.trim()
) { ) {
toast.error("请填写 Moonshot 官方 API Key"); toast.error("请填写 Kimi Code 控制台 API Key");
return; return;
} }
@ -7133,7 +7136,7 @@ export function AdminSettingsClient() {
mutateMoonshotDetailAgentSetting(); mutateMoonshotDetailAgentSetting();
mutateSiteSettings(); mutateSiteSettings();
toast.success("Moonshot 官方 Kimi K2.6 已绑定为 AI详情 Agent 专用模型"); toast.success("Kimi Code 编程套餐已绑定为 AI详情 Agent 专用模型");
} catch (error) { } catch (error) {
console.error("[Moonshot Detail Agent Config] Error:", error); console.error("[Moonshot Detail Agent Config] Error:", error);
toast.error( toast.error(
@ -8653,7 +8656,7 @@ export function AdminSettingsClient() {
}, [expandedLocalAiSections]); }, [expandedLocalAiSections]);
return ( return (
<div className="w-full px-4 py-6 sm:px-6 xl:px-8 2xl:px-10"> <div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
<Tabs onValueChange={setActiveTab} value={activeTab}> <Tabs onValueChange={setActiveTab} value={activeTab}>
<AdminSettingsNavigation <AdminSettingsNavigation
activeTab={activeTab} activeTab={activeTab}
@ -8677,18 +8680,20 @@ export function AdminSettingsClient() {
type="text" type="text"
value={userSearch} value={userSearch}
/> />
<div className="flex items-center gap-2"> <div className="grid w-full grid-cols-2 gap-1.5 sm:flex sm:w-auto sm:items-center">
<Button <Button
className="flex items-center gap-2" className="min-w-0 px-2"
onClick={() => setShowBatchLimitDialog(true)} onClick={() => setShowBatchLimitDialog(true)}
size="sm" size="sm"
title="批量设置限额"
variant="outline" variant="outline"
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
批量设置限额 <span className="sm:hidden">批量限额</span>
<span className="hidden sm:inline">批量设置限额</span>
</Button> </Button>
<Button <Button
className="flex items-center gap-2" className="min-w-0 px-2"
onClick={async () => { onClick={async () => {
try { try {
toast.info("正在从钉钉同步用户信息,可能需要几分钟..."); toast.info("正在从钉钉同步用户信息,可能需要几分钟...");
@ -8713,10 +8718,12 @@ export function AdminSettingsClient() {
} }
}} }}
size="sm" size="sm"
title="同步钉钉用户"
variant="outline" variant="outline"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
同步钉钉用户 <span className="sm:hidden">同步用户</span>
<span className="hidden sm:inline">同步钉钉用户</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -8725,40 +8732,35 @@ export function AdminSettingsClient() {
<div className="mb-4 space-y-4"> <div className="mb-4 space-y-4">
{usersData?.users?.map((user) => ( {usersData?.users?.map((user) => (
<div <div
className="flex items-center justify-between rounded-lg border p-4" className="flex flex-col items-stretch gap-3 rounded-lg border p-3 xl:flex-row xl:items-center xl:justify-between"
key={user.id} key={user.id}
> >
<div className="flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium"> <div className="truncate font-medium">
{user.name || user.email} {user.name || user.email}
</div> </div>
<div className="text-muted-foreground text-sm"> <div className="mt-1 grid gap-x-4 gap-y-0.5 text-muted-foreground text-xs sm:grid-cols-2 xl:grid-cols-3">
邮箱: {user.email} <div className="truncate">邮箱: {user.email}</div>
</div> <div>部门: {user.department || "未设置"}</div>
<div className="text-muted-foreground text-sm"> <div>
部门: {user.department || "未设置"} 角色: {ROLE_LABELS[user.role] ?? user.role} ·{" "}
</div> {user.isActive ? "已激活" : "未激活"}
<div className="text-muted-foreground text-sm"> </div>
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态:{" "} <div>标签: {user.isHighVip ? "高级VIP" : "普通"}</div>
{user.isActive ? "已激活" : "未激活"} <div>
</div> 月度限额:{" "}
<div className="text-muted-foreground text-sm"> {user.monthlyImageLimit
标签: {user.isHighVip ? "高级VIP" : "普通"} ? `${user.monthlyImageLimit} 张/月`
</div> : "无限制"}
<div className="text-muted-foreground text-sm"> </div>
月度出图限额:{" "} <div>
{user.monthlyImageLimit 积分: {Number(user.pointsBalance || 0).toLocaleString()}
? `${user.monthlyImageLimit} 张/月` {" · 赠送 "}
: "无限制"} {Number(user.pointsGiftedTotal || 0).toLocaleString()}
</div> </div>
<div className="text-muted-foreground text-sm">
剩余积分:{" "}
{Number(user.pointsBalance || 0).toLocaleString()} |
累计赠送:{" "}
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex w-full flex-wrap items-center gap-1.5 xl:w-auto xl:justify-end">
{/* 配额设置 */} {/* 配额设置 */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Input <Input
@ -8905,15 +8907,16 @@ export function AdminSettingsClient() {
{/* 分页控件 */} {/* 分页控件 */}
{usersData && usersData.totalPages > 1 && ( {usersData && usersData.totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-xs">
显示 {(userPage - 1) * userLimit + 1} -{" "} 显示 {(userPage - 1) * userLimit + 1} -{" "}
{Math.min(userPage * userLimit, usersData.total)} 条,共{" "} {Math.min(userPage * userLimit, usersData.total)} 条,共{" "}
{usersData.total} 条 {usersData.total} 条
</div> </div>
<div className="flex items-center gap-2"> <div className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 sm:flex sm:w-auto sm:gap-2">
{/* 首页按钮 */} {/* 首页按钮 */}
<Button <Button
className="hidden sm:inline-flex"
disabled={userPage === 1} disabled={userPage === 1}
onClick={() => setUserPage(1)} onClick={() => setUserPage(1)}
size="sm" size="sm"
@ -8925,19 +8928,24 @@ export function AdminSettingsClient() {
{/* 上一页按钮 */} {/* 上一页按钮 */}
<Button <Button
aria-label="上一页"
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
disabled={userPage === 1} disabled={userPage === 1}
onClick={() => setUserPage((p) => Math.max(1, p - 1))} onClick={() => setUserPage((p) => Math.max(1, p - 1))}
size="sm" size="sm"
variant="outline" variant="outline"
> >
上一页 <ChevronLeft className="h-3.5 w-3.5" />
<span className="hidden sm:inline">上一页</span>
</Button> </Button>
{/* 页码显示和跳转 */} {/* 页码显示和跳转 */}
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center justify-center gap-1">
<span className="whitespace-nowrap text-sm">第</span> <span className="hidden whitespace-nowrap text-xs sm:inline">
第
</span>
<Input <Input
className="h-8 w-16 text-center" className="h-8 w-10 px-1 text-center sm:w-14"
max={usersData.totalPages} max={usersData.totalPages}
min={1} min={1}
onChange={(e) => { onChange={(e) => {
@ -8962,13 +8970,15 @@ export function AdminSettingsClient() {
type="number" type="number"
value={userPage} value={userPage}
/> />
<span className="whitespace-nowrap text-sm"> <span className="whitespace-nowrap text-xs">
/ {usersData.totalPages} 页 /{usersData.totalPages}页
</span> </span>
</div> </div>
{/* 下一页按钮 */} {/* 下一页按钮 */}
<Button <Button
aria-label="下一页"
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
disabled={userPage === usersData.totalPages} disabled={userPage === usersData.totalPages}
onClick={() => onClick={() =>
setUserPage((p) => setUserPage((p) =>
@ -8978,11 +8988,13 @@ export function AdminSettingsClient() {
size="sm" size="sm"
variant="outline" variant="outline"
> >
下一页 <span className="hidden sm:inline">下一页</span>
<ChevronRight className="h-3.5 w-3.5" />
</Button> </Button>
{/* 末页按钮 */} {/* 末页按钮 */}
<Button <Button
className="hidden sm:inline-flex"
disabled={userPage === usersData.totalPages} disabled={userPage === usersData.totalPages}
onClick={() => setUserPage(usersData.totalPages)} onClick={() => setUserPage(usersData.totalPages)}
size="sm" size="sm"
@ -9979,6 +9991,11 @@ export function AdminSettingsClient() {
</Card> </Card>
</TabsContent> </TabsContent>
{/* 访问安全 */}
<TabsContent className="mt-6" value="access-security">
<AccessSecurityDashboard />
</TabsContent>
{/* 网站设置 */} {/* 网站设置 */}
<TabsContent className="mt-6" value="site"> <TabsContent className="mt-6" value="site">
<Card> <Card>
@ -13424,7 +13441,7 @@ export function AdminSettingsClient() {
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用 用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
Agent 对话默认模型;仅影响 Agent 对话默认模型;仅影响
AI详情生成,不影响普通无限画布对话框的 Agent AI详情生成,不影响普通无限画布对话框的 Agent
对话。建议选择官方 Kimi K2.6 多模态模型。 对话。建议选择 Kimi Code 编程套餐模型。
</div> </div>
</div> </div>

View File

@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto p-6"> <div className="settings-workspace container mx-auto p-4">
<div className="flex h-[60vh] items-center justify-center"> <div className="flex h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
} }
return ( return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20"> <div className="settings-workspace min-h-screen bg-muted/10">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4"> <div className="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-4 w-4 text-background" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1> <h1 className="text-base font-semibold">风格化工作流配置</h1>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤 配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p> </p>
</div> </div>
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
{/* Stats Bar */} {/* Stats Bar */}
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3"> <div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-6"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{enabledStepsCount} {enabledStepsCount}
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
</div> </div>
{/* Main Content */} {/* Main Content */}
<main className="container mx-auto p-6"> <main className="container mx-auto p-4 sm:px-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-5 lg:w-fit"> <TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2"> <TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" /> <MonitorPlay className="h-4 w-4" />

View File

@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
); );
return ( return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20"> <div className="settings-workspace min-h-screen bg-muted/10">
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4"> <div className="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-4 w-4 text-background" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1> <h1 className="text-base font-semibold">PSD 模板配置</h1>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出 上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
</p> </p>
</div> </div>
@ -2457,7 +2457,6 @@ export function PsdTemplatesSettingsClient({
onClick={handleSaveTemplate} onClick={handleSaveTemplate}
disabled={!draftTemplate || isSaving} disabled={!draftTemplate || isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -2472,8 +2471,8 @@ export function PsdTemplatesSettingsClient({
</header> </header>
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3"> <div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{templates.length} {templates.length}
</Badge> </Badge>
@ -2509,8 +2508,8 @@ export function PsdTemplatesSettingsClient({
</div> </div>
</div> </div>
<div className="container mx-auto px-6 py-6"> <div className="container mx-auto px-4 py-4 sm:px-5">
<div className="space-y-6"> <div className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">品类标签</CardTitle> <CardTitle className="text-base">品类标签</CardTitle>

View File

@ -561,19 +561,19 @@ export function StyleProSettingsClient() {
}; };
return ( return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8"> <main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div> <div>
<div className="mb-3"> <div className="mb-2">
<Button asChild variant="outline"> <Button asChild size="sm" variant="outline">
<Link href="/settings"> <Link href="/settings">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置 返回设置
</Link> </Link>
</Button> </Button>
</div> </div>
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1> <h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
<p className="mt-2 max-w-2xl text-muted-foreground"> <p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。 配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
</p> </p>
</div> </div>

View File

@ -1,5 +1,7 @@
"use client"; "use client";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
@ -12,6 +14,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -30,6 +33,7 @@ import {
getVideoCanvasFunctionBillingCopy, getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig, type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition, type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY, VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig, defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig,
@ -199,8 +203,9 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo( const functions = useMemo(
() => () =>
Object.values(config.functions).sort((a, b) => { Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind); const compare = (left: string, right: string) =>
return kindCompare || a.label.localeCompare(b.label); left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}), }),
[config.functions], [config.functions],
); );
@ -225,6 +230,44 @@ export function VideoCanvasSettingsClient() {
})); }));
}; };
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
commercial: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
@ -255,11 +298,17 @@ export function VideoCanvasSettingsClient() {
}; };
return ( return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8"> <main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1> <Button asChild className="mb-2" size="sm" variant="outline">
<p className="mt-2 max-w-3xl text-sm text-muted-foreground"> <Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。 这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p> </p>
</div> </div>
@ -321,6 +370,125 @@ export function VideoCanvasSettingsClient() {
</CardContent> </CardContent>
</Card> </Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2> <h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>

View File

@ -17,6 +17,7 @@ import {
requiresSingleActiveSession, requiresSingleActiveSession,
revokeSingleActiveSession, revokeSingleActiveSession,
} from "@/lib/auth/session-control"; } from "@/lib/auth/session-control";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
export type UserType = "guest" | "regular" | "admin" | "agent_admin"; export type UserType = "guest" | "regular" | "admin" | "agent_admin";
@ -200,7 +201,10 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
} }
const nextAuth = NextAuth(async () => { const nextAuth = NextAuth(async () => {
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any); const runtimeAuthSettings =
process.env.CANVAS_E2E_LOCAL === "true"
? null
: await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl = const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined; (await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
const googleClientId = const googleClientId =
@ -283,6 +287,32 @@ const nextAuth = NextAuth(async () => {
delete token.sessionKey; delete token.sessionKey;
} }
if (account && resolvedUserId) {
await recordAccessIpEventFromHeaders({
eventType: "login_success",
userId: resolvedUserId,
userEmail:
typeof token.email === "string"
? token.email
: typeof user?.email === "string"
? user.email
: null,
userRole: resolvedUserType || "regular",
method: "POST",
path: `/api/auth/${account.provider}`,
routeGroup: "auth",
statusCode: 200,
outcome: "login_success",
severity: "info",
reasons: ["登录成功"],
metadata: {
provider: account.provider,
},
}).catch((error) => {
console.warn("[Auth] Failed to record login access event:", error);
});
}
return token; return token;
}, },
session({ session, token }) { session({ session, token }) {

View File

@ -173,6 +173,7 @@ export async function POST(request: Request) {
const client = createS3Client(target.config); const client = createS3Client(target.config);
const bucket = target.config.bucket; const bucket = target.config.bucket;
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
try { try {
if (payload.action === "initiate") { if (payload.action === "initiate") {
@ -207,21 +208,24 @@ export async function POST(request: Request) {
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read", ...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
}); });
const url = await getSignedUrl(client, command, { const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}); });
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
};
if (usePublicReadAcl) {
headers["x-amz-acl"] = "public-read";
}
return NextResponse.json({ return NextResponse.json({
mode: "single", mode: "single",
key, key,
url, url,
headers: { headers,
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}); });
@ -233,7 +237,7 @@ export async function POST(request: Request) {
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read", ...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
}) })
); );

View File

@ -72,6 +72,78 @@ function isWorkflowImageGenerationModel(model: {
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限) // 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const requestHost = new URL(request.url).hostname;
const isLocalCanvasE2E =
process.env.CANVAS_E2E_LOCAL === "true" ||
requestHost === "127.0.0.1" ||
requestHost === "localhost" ||
requestHost === "::1";
if (isLocalCanvasE2E) {
const models = [
{
id: "local-e2e-image-model",
modelId: "nano-banana-pro-local",
displayName: "Nano Banana Pro",
avatarUrl: null,
description: "本地视频画布测试图片模型",
supportsVision: true,
supportsImageGen: true,
isMultimodal: true,
isDefault: true,
providerName: "Local E2E",
providerType: "local",
isSubModel: false,
parentModelId: null,
capabilities: { image: true },
videoToolCapability: null,
imageGenConfig: {
creditCostPerImage: 1,
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
},
sortOrder: 0,
selectable: true,
},
{
id: "local-e2e-video-model",
modelId: "kling-3-local",
displayName: "可灵 3.0",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
supportsImageGen: false,
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "local",
isSubModel: false,
parentModelId: null,
capabilities: { video: true },
videoToolCapability: {
supported: true,
durations: [5, 10],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
},
sortOrder: 1,
selectable: true,
},
];
return Response.json({
models,
defaultModelId: models[0].id,
agentWorkflowConfiguredDefaultModelId: null,
agentWorkflowDefaultModelId: null,
agentWorkflowConfiguredModels: [],
agentModel: null,
});
}
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {

View File

@ -0,0 +1,207 @@
import { desc, eq, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist, user } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 10;
const createSchema = z.object({
ip: z.string().trim().min(1).max(128),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
const patchSchema = z.object({
id: z.string().uuid(),
isActive: z.boolean(),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
function normalizeOptionalDate(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized) return null;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function GET() {
try {
await requireAdmin();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
isActive: accessIpBlocklist.isActive,
expiresAt: accessIpBlocklist.expiresAt,
createdAt: accessIpBlocklist.createdAt,
updatedAt: accessIpBlocklist.updatedAt,
createdBy: accessIpBlocklist.createdBy,
createdByEmail: user.email,
})
.from(accessIpBlocklist)
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
.limit(300);
return NextResponse.json({ entries });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpBlocklist] failed to list entries", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: Request) {
try {
const session = await requireAdmin();
const body = createSchema.parse(await request.json());
const normalizedIp = normalizeManualBlockIp(body.ip);
if (!normalizedIp) {
return new ChatSDKError(
"bad_request:api",
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
).toResponse();
}
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const reason = body.reason?.trim() || null;
const [existing] = await db
.select({ id: accessIpBlocklist.id })
.from(accessIpBlocklist)
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
.limit(1);
const [entry] = existing
? await db
.update(accessIpBlocklist)
.set({
reason,
isActive: true,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, existing.id))
.returning()
: await db
.insert(accessIpBlocklist)
.values({
ip: normalizedIp,
reason,
isActive: true,
expiresAt,
createdBy: session.user.id,
updatedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
.returning();
await recordAccessIpEventFromHeaders({
eventType: "manual_block_added",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "POST",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: "manual_block_added",
severity: "high",
reasons: ["管理员手动拉黑 IP"],
metadata: {
targetIp: normalizedIp,
reason,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record block action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to create entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function PATCH(request: Request) {
try {
const session = await requireAdmin();
const body = patchSchema.parse(await request.json());
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const [existing] = await db
.select({ ip: accessIpBlocklist.ip })
.from(accessIpBlocklist)
.where(eq(accessIpBlocklist.id, body.id))
.limit(1);
if (!existing) {
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
}
const [entry] = await db
.update(accessIpBlocklist)
.set({
isActive: body.isActive,
reason: body.reason?.trim() || undefined,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, body.id))
.returning();
await recordAccessIpEventFromHeaders({
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "PATCH",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
severity: body.isActive ? "high" : "watch",
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
metadata: {
targetIp: existing.ip,
blocklistId: body.id,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record update action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to update entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -0,0 +1,271 @@
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { accessIpEvent } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 10;
type Severity = "info" | "watch" | "high" | "critical";
const severityByRank: Record<number, Severity> = {
0: "info",
1: "watch",
2: "high",
3: "critical",
};
function clampInt(value: string | null, fallback: number, min: number, max: number) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function normalizeSearch(value: string | null) {
return String(value || "").trim().slice(0, 120);
}
function buildSeverityFromAggregate(params: {
eventSeverityRank: number;
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
}) {
let severity = severityByRank[params.eventSeverityRank] || "info";
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
severity = "high";
} else if (params.authFailureCount >= 5 || params.total >= 250) {
severity = severity === "critical" ? severity : "high";
} else if (
params.failureCount >= 20 ||
(params.total >= 10 && failureRate >= 0.45)
) {
severity = severity === "critical" ? severity : "high";
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
severity = severity === "info" ? "watch" : severity;
}
return severity;
}
function buildAggregateReasons(params: {
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
loginSuccessCount: number;
}) {
const reasons: string[] = [];
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0) reasons.push("触发限流");
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
if (params.total >= 250) reasons.push("请求量偏高");
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
reasons.push("失败率偏高");
}
if (params.loginSuccessCount > 0 && reasons.length === 0) {
reasons.push("有成功登录记录");
}
return reasons;
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
const severityFilter = searchParams.get("severity") || "all";
const search = normalizeSearch(searchParams.get("q"));
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
if (severityFilter === "abnormal") {
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
}
if (search) {
const pattern = `%${search}%`;
whereParts.push(sql`(
${accessIpEvent.ip} ILIKE ${pattern}
OR ${accessIpEvent.path} ILIKE ${pattern}
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
)`);
}
const whereClause = and(...whereParts);
const [overview] = await db
.select({
totalEvents: sql<number>`count(*)::int`,
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
})
.from(accessIpEvent)
.where(whereClause);
const recentEvents = await db
.select({
id: accessIpEvent.id,
eventType: accessIpEvent.eventType,
ip: accessIpEvent.ip,
userId: accessIpEvent.userId,
userEmail: accessIpEvent.userEmail,
userRole: accessIpEvent.userRole,
method: accessIpEvent.method,
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
statusCode: accessIpEvent.statusCode,
outcome: accessIpEvent.outcome,
severity: accessIpEvent.severity,
reasons: accessIpEvent.reasons,
userAgent: accessIpEvent.userAgent,
referer: accessIpEvent.referer,
occurredAt: accessIpEvent.occurredAt,
createdAt: accessIpEvent.createdAt,
})
.from(accessIpEvent)
.where(whereClause)
.orderBy(desc(accessIpEvent.createdAt))
.limit(limit);
const topIpRows = await db
.select({
ip: accessIpEvent.ip,
total: sql<number>`count(*)::int`,
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
when 'critical' then 3
when 'high' then 2
when 'watch' then 1
else 0
end)::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.ip)
.orderBy(
desc(sql`(
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
+ count(*)
)`)
)
.limit(24);
const topIps = topIpRows.map((row) => {
const severity = buildSeverityFromAggregate({
eventSeverityRank: Number(row.eventSeverityRank || 0),
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
});
return {
...row,
severity,
failureRate:
Number(row.total || 0) > 0
? Number(row.failureCount || 0) / Number(row.total || 1)
: 0,
reasons: buildAggregateReasons({
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
loginSuccessCount: Number(row.loginSuccessCount || 0),
}),
};
});
const hourlyStats = await db
.select({
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
const pathStats = await db
.select({
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
.orderBy(desc(sql`count(*)`))
.limit(12);
return NextResponse.json({
window: {
hours,
since: since.toISOString(),
},
overview: {
totalEvents: overview?.totalEvents || 0,
uniqueIps: overview?.uniqueIps || 0,
abnormalEvents: overview?.abnormalEvents || 0,
abnormalIps: overview?.abnormalIps || 0,
rateLimitedEvents: overview?.rateLimitedEvents || 0,
manualBlockedEvents: overview?.manualBlockedEvents || 0,
loginSuccessEvents: overview?.loginSuccessEvents || 0,
unauthorizedEvents: overview?.unauthorizedEvents || 0,
},
topIps,
hourlyStats,
pathStats,
recentEvents,
rules: [
"短时间请求量偏高",
"4xx/5xx 或失败率偏高",
"认证失败偏多",
"触发限流",
"命中敏感路径或异常 User-Agent",
],
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpEvent] failed to load admin analytics", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -18,6 +18,8 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME, DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
} from "@/lib/ai/detail-agent-model-config"; } from "@/lib/ai/detail-agent-model-config";
import { import {
normalizeProductPlanningApiConfig, normalizeProductPlanningApiConfig,
@ -95,7 +97,7 @@ const moonshotDetailAgentConfigSchema = z.object({
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL; const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) { function isMoonshotOfficialBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL; return isDetailAgentLocalMoonshotBaseUrl(value);
} }
function isObjectLike(value: unknown): value is Record<string, unknown> { function isObjectLike(value: unknown): value is Record<string, unknown> {
@ -191,19 +193,27 @@ export async function POST(request: Request) {
? (existingSetting.value as Record<string, unknown>) ? (existingSetting.value as Record<string, unknown>)
: {}; : {};
const config = moonshotDetailAgentConfigSchema.parse(value || {}); const config = moonshotDetailAgentConfigSchema.parse(value || {});
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL; const normalizedBaseUrl = normalizeDetailAgentLocalMoonshotBaseUrl(config.baseUrl);
const rawModelId = String(config.modelId || "").trim(); const rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim(); const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId = const normalizedModelId =
!rawModelId || rawModelId === "kimi-k2.5" !rawModelId ||
rawModelId === "kimi-k2.5" ||
rawModelId === "kimi-k2.6" ||
rawModelId === "kimi-k2.7-code" ||
rawModelId === "kimi-k2.7-code-highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID ? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId; : rawModelId;
const normalizedDisplayName = const normalizedDisplayName =
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5" !rawDisplayName ||
rawDisplayName === "官方 Kimi K2.5" ||
rawDisplayName === "官方 Kimi K2.6" ||
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME ? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName; : rawDisplayName;
const normalizedProviderName = const normalizedProviderName =
String(config.providerName || "").trim() || "Moonshot AI (Official)"; String(config.providerName || "").trim() || "Kimi Code 编程套餐";
const normalizedApiKey = const normalizedApiKey =
String(config.apiKey || "").trim() || String(config.apiKey || "").trim() ||
String(existingValue.apiKey || "").trim(); String(existingValue.apiKey || "").trim();
@ -212,12 +222,12 @@ export async function POST(request: Request) {
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) { if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
return new ChatSDKError( return new ChatSDKError(
"bad_request:api", "bad_request:api",
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`, `Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
).toResponse(); ).toResponse();
} }
if (!normalizedApiKey) { if (!normalizedApiKey) {
return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse(); return new ChatSDKError("bad_request:api", "缺少 Kimi Code 控制台 API Key").toResponse();
} }
const saved = await upsertSystemSetting({ const saved = await upsertSystemSetting({

View File

@ -17,19 +17,67 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布"; return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
} }
function shouldUseLocalCanvasE2E(request: NextRequest) {
const hostname = request.nextUrl.hostname;
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
);
}
function createLocalCanvasProject(
id: string,
input: {
canvasData?: unknown;
description?: unknown;
thumbnail?: unknown;
title?: unknown;
workspaceType?: unknown;
} = {}
) {
const now = new Date().toISOString();
const workspaceType = resolveWorkspaceType(input.workspaceType || "video");
return {
id,
userId: "local-video-canvas-e2e-user",
title: String(input.title || getDefaultWorkspaceTitle(workspaceType)),
description: typeof input.description === "string" ? input.description : null,
thumbnail: typeof input.thumbnail === "string" ? input.thumbnail : null,
workspaceType,
canvasData:
input.canvasData && typeof input.canvasData === "object"
? input.canvasData
: {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
},
createdAt: now,
updatedAt: now,
};
}
// 获取单个画布 // 获取单个画布
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id),
});
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const { id } = await params;
const [project] = await db const [project] = await db
.select() .select()
.from(canvasProject) .from(canvasProject)
@ -57,13 +105,20 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const body = await request.json();
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id, body),
});
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const { id } = await params;
const body = await request.json();
const { title, description, thumbnail, canvasData } = body; const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision; const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =

View File

@ -25,7 +25,7 @@ const VIDEO_ANALYSIS_MAX_SIDE = 960;
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000; const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
const requestSchema = z.object({ const requestSchema = z.object({
mode: z.enum(["video-analysis", "character-scene-extraction", "agent-plan"]), mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
prompt: z.string().trim().optional(), prompt: z.string().trim().optional(),
imageUrls: z.array(z.string().trim()).optional(), imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(), videoUrls: z.array(z.string().trim()).optional(),
@ -70,6 +70,14 @@ function normalizeText(value: unknown) {
return String(value || "").trim(); return String(value || "").trim();
} }
function shouldUseLocalWorkflowE2E(request: Request) {
const hostname = new URL(request.url).hostname;
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
);
}
function normalizeStringList(value: unknown, limit = 8) { function normalizeStringList(value: unknown, limit = 8) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return Array.from( return Array.from(
@ -118,6 +126,22 @@ function normalizeVideoAnalysisRows(
const sceneDescription = normalizeText( const sceneDescription = normalizeText(
raw.sceneDescription || raw.scene || raw.title || raw.description, raw.sceneDescription || raw.scene || raw.title || raw.description,
); );
const narrative = normalizeText(
raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary,
);
const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1);
const startTime = normalizeText(raw.startTime || raw.start || raw.start_time);
const endTime = normalizeText(raw.endTime || raw.end || raw.end_time);
const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds);
const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale);
const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType,
);
const focusDepth = normalizeText(
raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth,
);
const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign);
const backgroundMusic = normalizeText( const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm, raw.backgroundMusic || raw.music || raw.bgm,
); );
@ -135,10 +159,16 @@ function normalizeVideoAnalysisRows(
); );
if ( if (
!sceneDescription && !sceneDescription &&
!narrative &&
!backgroundMusic && !backgroundMusic &&
!voiceAndSfx && !voiceAndSfx &&
!imagePrompt && !imagePrompt &&
!motionPrompt !motionPrompt &&
!shotSize &&
!cameraAngle &&
!cameraMovement &&
!focusDepth &&
!lighting
) { ) {
return null; return null;
} }
@ -147,7 +177,17 @@ function normalizeVideoAnalysisRows(
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1)) ? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
: index % Math.max(framePreviews.length, 1); : index % Math.max(framePreviews.length, 1);
return { return {
shot: shot || String(index + 1),
startTime: startTime || (index === 0 ? "0" : ""),
endTime,
duration,
sceneDescription: sceneDescription || `镜头 ${index + 1}`, sceneDescription: sceneDescription || `镜头 ${index + 1}`,
narrative: narrative || sceneDescription || `镜头 ${index + 1}`,
shotSize: shotSize || "中景至近景",
cameraAngle: cameraAngle || "平视",
cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)",
focusDepth: focusDepth || "浅景深",
lighting: lighting || "冷调低沉照明,主体光线柔和。",
backgroundMusic: backgroundMusic || "无", backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无", voiceAndSfx: voiceAndSfx || "无",
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧", imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
@ -159,7 +199,17 @@ function normalizeVideoAnalysisRows(
( (
item, item,
): item is { ): item is {
shot: string;
startTime: string;
endTime: string;
duration: string;
sceneDescription: string; sceneDescription: string;
narrative: string;
shotSize: string;
cameraAngle: string;
cameraMovement: string;
focusDepth: string;
lighting: string;
backgroundMusic: string; backgroundMusic: string;
voiceAndSfx: string; voiceAndSfx: string;
imagePrompt: string; imagePrompt: string;
@ -170,6 +220,265 @@ function normalizeVideoAnalysisRows(
.slice(0, 8); .slice(0, 8);
} }
function normalizeStoryShot(value: unknown, index: number) {
const text = normalizeText(value);
if (/^\d+$/.test(text)) return text.padStart(2, "0");
return text || String(index + 1).padStart(2, "0");
}
type NormalizedStoryScriptRow = {
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;
};
function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => {
if (Array.isArray(item)) {
const cells = item.map((cell) => normalizeText(cell));
const shot = normalizeStoryShot(cells[0], index);
const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || "");
const duration = secondCellLooksLikeDuration ? cells[1] : "5s";
const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`;
const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene;
return {
shot,
duration,
scene,
sceneDescription,
shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景",
lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影",
dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+",
backgroundMusic: "无",
voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无",
cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头",
imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription,
motionPrompt:
(secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) ||
"保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
}
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index);
const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds);
const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`);
const sceneDescription = normalizeText(
raw.sceneDescription ||
raw.description ||
raw.visual ||
raw.picture ||
raw.screen ||
raw.summary,
);
const shotSize = normalizeText(
raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot,
);
const lighting = normalizeText(
raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood,
);
const dialogue = normalizeText(
raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line,
);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion,
);
const imagePrompt = normalizeText(
raw.imagePrompt ||
raw.image_generation_prompt ||
raw.image ||
raw.keyframePrompt ||
raw.keyFramePrompt,
);
const motionPrompt = normalizeText(
raw.motionPrompt ||
raw.videoPrompt ||
raw.video_motion_prompt ||
raw.motion ||
raw.cameraMovement,
);
const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack,
);
const voiceAndSfx = normalizeText(
raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx,
);
if (
!scene &&
!sceneDescription &&
!imagePrompt &&
!motionPrompt &&
!backgroundMusic &&
!voiceAndSfx
) {
return null;
}
return {
shot,
duration: duration || "5s",
scene,
sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`,
shotSize: shotSize || "中景",
lighting: lighting || "电影感光影",
dialogue: dialogue || "+",
backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无",
cameraMovement: cameraMovement || "缓慢推镜头",
imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`,
motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
})
.filter(
(
item,
): item is NormalizedStoryScriptRow => Boolean(item),
)
.slice(0, 18);
}
const storyExpansionPhases = [
{
description: "建立镜头,交代环境、人物位置和当前压力",
cameraMovement: "缓慢推镜头",
imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确",
lighting: "环境主光明确,氛围基调清晰",
motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系",
shotSize: "中景",
},
{
description: "动作推进,突出人物的关键行为或剧情转折",
cameraMovement: "跟随主体动作推进",
imagePrompt: "中景表现人物动作,服装、道具和光影保持连续",
lighting: "主体光影连续,动作区域清晰",
motionPrompt: "跟随主体动作推进,节奏自然连贯",
shotSize: "中景",
},
{
description: "情绪反应,用近景或特写强化人物心理和冲突",
cameraMovement: "轻微推近或稳定特写",
imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节",
lighting: "面部光线突出,情绪反差增强",
motionPrompt: "轻微推近或稳定特写,强调情绪变化",
shotSize: "特写",
},
{
description: "关系与氛围,表现周围人物、环境反馈或声画张力",
cameraMovement: "横移扫过环境或切换视角",
imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影",
lighting: "场景氛围增强,空间层次明显",
motionPrompt: "镜头扫过环境或切换视角,保持节奏递进",
shotSize: "全景",
},
{
description: "转场收束,为下一镜头保留动作、视线或情绪钩子",
cameraMovement: "顺势后移、上摇或转场",
imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情",
lighting: "光影引导视线并保留转场方向",
motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头",
shotSize: "大远景",
},
];
function expandStoryScriptRowsToTarget(
rows: NormalizedStoryScriptRow[],
targetCount = 18,
): NormalizedStoryScriptRow[] {
if (rows.length === 0) return [];
if (rows.length >= targetCount) {
return rows.slice(0, targetCount).map((row, index) => ({
...row,
shot: String(index + 1).padStart(2, "0"),
}));
}
const baseCount = Math.floor(targetCount / rows.length);
const extraCount = targetCount % rows.length;
const expanded: NormalizedStoryScriptRow[] = [];
rows.forEach((row, rowIndex) => {
const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0);
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const phase =
storyExpansionPhases[
Math.min(
storyExpansionPhases.length - 1,
Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length),
)
];
expanded.push({
...row,
shot: String(expanded.length + 1).padStart(2, "0"),
sceneDescription: `${phase.description}:${row.sceneDescription}`,
shotSize: row.shotSize || phase.shotSize,
lighting: row.lighting || phase.lighting,
cameraMovement: row.cameraMovement || phase.cameraMovement,
imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`,
motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`,
});
}
});
return expanded.slice(0, targetCount);
}
function buildLocalStoryScriptRows(): NormalizedStoryScriptRow[] {
const rows = [
["01", "5s", "现代办公室", "深夜办公室里,办公电脑发出刺眼的蓝光。@现代沈昭昭伏案加班,面色苍白,表情极度疲惫,双手在键盘上飞速敲击。", "中景", "冷蓝灰低饱和,屏幕蓝光照亮脸部,压抑疲惫", "+", "急促沉闷的键盘敲击声,连续的手机提示音", "缓慢推镜头"],
["02", "5s", "现代办公室", "@现代沈昭昭抬头看向屏幕,眼神涣散,视线逐渐模糊,双眼缓缓闭上。画面瞬间陷入黑屏。", "特写", "光线逐渐变暗直至完全黑屏", "+", "心跳声骤停,刺耳的耳鸣声", "固定,焦点逐渐模糊"],
["03", "6s", "盛唐·金銮殿·日", "@金銮殿内,强烈的阳光从殿门射入。@沈昭昭猛然睁开眼睛,发现自己身着男装官服,正双手被押跪在大殿中央。", "全景", "极其明亮的暖金色调,反差强烈的庄严感", "+", "震天的威武鼓声,空旷的大殿回音", "快速摇镜头,从下往上展现大殿"],
["04", "5s", "盛唐·金銮殿·日", "镜头转向一侧,一名紫袍大臣满脸怒容,指着@沈昭昭厉声斥责,步步紧逼。", "中近景", "暖金主光带阴影,气氛紧张压抑", "大逆不道!假称诗才惊世,欺君罔上,当斩!", "脚步声重重踏在木地板上", "手持微晃跟拍"],
["05", "5s", "盛唐·金銮殿·日", "@沈昭昭跪在地上,满脸震惊与茫然,瞳孔微震,低声吐出几个字。", "特写", "暖金光影,充满不安感", "这是……唐朝?", "急促的呼吸声,环境静谧", "平滑微推"],
["06", "6s", "盛唐·金銮殿·日", "高高的龙椅之上,@皇帝目光沉沉地俯视下方,神情威严,极具压迫感地发话。", "中景", "金黄色主调,庄重威严不可侵犯", "既言才华盖世,当殿作诗。若不能--斩。", "衣物摩擦声,低沉浑厚的嗓音", "缓慢推镜头,轻微仰拍"],
["07", "5s", "盛唐·金銮殿·日", "随着话音落下,侍卫猛然拔出@侍卫长刀,冰冷的刀光瞬间映照在@沈昭昭惊恐的瞳孔中。", "大特写", "强烈的明暗对比,刀光如电", "+", "清脆刺耳的金属拔刀声,全场死寂", "极快推向眼睛"],
["08", "6s", "盛唐·金銮殿·日", "大殿死寂中,@沈昭昭闭上眼睛深呼吸,顶着寒芒缓缓站直身体,表情从慌乱彻底变为镇定。", "近景", "暖金光打在背上,驱散阴影,气场反转", "+", "绵长深沉的呼吸声", "伴随人物起身向上摇镜头"],
["09", "5s", "盛唐·金銮殿·日", "顺着她的视线看去,越过敞开的殿门,外面是壮丽的@长安城白昼,阳光万里,气象万千。", "大远景", "极度开阔明亮,充满生机与宏大感", "+", "微风声,隐约的市井钟声", "从室内越过门框向前推进"],
["10", "5s", "盛唐·金銮殿·日", "@沈昭昭目光变得极其锐利,嘴角勾起一抹自信的微笑,轻声低语宣告决心。", "特写", "暖光充盈,充满一触即发的力量感", "既来盛唐……便与盛唐争一争。", "心跳声重新响起,坚定有力", "静止,稳定构图"],
["11", "6s", "盛唐·金銮殿·日", "@沈昭昭猛然抬头,张开双臂,以极具爆发力的姿态向着殿外高声吟诵千古绝句。", "中景", "光芒万丈,极具震撼的视觉冲击力", "君不见黄河之水天上来--", "极具穿透力的朗诵声,音浪轰鸣", "极速拉开并转为轻微仰拍"],
["12", "5s", "盛唐·金銮殿·日", "诗句如惊雷般在殿内炸开,两旁的群臣面面相觑,脸上露出极度震惊与不可思议的表情。", "全景", "暖金光影,局势瞬间颠覆", "+", "倒吸凉气声,慌乱的窃窃私语", "快速平移横扫群臣"],
["13", "5s", "盛唐·金銮殿·日", "原本端坐的@皇帝被这惊世骇俗的诗句彻底震撼,双眼大睁,双手紧抓龙椅扶手,身体前倾缓缓站起。", "中近景", "皇帝周围的气压仿佛被点燃,极具戏剧张力", "+", "沉重的衣袍摩擦声,王座轻微吱呀声", "缓慢向上推,跟随起身动势"],
["14", "5s", "盛唐·金銮殿·日", "伴随着连绵不断的吟诵,殿外忽起大风吹入大殿,@沈昭昭青绿色的衣袖疯狂翻飞,宛如文曲星降世。", "全景", "风起云涌,光影随风摇曳,豪情万丈", "+", "呼啸的风声,激昂的背景音乐推向高潮", "环绕轨道跟拍"],
["15", "5s", "长安城夜景", "画面通过殿门的光影流转,平滑转场至@长安城夜景,万家灯火连绵不绝,孔明灯升空。", "大远景", "浪漫璀璨,时间流逝后的盛世繁华", "+", "余音绕梁的空灵回声,悠扬古风音乐", "向上缓慢摇升"],
["16", "5s", "盛唐·金銮殿·夜", "@皇帝站在大殿边缘,背负双手眺望远方夜色,脸上满是折服,低声给出至高定论。", "中景", "深沉平静,尽显惜才之心", "此诗,当传万世。", "夜风拂过,语气深沉悠远", "静止,人物居中侧面"],
["17", "5s", "长安城夜景", "@沈昭昭并肩看着殿外的万家灯火,目光中再无一丝迷茫,心中燃起万丈豪情,轻声立下宏愿。", "近景", "充满希望与野心,情绪达到最高点", "既然来了……那便写尽三万里长安。", "轻柔但坚定的吐字声", "侧后方缓缓推近面部侧颜"],
["18", "6s", "长安城夜景", "镜头快速向高空推远,整个@长安城夜景与无尽的盛唐宏大山河画卷徐徐铺陈开来,气吞山河。", "大远景", "大气磅礴,余韵无穷的史诗质感", "+", "激昂澎湃的古风管弦乐骤然爆发,随后戛然而止留白", "极速拉高、后退,展露全貌"],
];
return rows.map(([shot, duration, scene, sceneDescription, shotSize, lighting, dialogue, voiceAndSfx, cameraMovement], index) => ({
shot,
duration,
scene,
sceneDescription,
shotSize,
lighting,
dialogue,
backgroundMusic: index >= 10 ? "激昂古风管弦乐" : "低频史诗氛围",
voiceAndSfx,
cameraMovement,
imagePrompt: `${shotSize},${sceneDescription} ${lighting},构图清晰,角色与场景资产保持一致。[视觉风格:盛唐·国风二次元]`,
motionPrompt: `起始状态:${sceneDescription} 动作过程:${cameraMovement},主体动作自然连贯。结束状态:画面情绪与下一镜头衔接。音效:${voiceAndSfx}。[视觉风格:盛唐·国风二次元]`,
keyframeUrl: "",
}));
}
function normalizeCharacters(value: unknown) { function normalizeCharacters(value: unknown) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value
@ -243,11 +552,21 @@ function buildVideoAnalysisPrompt(params: {
"highlights": ["3-5条镜头分析要点"], "highlights": ["3-5条镜头分析要点"],
"analysisRows": [ "analysisRows": [
{ {
"sceneDescription": "镜头内容与景别描述", "shot": "1",
"startTime": "0",
"endTime": "5",
"duration": "5",
"sceneDescription": "画面描述,说明主体、环境、动作、构图和色调",
"narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用",
"shotSize": "景别,例如中景至近景",
"cameraAngle": "摄影机角度,例如平视",
"cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)",
"focusDepth": "焦距与景深,例如浅景深",
"lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光",
"backgroundMusic": "这一段适合的背景音乐氛围", "backgroundMusic": "这一段适合的背景音乐氛围",
"voiceAndSfx": "人声/环境声/音效建议,没有就写无", "voiceAndSfx": "人声/环境声/音效建议,没有就写无",
"imagePrompt": "后续生成关键帧时可直接使用的图像提示词", "imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的运动提示词", "motionPrompt": "后续生成视频时可直接使用的英文运动提示词",
"keyframeIndex": 1 "keyframeIndex": 1
} }
], ],
@ -257,7 +576,9 @@ function buildVideoAnalysisPrompt(params: {
"visualMotifs": ["2-4个可复用的视觉母题"], "visualMotifs": ["2-4个可复用的视觉母题"],
"recommendedNextSteps": ["2-4条建议的下游动作"] "recommendedNextSteps": ["2-4条建议的下游动作"]
}`, }`,
"analysisRows 请尽量输出 2-6 行;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。", "analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。", "如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
] ]
.filter(Boolean) .filter(Boolean)
@ -437,13 +758,63 @@ function buildAgentPlanPrompt(params: {
.join("\n"); .join("\n");
} }
function buildStoryScriptPrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
videoCount: number;
}) {
return [
"你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`参考图数量:${params.imageCount}`,
`视频数量:${params.videoCount}`,
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话概括故事主题、情绪和视频方向,不超过120字",
"analysisRows": [
{
"shot": "01",
"duration": "5s",
"scene": "场景名或地点,如 盛唐·金銮殿·日",
"sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段",
"shotSize": "景别,如中景、特写、全景、大远景",
"lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑",
"dialogue": "对白或旁白,没有就写 +",
"backgroundMusic": "背景音乐或情绪氛围,没有就写无",
"voiceAndSfx": "环境声、动作声或音效,没有就写无",
"cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍",
"imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词"
}
],
"assetCandidates": {
"style": "全局视觉风格,如 盛唐·国风二次元",
"characters": ["需要生成的角色资产名"],
"scenes": ["需要生成的场景资产名"],
"props": ["需要生成的道具资产名"]
},
"shotIdeas": [
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。",
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。",
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
]
.filter(Boolean)
.join("\n");
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const parsed = requestSchema.safeParse(body); const parsed = requestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
@ -468,6 +839,35 @@ export async function POST(request: Request) {
); );
} }
if (shouldUseLocalWorkflowE2E(request) && parsed.data.mode === "story-script") {
const analysisRows = buildLocalStoryScriptRows();
return NextResponse.json({
success: true,
data: {
summary: "本地测试脚本:现代职场女性穿越盛唐,在金銮殿以诗才逆转死局并立下宏愿。",
analysisRows,
assetCandidates: {
style: "盛唐·国风二次元",
characters: ["现代沈昭昭", "沈昭昭", "皇帝"],
scenes: ["现代办公室", "盛唐·金銮殿", "长安城白昼", "长安城夜景"],
props: ["侍卫长刀"],
},
shotIdeas: analysisRows.map((row) => ({
title: `镜头 ${row.shot}`,
description: row.sceneDescription,
})),
recommendedNextSteps: ["准备角色/场景/道具资产", "合成分镜图提示词", "批量生成分镜", "批量生视频"],
model: "Local E2E Story Script",
raw: JSON.stringify({ analysisRows }, null, 2),
},
});
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const [allModels, agentModel] = await Promise.all([ const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false), getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(), getAgentAiModelWithProvider(),
@ -534,7 +934,14 @@ export async function POST(request: Request) {
prompt, prompt,
imageCount: imageUrls.length, imageCount: imageUrls.length,
}) })
: buildAgentPlanPrompt({ : parsed.data.mode === "story-script"
? buildStoryScriptPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
videoCount: videoUrls.length,
})
: buildAgentPlanPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle), nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt, prompt,
imageCount: imageUrls.length, imageCount: imageUrls.length,
@ -568,7 +975,7 @@ export async function POST(request: Request) {
}, },
], ],
temperature: 0.2, temperature: 0.2,
maxTokens: 1600, maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
} as any); } as any);
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {}; const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
@ -619,6 +1026,29 @@ export async function POST(request: Request) {
}); });
} }
if (parsed.data.mode === "story-script") {
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows(
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
));
return NextResponse.json({
success: true,
data: {
summary,
analysisRows,
shotIdeas:
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
? normalizeShotIdeas(parsedJson.shotIdeas)
: analysisRows.map((row, index) => ({
title: `镜头 ${index + 1}`,
description: row.sceneDescription,
})),
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {

View File

@ -83,6 +83,15 @@ async function readLocalFallbackFavicon() {
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
if (
process.env.CANVAS_E2E_LOCAL === "true" ||
requestUrl.hostname === "127.0.0.1" ||
requestUrl.hostname === "localhost" ||
requestUrl.hostname === "::1"
) {
return await readLocalFallbackFavicon();
}
const preferDark = requestUrl.searchParams.get("theme") === "dark"; const preferDark = requestUrl.searchParams.get("theme") === "dark";
const settings = await getSiteSettings(); const settings = await getSiteSettings();
const configuredFavicon = String(settings?.favicon || "").trim(); const configuredFavicon = String(settings?.favicon || "").trim();

View File

@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
type AccessLogSeverity,
recordAccessIpEvent,
} from "@/lib/security/access-log";
export const runtime = "nodejs";
export const maxDuration = 10;
const payloadSchema = z.object({
eventType: z.string().trim().max(32).optional(),
ip: z.string().trim().max(128).optional().nullable(),
userId: z.string().trim().uuid().optional().nullable(),
userEmail: z.string().trim().max(128).optional().nullable(),
userRole: z.string().trim().max(32).optional().nullable(),
method: z.string().trim().max(12).optional().nullable(),
path: z.string().trim().max(256).optional().nullable(),
routeGroup: z.string().trim().max(64).optional().nullable(),
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
outcome: z.string().trim().max(32).optional().nullable(),
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
userAgent: z.string().trim().max(512).optional().nullable(),
referer: z.string().trim().max(512).optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
});
const suspiciousPathPatterns = [
/\.env/i,
/\/\.git/i,
/\/wp-admin/i,
/\/phpmyadmin/i,
/\/actuator/i,
/\/vendor\/phpunit/i,
/\/eval-stdin\.php/i,
/\/containers\/json/i,
/\/index\.php/i,
];
const suspiciousUserAgentPatterns = [
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
/wget|curl|python-requests|go-http-client/i,
/nuclei|crawlergo|httpx/i,
];
function pushReason(reasons: string[], reason: string) {
if (!reasons.includes(reason)) reasons.push(reason);
}
function rankSeverity(value: AccessLogSeverity) {
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
}
function maxSeverity(
current: AccessLogSeverity,
next: AccessLogSeverity
): AccessLogSeverity {
return rankSeverity(next) > rankSeverity(current) ? next : current;
}
function classifyPayload(
payload: z.infer<typeof payloadSchema>
): { severity: AccessLogSeverity; reasons: string[] } {
let severity: AccessLogSeverity = payload.severity || "info";
const reasons = [...(payload.reasons || [])];
const path = payload.path || "";
const userAgent = payload.userAgent || "";
const statusCode = payload.statusCode || 0;
const outcome = payload.outcome || "allowed";
if (outcome === "rate_limited") {
severity = maxSeverity(severity, "high");
pushReason(reasons, "触发访问频率限制");
}
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "出现未授权访问");
}
if (statusCode === 429) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "返回 429 限流");
} else if (statusCode >= 500) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "触发服务端错误");
} else if (statusCode >= 400) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "返回 4xx 异常状态");
}
if (path.startsWith("/api/auth") && statusCode >= 400) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "认证接口失败");
}
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "命中敏感探测路径");
}
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "User-Agent 异常");
}
if (reasons.includes("静态黑名单命中但未封禁")) {
severity = maxSeverity(severity, "high");
}
return {
severity,
reasons: Array.from(new Set(reasons)).slice(0, 8),
};
}
function getExpectedSecret() {
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
}
export async function POST(request: Request) {
const expectedSecret = getExpectedSecret();
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
if (!expectedSecret || providedSecret !== expectedSecret) {
return new NextResponse("Not found", { status: 404 });
}
const raw = await request.json().catch(() => null);
const parsed = payloadSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ ok: true });
}
const classification = classifyPayload(parsed.data);
await recordAccessIpEvent({
...parsed.data,
severity: classification.severity,
reasons: classification.reasons,
}).catch((error) => {
console.warn("[AccessIpEvent] failed to record access event", error);
});
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,140 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist } from "@/lib/db/schema";
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 5;
const payloadSchema = z.object({
ip: z.string().trim().max(128).optional().nullable(),
});
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
let blocklistUnavailableUntil = 0;
let blocklistMissingTableWarned = false;
function getExpectedSecret() {
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
}
function getErrorCode(error: unknown) {
const directCode =
error && typeof error === "object"
? (error as { code?: unknown }).code
: null;
if (typeof directCode === "string") return directCode;
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
const causeCode =
cause && typeof cause === "object"
? (cause as { code?: unknown }).code
: null;
return typeof causeCode === "string" ? causeCode : null;
}
function getErrorMessage(error: unknown) {
const messages: string[] = [];
if (error instanceof Error) messages.push(error.message);
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
if (cause instanceof Error) messages.push(cause.message);
if (!messages.length) messages.push(String(error));
return messages.join("\n");
}
function isMissingAccessIpBlocklistTableError(error: unknown) {
if (getErrorCode(error) === "42P01") return true;
const message = getErrorMessage(error);
return (
message.includes('relation "AccessIpBlocklist" does not exist') ||
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
);
}
function markBlocklistUnavailable(error: unknown) {
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
if (blocklistMissingTableWarned) return;
blocklistMissingTableWarned = true;
console.warn(
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
getErrorMessage(error)
);
}
export async function POST(request: Request) {
const expectedSecret = getExpectedSecret();
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
if (!expectedSecret || providedSecret !== expectedSecret) {
return new NextResponse("Not found", { status: 404 });
}
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success || !parsed.data.ip) {
return NextResponse.json({ blocked: false });
}
if (
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
Date.now() < blocklistUnavailableUntil
) {
return NextResponse.json({ blocked: false });
}
try {
const now = new Date();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
expiresAt: accessIpBlocklist.expiresAt,
})
.from(accessIpBlocklist)
.where(
and(
eq(accessIpBlocklist.isActive, true),
or(
isNull(accessIpBlocklist.expiresAt),
gt(accessIpBlocklist.expiresAt, now)
)
)
)
.limit(1000);
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
return NextResponse.json({
blocked: Boolean(matched),
entry: matched
? {
id: matched.id,
ip: matched.ip,
reason: matched.reason || null,
expiresAt: matched.expiresAt || null,
}
: null,
});
} catch (error) {
if (isMissingAccessIpBlocklistTableError(error)) {
markBlocklistUnavailable(error);
return NextResponse.json({ blocked: false });
}
console.warn("[AccessIpBlocklist] internal check failed", error);
return NextResponse.json({ blocked: false });
}
}

View File

@ -1,8 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { getSystemSetting } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import sharp from "sharp"; import sharp from "sharp";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -21,10 +27,12 @@ interface PickObjectRequest {
imageData: string; // 带红色标记的图片 base64 imageData: string; // 带红色标记的图片 base64
clickX: number; // 点击位置 X (0-1) clickX: number; // 点击位置 X (0-1)
clickY: number; // 点击位置 Y (0-1) clickY: number; // 点击位置 Y (0-1)
mode?: "object" | "video-canvas-mark";
} }
interface PickObjectResult { interface PickObjectResult {
name: string; name: string;
labels?: string[];
position: string; position: string;
confidence: number; confidence: number;
boundingBox: { boundingBox: {
@ -119,7 +127,51 @@ async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string)
return uploaded.url; return uploaded.url;
} }
async function normalizeImageInput(imageData: string): Promise<string> { async function prepareMarkedVisionAnalysisImage(
buffer: Buffer,
clickX: number,
clickY: number
) {
const prepared = await downscaleImageForVision(buffer);
const metadata = await sharp(prepared.buffer, { failOn: "none" }).metadata();
const width = Math.max(1, metadata.width || prepared.width || MAX_VISION_SIDE);
const height = Math.max(1, metadata.height || prepared.height || MAX_VISION_SIDE);
const markerX = Math.max(0, Math.min(width, clickX * width));
const markerY = Math.max(0, Math.min(height, clickY * height));
const radius = Math.max(10, Math.min(width, height) * 0.025);
const overlay = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${markerX}" cy="${markerY}" r="${radius + 3}" fill="#fff" />
<circle cx="${markerX}" cy="${markerY}" r="${radius}" fill="#f00" />
<g stroke="#f00" stroke-width="2">
<line x1="${markerX - radius * 2}" y1="${markerY}" x2="${markerX - radius - 2}" y2="${markerY}" />
<line x1="${markerX + radius + 2}" y1="${markerY}" x2="${markerX + radius * 2}" y2="${markerY}" />
<line x1="${markerX}" y1="${markerY - radius * 2}" x2="${markerX}" y2="${markerY - radius - 2}" />
<line x1="${markerX}" y1="${markerY + radius + 2}" x2="${markerX}" y2="${markerY + radius * 2}" />
</g>
</svg>
`);
const composited = sharp(prepared.buffer, { failOn: "none" }).composite([
{ input: overlay, left: 0, top: 0 },
]);
const markedBuffer =
prepared.ext === "png"
? await composited.png({ compressionLevel: 9 }).toBuffer()
: await composited.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
logger.info("marked vision image prepared", {
clickX,
clickY,
outputBytes: markedBuffer.length,
outputExt: prepared.ext,
});
const mimeType = prepared.ext === "png" ? "image/png" : "image/jpeg";
return `data:${mimeType};base64,${markedBuffer.toString("base64")}`;
}
async function normalizeImageInput(
imageData: string,
options?: { clickX: number; clickY: number; markPoint: boolean }
): Promise<string> {
const raw = String(imageData || "").trim(); const raw = String(imageData || "").trim();
if (!raw) throw new Error("缺少图片数据"); if (!raw) throw new Error("缺少图片数据");
@ -145,7 +197,10 @@ async function normalizeImageInput(imageData: string): Promise<string> {
} }
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
ensureImageSizeAllowed(arrayBuffer.byteLength); ensureImageSizeAllowed(arrayBuffer.byteLength);
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url"); const buffer = Buffer.from(arrayBuffer);
return options?.markPoint
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
: uploadVisionAnalysisImage(buffer, "pick-object-url");
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
@ -160,7 +215,9 @@ async function normalizeImageInput(imageData: string): Promise<string> {
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4)); ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
const buffer = Buffer.from(base64Body, "base64"); const buffer = Buffer.from(base64Body, "base64");
ensureImageSizeAllowed(buffer.byteLength); ensureImageSizeAllowed(buffer.byteLength);
return uploadVisionAnalysisImage(buffer, "pick-object-base64"); return options?.markPoint
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
: uploadVisionAnalysisImage(buffer, "pick-object-base64");
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -174,7 +231,7 @@ export async function POST(request: Request) {
} }
const body: PickObjectRequest = await request.json(); const body: PickObjectRequest = await request.json();
const { imageData, clickX, clickY } = body; const { imageData, clickX, clickY, mode = "object" } = body;
logger.info("request received", { logger.info("request received", {
imageDataLength: imageData?.length, imageDataLength: imageData?.length,
@ -204,12 +261,34 @@ export async function POST(request: Request) {
}, { status: 400 }); }, { status: 400 });
} }
const normalizedImage = await normalizeImageInput(imageData); const normalizedImage = await normalizeImageInput(imageData, {
clickX,
clickY,
markPoint: mode === "video-canvas-mark",
});
logger.info("vision model selected", { model: "qwen3-vl" }); logger.info("vision model selected", { model: "qwen3-vl" });
// 构建 prompt - AI 通过红色标记来识别用户选择的物品 const videoCanvasConfig =
const prompt = ` mode === "video-canvas-mark"
? normalizeVideoCanvasFunctionConfig(
(
await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => null
)
)?.value || defaultVideoCanvasFunctionConfig
)
: null;
const elementMarkDefinition = videoCanvasConfig?.functions["element-mark"];
if (mode === "video-canvas-mark" && elementMarkDefinition?.enabled === false) {
return NextResponse.json(
{ success: false, error: "视频元素标记功能当前未启用" },
{ status: 403 }
);
}
// AI 通过红色标记识别用户点中的主体;视频画布规则来自系统设置。
const defaultObjectPrompt = `
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。 图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
【重要】 【重要】
@ -232,12 +311,24 @@ export async function POST(request: Request) {
【position 格式】固定4字,从以下选择: 【position 格式】固定4字,从以下选择:
${POSITION_OPTIONS.join('、')} ${POSITION_OPTIONS.join('、')}
`.trim(); `.trim();
const prompt =
mode === "video-canvas-mark"
? String(
elementMarkDefinition?.prompt ||
defaultVideoCanvasFunctionConfig.functions["element-mark"]?.prompt ||
defaultObjectPrompt
).replaceAll("{{positionOptions}}", POSITION_OPTIONS.join("、"))
: defaultObjectPrompt;
logger.info("vision model call started"); logger.info("vision model call started");
// 调用视觉模型,发送带红色标记的图片 // 调用视觉模型,发送带红色标记的图片
const { text: responseText, model } = await callQwen3Vl({ const { text: responseText, model } = await callQwen3Vl({
imageUrl: normalizedImage, imageUrl: normalizedImage,
model:
mode === "video-canvas-mark"
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
: undefined,
prompt, prompt,
temperature: 0.1, temperature: 0.1,
maxTokens: 300, maxTokens: 300,
@ -266,7 +357,17 @@ ${POSITION_OPTIONS.join('、')}
const parsed = JSON.parse(jsonMatch[0]); const parsed = JSON.parse(jsonMatch[0]);
// 验证和修正名称 // 验证和修正名称
let name = parsed.name || '物品'; const labels =
mode === "video-canvas-mark" && Array.isArray(parsed.labels)
? Array.from(
new Set(
parsed.labels
.map((item: unknown) => String(item || "").trim().slice(0, 8))
.filter(Boolean)
)
).slice(0, 4)
: [];
let name = labels[0] || parsed.name || '物品';
if (name.length > 8) { if (name.length > 8) {
name = name.substring(0, 6); name = name.substring(0, 6);
} }
@ -292,6 +393,7 @@ ${POSITION_OPTIONS.join('、')}
result = { result = {
name, name,
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
position, position,
confidence, confidence,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容 boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
@ -312,6 +414,7 @@ ${POSITION_OPTIONS.join('、')}
result = { result = {
name: '物品', name: '物品',
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
position, position,
confidence: 0.5, confidence: 0.5,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },

View File

@ -1010,6 +1010,36 @@ export async function GET(request: NextRequest) {
try { try {
const startedAt = Date.now(); const startedAt = Date.now();
const scope = request.nextUrl.searchParams.get("scope") || ""; const scope = request.nextUrl.searchParams.get("scope") || "";
const isLocalCanvasE2E =
process.env.CANVAS_E2E_LOCAL === "true" ||
request.nextUrl.hostname === "127.0.0.1" ||
request.nextUrl.hostname === "localhost" ||
request.nextUrl.hostname === "::1";
if (
isLocalCanvasE2E &&
PUBLIC_SITE_SETTINGS_SCOPES.has(scope)
) {
const scopedScope = scope as PublicSiteSettingsScope;
const data =
scopedScope === "branding"
? createBrandingSettings(undefined)
: {
...HOME_SITE_SETTINGS_FALLBACK,
canvasEnabled: true,
realtimeCanvasEnabled: true,
videoCanvasEnabled: true,
};
return createScopedSiteSettingsResponse(
scopedScope,
data,
"FALLBACK",
`local-e2e:${scopedScope}`,
{ fallback: true, startedAt }
);
}
if (PUBLIC_SITE_SETTINGS_SCOPES.has(scope)) { if (PUBLIC_SITE_SETTINGS_SCOPES.has(scope)) {
const scopedScope = scope as PublicSiteSettingsScope; const scopedScope = scope as PublicSiteSettingsScope;
const brandName = const brandName =

View File

@ -17,12 +17,40 @@ import {
normalizeMembershipUserOverrides, normalizeMembershipUserOverrides,
resolveMembershipPlanIdForUser, resolveMembershipPlanIdForUser,
} from "@/lib/membership/config"; } from "@/lib/membership/config";
import { defaultPointsSystemConfig } from "@/lib/points/config";
const MEMBERSHIP_CONFIG_KEY = "membership_config"; const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides"; const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
export async function GET() { export async function GET(request: Request) {
try { try {
const requestHost = new URL(request.url).hostname;
const isLocalCanvasE2E =
process.env.CANVAS_E2E_LOCAL === "true" ||
requestHost === "127.0.0.1" ||
requestHost === "localhost" ||
requestHost === "::1";
if (isLocalCanvasE2E) {
const currentPlan = getMembershipPlanById(defaultMembershipConfig, "pro");
return NextResponse.json({
config: defaultMembershipConfig,
pointsSystemConfig: defaultPointsSystemConfig,
isHighVip: true,
currentPlanId: currentPlan.id,
currentPlan,
summary: {
currentCredits: currentPlan.monthlyCredits,
dailyFreeCredits: currentPlan.dailyFreeCredits,
usedThisMonth: 0,
monthlyLimit: currentPlan.monthlyCredits,
remainingThisMonth: currentPlan.monthlyCredits,
totalImages: 0,
},
updatedAt: new Date().toISOString(),
});
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {

View File

@ -8,6 +8,10 @@ import {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
if (process.env.CANVAS_E2E_LOCAL === "true") {
return Response.json({ config: defaultVideoCanvasFunctionConfig });
}
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null); const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
const config = setting?.value const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value) ? normalizeVideoCanvasFunctionConfig(setting.value)

View File

@ -1135,7 +1135,7 @@ function normalizeImportedCanvasState(payload: unknown): {
}; };
} }
const FLOATING_PANEL_WIDTH = 400; const FLOATING_PANEL_WIDTH = 380;
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75; const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1"; const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
const FLOATING_PANEL_INSET = 16; const FLOATING_PANEL_INSET = 16;
@ -1985,8 +1985,7 @@ export default function CanvasEditorLegacyPage({
] = useState(false); ] = useState(false);
const [isFloatingPanelResizing, setIsFloatingPanelResizing] = const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
useState(false); useState(false);
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] = const areSecondaryPanelsReady = true;
useState(false);
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
useState(false); useState(false);
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState< const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
@ -3279,16 +3278,16 @@ export default function CanvasEditorLegacyPage({
}; };
}, [checkCanvasAuthSession]); }, [checkCanvasAuthSession]);
useEffect(() => { useEffect(
if (!isPanelOpen && !isVideoCanvasFileListOpen) { () =>
return; scheduleCanvasIdleTask(() => {
} void Promise.all([
import("@/components/canvas/CanvasChatPanel"),
const timeoutId = window.setTimeout(() => { import("@/components/canvas/CanvasFileListPanel"),
setAreSecondaryPanelsReady(true); ]);
}, 120); }, 700),
return () => window.clearTimeout(timeoutId); []
}, [isPanelOpen, isVideoCanvasFileListOpen]); );
const hydrateCanvasProject = useCallback( const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => { (project: CanvasInitialProject) => {
@ -10780,7 +10779,7 @@ export default function CanvasEditorLegacyPage({
> >
<Button <Button
className={cn( className={cn(
"h-10 rounded-full px-3 font-medium text-sm", "h-8 rounded-full px-2.5 text-[12px] font-medium",
isVideoMode isVideoMode
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82" ? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background" : "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
@ -10788,7 +10787,7 @@ export default function CanvasEditorLegacyPage({
onClick={() => setIsPanelOpen(true)} onClick={() => setIsPanelOpen(true)}
variant="ghost" variant="ghost"
> >
<MessageCircle className="mr-1.5 h-4 w-4" /> <MessageCircle className="mr-1 h-3.5 w-3.5" />
对话 对话
</Button> </Button>
</div> </div>
@ -10801,7 +10800,7 @@ export default function CanvasEditorLegacyPage({
"absolute z-10 ease-in-out", "absolute z-10 ease-in-out",
isFloatingPanelResizing isFloatingPanelResizing
? "transition-none" ? "transition-none"
: "transition-all duration-300", : "transition-all duration-150",
isPanelOpen isPanelOpen
? "translate-x-0 opacity-100" ? "translate-x-0 opacity-100"
: "pointer-events-none translate-x-[120%] opacity-0" : "pointer-events-none translate-x-[120%] opacity-0"

View File

@ -66,7 +66,7 @@ export function CanvasPageClientRouter({
if (isVideoMode) { if (isVideoMode) {
return ( return (
<CanvasLoadingBrandProvider value={loadingBrand}> <CanvasLoadingBrandProvider value={loadingBrand}>
<LiblibVideoCanvasReplica canvasId={id} /> <LiblibVideoCanvasReplica canvasId={id} initialProject={initialProject} />
</CanvasLoadingBrandProvider> </CanvasLoadingBrandProvider>
); );
} }

View File

@ -50,10 +50,13 @@ export default async function CanvasEditorPage({
}: CanvasEditorPageProps) { }: CanvasEditorPageProps) {
const { id } = await params; const { id } = await params;
const resolvedSearchParams = searchParams ? await searchParams : {}; const resolvedSearchParams = searchParams ? await searchParams : {};
const [siteSettings, initialProject] = await Promise.all([ const bypassLocalDatabase = process.env.CANVAS_E2E_LOCAL === "true";
getSiteSettings().catch(() => null), const [siteSettings, initialProject] = bypassLocalDatabase
getInitialCanvasProject(id).catch(() => null), ? [null, null]
]); : await Promise.all([
getSiteSettings().catch(() => null),
getInitialCanvasProject(id).catch(() => null),
]);
const loadingBrand = { const loadingBrand = {
siteLogo: siteSettings?.siteLogo, siteLogo: siteSettings?.siteLogo,
siteLogoDark: siteSettings?.siteLogoDark, siteLogoDark: siteSettings?.siteLogoDark,

View File

@ -92,6 +92,15 @@
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: hsl(217.2 91.2% 59.8%);
/* border radius unit */ /* border radius unit */
--radius: 0.5rem; --radius: 0.5rem;
--ui-control-sm: 2rem;
--ui-control-md: 2.25rem;
--ui-control-lg: 2.5rem;
--ui-icon-sm: 0.875rem;
--ui-icon-md: 1rem;
--ui-radius-control: 0.4375rem;
--ui-radius-panel: 0.75rem;
--ui-motion-fast: 120ms;
--ui-motion-panel: 160ms;
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
--chat-user-bubble-bg: hsl(214 100% 50%); --chat-user-bubble-bg: hsl(214 100% 50%);
--chat-user-bubble-foreground: hsl(0 0% 100%); --chat-user-bubble-foreground: hsl(0 0% 100%);
@ -257,6 +266,29 @@
font-family: var(--font-jetbrains-mono); font-family: var(--font-jetbrains-mono);
} }
body {
font-size: 14px;
line-height: 1.5;
}
button,
a,
[role="button"] {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled):active,
[role="button"]:not([aria-disabled="true"]):active {
opacity: 0.82;
}
@media (width >= 768px) {
html {
font-size: 15px;
}
}
.drawnix, .drawnix,
.drawnix *, .drawnix *,
.drawnix-tutorial, .drawnix-tutorial,
@ -370,3 +402,107 @@
background-size: 40px 40px; background-size: 40px 40px;
} }
} }
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
.settings-workspace {
font-size: 13px;
line-height: 1.45;
letter-spacing: 0;
}
.settings-workspace [class*="tracking-"] {
letter-spacing: 0;
}
.settings-workspace [data-slot="card"] {
border-radius: 8px;
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
}
.settings-workspace [data-slot="card-header"] {
row-gap: 4px;
padding: 14px 16px 10px;
}
.settings-workspace [data-slot="card-title"] {
font-size: 15px;
line-height: 1.4;
letter-spacing: 0;
}
.settings-workspace [data-slot="card-description"] {
font-size: 12px;
line-height: 1.5;
}
.settings-workspace [data-slot="card-content"] {
padding: 0 16px 16px;
}
.settings-workspace [data-slot="card-footer"] {
padding: 0 16px 16px;
}
.settings-workspace [data-slot="label"] {
font-size: 12px;
line-height: 1.35;
}
.settings-workspace .text-3xl {
font-size: 20px;
line-height: 1.3;
}
.settings-workspace .text-2xl {
font-size: 18px;
line-height: 1.35;
}
.settings-workspace .text-xl {
font-size: 16px;
line-height: 1.4;
}
.settings-workspace .text-lg {
font-size: 15px;
line-height: 1.4;
}
.settings-workspace .rounded-2xl,
.settings-workspace .rounded-xl {
border-radius: 8px;
}
.settings-workspace .gap-6 {
gap: 16px;
}
.settings-workspace .space-y-6 > :not([hidden]) ~ :not([hidden]) {
margin-top: 16px;
}
.settings-workspace button .lucide,
.settings-workspace a .lucide {
width: 14px;
height: 14px;
}
.settings-workspace button,
.settings-workspace a,
.settings-workspace [role="button"] {
transition-duration: 120ms;
}
@media (width < 640px) {
.settings-workspace [data-slot="card-header"] {
padding: 12px 12px 8px;
}
.settings-workspace [data-slot="card-content"] {
padding: 0 12px 12px;
}
.settings-workspace [data-slot="card-footer"] {
padding: 0 12px 12px;
}
}

View File

@ -12862,14 +12862,14 @@ export function CanvasChatPanel({
> >
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */} {/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
{detailSlotPickerIndex !== null && ( {detailSlotPickerIndex !== null && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 animate-in fade-in duration-200"> <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 animate-in fade-in duration-150">
<div className="flex flex-col items-center gap-4 p-6 text-center"> <div className="flex flex-col items-center gap-3 p-5 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-primary/5 animate-pulse"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-primary/5 animate-pulse">
<ImagePlus className="h-6 w-6" /> <ImagePlus className="h-5 w-5" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">正在从画布选择图片</h3> <h3 className="text-base font-semibold text-foreground">正在从画布选择图片</h3>
<p className="text-sm text-muted-foreground"> <p className="text-[13px] text-muted-foreground">
请点击画布中的任意图片以填入 请点击画布中的任意图片以填入
<span className="font-medium text-primary"> <span className="font-medium text-primary">
“{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}” “{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}”
@ -12888,21 +12888,21 @@ export function CanvasChatPanel({
</div> </div>
)} )}
{!isWorkspacePresentation ? ( {!isWorkspacePresentation ? (
<div className="z-20 flex items-center justify-between gap-3 p-4 pb-2"> <div className="z-20 flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 flex flex-1 items-center gap-2 pr-2"> <div className="min-w-0 flex flex-1 items-center gap-2 pr-2">
<span <span
className={cn( className={cn(
"inline-flex h-5 w-5 items-center justify-center rounded-md", "inline-flex h-[18px] w-[18px] items-center justify-center rounded-[5px]",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/80 text-foreground/70" ? "border border-border/60 bg-background/80 text-foreground/70"
: "bg-[#eef2f7] text-[#6b7280] dark:bg-white/12 dark:text-zinc-100 dark:ring-1 dark:ring-white/10" : "bg-[#eef2f7] text-[#6b7280] dark:bg-white/12 dark:text-zinc-100 dark:ring-1 dark:ring-white/10"
)} )}
> >
<MessageCircle className="h-3.5 w-3.5" /> <MessageCircle className="h-3 w-3" />
</span> </span>
<h2 <h2
className={cn( className={cn(
"min-w-0 flex-1 truncate text-[14px] font-semibold leading-tight tracking-tight", "min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight",
isVideoWorkspace ? "text-foreground" : "text-[#111827] dark:text-zinc-100" isVideoWorkspace ? "text-foreground" : "text-[#111827] dark:text-zinc-100"
)} )}
title={displayCanvasName} title={displayCanvasName}
@ -12956,7 +12956,7 @@ export function CanvasChatPanel({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 rounded-full", "h-7 w-7 rounded-full",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background" ? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10" : "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10"
@ -12964,14 +12964,14 @@ export function CanvasChatPanel({
onClick={createNewChatSession} onClick={createNewChatSession}
title="新建聊天窗口" title="新建聊天窗口"
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
{isClawdMode ? ( {isClawdMode ? (
<div className="relative" ref={clawdHistoryMenuRef}> <div className="relative" ref={clawdHistoryMenuRef}>
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 rounded-full px-2.5 text-xs font-semibold", "h-7 rounded-full px-2 text-[11px] font-semibold",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background" ? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10", : "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10",
@ -12989,7 +12989,7 @@ export function CanvasChatPanel({
}} }}
title="Claw 对话历史" title="Claw 对话历史"
> >
<History className="h-4 w-4" /> <History className="h-3.5 w-3.5" />
<span>Claw</span> <span>Claw</span>
</Button> </Button>
{isClawdHistoryMenuOpen && ( {isClawdHistoryMenuOpen && (
@ -13071,7 +13071,7 @@ export function CanvasChatPanel({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 rounded-full", "h-7 w-7 rounded-full",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background" ? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10", : "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10",
@ -13080,7 +13080,7 @@ export function CanvasChatPanel({
onClick={() => setIsSessionMenuOpen((open) => !open)} onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录" title="聊天窗口记录"
> >
<History className="h-4 w-4" /> <History className="h-3.5 w-3.5" />
</Button> </Button>
{isSessionMenuOpen && ( {isSessionMenuOpen && (
<div <div
@ -13137,7 +13137,7 @@ export function CanvasChatPanel({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 rounded-full", "h-7 w-7 rounded-full",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background" ? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10" : "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10"
@ -13152,13 +13152,13 @@ export function CanvasChatPanel({
}} }}
title="已生成文件列表" title="已生成文件列表"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 rounded-full", "h-7 w-7 rounded-full",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background" ? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10" : "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10"
@ -13167,7 +13167,7 @@ export function CanvasChatPanel({
disabled={!onClose} disabled={!onClose}
title="收起聊天面板" title="收起聊天面板"
> >
<ArrowRightToLine className="h-4 w-4" /> <ArrowRightToLine className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
@ -13183,26 +13183,26 @@ export function CanvasChatPanel({
className={cn( className={cn(
"flex-1 min-h-0 overflow-x-hidden", "flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto", isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
isWorkspacePresentation ? "p-6" : "p-4", isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState isPlainCanvasEmptyState
? "pt-0" ? "pt-0"
: isDetailGenerationMode && renderableVisibleMessages.length === 0 : isDetailGenerationMode && renderableVisibleMessages.length === 0
? "pt-10" ? "pt-10"
: "pt-4" : "pt-3"
)} )}
> >
<div className={cn( <div className={cn(
"space-y-6 overflow-x-hidden pr-1", "space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}> )}>
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? ( {!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? (
isWorkspacePresentation ? ( isWorkspacePresentation ? (
<div className="flex h-full min-h-[360px] items-center justify-center"> <div className="flex h-full min-h-[360px] items-center justify-center">
<div className="w-full max-w-3xl rounded-[28px] border border-dashed border-[#d7dce5] bg-white/78 p-10 text-center shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/60"> <div className="w-full max-w-3xl rounded-[18px] border border-dashed border-[#d7dce5] bg-white/78 p-7 text-center shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/60">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#eef2ff] text-[#4f46e5] dark:bg-[#312e81]/40 dark:text-[#a5b4fc]"> <div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl bg-[#eef2ff] text-[#4f46e5] dark:bg-[#312e81]/40 dark:text-[#a5b4fc]">
<Sparkles className="h-6 w-6" /> <Sparkles className="h-5 w-5" />
</div> </div>
<div className="text-2xl font-semibold tracking-tight text-foreground"> <div className="text-xl font-semibold text-foreground">
{workspaceEmptyTitle} {workspaceEmptyTitle}
</div> </div>
{workspaceEmptyDescription ? ( {workspaceEmptyDescription ? (
@ -13312,16 +13312,16 @@ export function CanvasChatPanel({
) : null} ) : null}
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col px-7 pt-12 pb-12"> <div className="flex flex-1 flex-col px-5 pb-8 pt-8">
<div className="text-left"> <div className="text-left">
<h1 className="text-[24px] font-semibold leading-tight tracking-tight text-[#050505] dark:text-zinc-50"> <h1 className="text-[20px] font-semibold leading-tight text-[#050505] dark:text-zinc-50">
Hi,{" "} Hi,{" "}
{greetingUserName ? ( {greetingUserName ? (
<span className="text-[#2563ff]">{greetingUserName}</span> <span className="text-[#2563ff]">{greetingUserName}</span>
) : null}{" "} ) : null}{" "}
{greetingPeriodText} {greetingPeriodText}
</h1> </h1>
<p className="mt-3 text-[15px] font-normal leading-6 text-[#6b7280] dark:text-zinc-400"> <p className="mt-2 text-[13px] font-normal leading-5 text-[#6b7280] dark:text-zinc-400">
开始创作吧! 累计为你生成了{" "} 开始创作吧! 累计为你生成了{" "}
<span className="font-semibold text-[#2563ff]"> <span className="font-semibold text-[#2563ff]">
{Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0} {Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0}
@ -13331,13 +13331,13 @@ export function CanvasChatPanel({
</div> </div>
{canvasSkillTags.length > 0 ? ( {canvasSkillTags.length > 0 ? (
<div className="mt-8 rounded-[26px] border border-[#e4e7ec] bg-white px-5 py-6 text-center dark:border-white/10 dark:bg-zinc-950/70"> <div className="mt-6 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
<h3 className="text-[16px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100"> <h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
{canvasSkillTagsTitle} {canvasSkillTagsTitle}
</h3> </h3>
<div <div
className={cn( className={cn(
"mt-6 flex flex-wrap justify-center gap-2 transition-[max-height] duration-200 ease-out", "mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
areCanvasSkillTagsExpanded areCanvasSkillTagsExpanded
? "max-h-none overflow-visible" ? "max-h-none overflow-visible"
: "max-h-[76px] overflow-hidden" : "max-h-[76px] overflow-hidden"
@ -13365,7 +13365,7 @@ export function CanvasChatPanel({
onDragEnd={handleCanvasSkillTagDragEnd} onDragEnd={handleCanvasSkillTagDragEnd}
onClick={() => handleSelectCanvasSkillTag(tag)} onClick={() => handleSelectCanvasSkillTag(tag)}
className={cn( className={cn(
"inline-flex h-[34px] max-w-full cursor-grab items-center justify-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium leading-none transition-colors active:cursor-grabbing", "inline-flex h-8 max-w-full cursor-grab items-center justify-center gap-1.5 rounded-full border px-3 text-[12px] font-medium leading-none transition-colors duration-150 active:cursor-grabbing",
isActive isActive
? "border-[#d8dce4] bg-[#f7f8fa] text-[#1f2328] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200" ? "border-[#d8dce4] bg-[#f7f8fa] text-[#1f2328] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200"
: "border-[#e5e7eb] bg-white text-[#1f2328] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10", : "border-[#e5e7eb] bg-white text-[#1f2328] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10",
@ -13510,7 +13510,7 @@ export function CanvasChatPanel({
return ( return (
<div key={message.id} className="space-y-3"> <div key={message.id} className="space-y-3">
{showDateSeparator && messageTimestamp ? ( {showDateSeparator && messageTimestamp ? (
<div className="text-[14px] font-medium text-[#a6adba]"> <div className="text-[11px] font-medium text-[#9ca3af]">
{formatMessageDateLabel(messageTimestamp)} {formatMessageDateLabel(messageTimestamp)}
</div> </div>
) : null} ) : null}
@ -13531,7 +13531,7 @@ export function CanvasChatPanel({
<> <>
<div <div
className={cn( className={cn(
"rounded-2xl border px-3 py-2 shadow-sm", "rounded-[12px] border px-3 py-2 shadow-sm",
isVideoWorkspace isVideoWorkspace
? "border-border/60 bg-background/80 text-foreground" ? "border-border/60 bg-background/80 text-foreground"
: "border-black/5 bg-[#f1f2f4] text-[#1f2937] dark:border-white/10 dark:bg-[#1b1f2a] dark:text-zinc-50" : "border-black/5 bg-[#f1f2f4] text-[#1f2937] dark:border-white/10 dark:bg-[#1b1f2a] dark:text-zinc-50"
@ -13584,7 +13584,7 @@ export function CanvasChatPanel({
.trim(); .trim();
if (!text) return; if (!text) return;
renderedUserParts.push( renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-current">{text}</span> <span key={`part-${idx}`} className="text-[13px] leading-5 text-current">{text}</span>
); );
} }
}); });
@ -13623,7 +13623,7 @@ export function CanvasChatPanel({
extractUserTextPrompt(message as any) || extractUserTextPrompt(message as any) ||
(isVideoModel ? "已发送视频生成请求" : "已发送请求"); (isVideoModel ? "已发送视频生成请求" : "已发送请求");
renderedUserParts.push( renderedUserParts.push(
<span key="fallback-text" className="text-sm text-current"> <span key="fallback-text" className="text-[13px] leading-5 text-current">
{fallbackText} {fallbackText}
</span> </span>
); );
@ -13652,7 +13652,7 @@ export function CanvasChatPanel({
<> <>
<div <div
className={cn( className={cn(
"min-w-0 max-w-full text-[15px] text-[#2f3640] dark:text-zinc-300", "min-w-0 max-w-full text-[14px] text-[#2f3640] dark:text-zinc-300",
isImageOnlyAssistant isImageOnlyAssistant
? "bg-transparent px-0 py-0" ? "bg-transparent px-0 py-0"
: "bg-transparent px-0 py-0" : "bg-transparent px-0 py-0"
@ -13733,7 +13733,7 @@ export function CanvasChatPanel({
? renderPlainText(analysisSection.before, `before-${i}`) ? renderPlainText(analysisSection.before, `before-${i}`)
: null} : null}
<Collapsible <Collapsible
className="my-2 rounded-[20px] bg-[#f4f5f7] px-5 py-4 ring-1 ring-black/5 dark:bg-zinc-900/70 dark:ring-white/10" className="my-2 rounded-[12px] bg-[#f4f5f7] px-4 py-3 ring-1 ring-black/5 dark:bg-zinc-900/70 dark:ring-white/10"
open={analysisOpen} open={analysisOpen}
onOpenChange={(open) => onOpenChange={(open) =>
setAnalysisCollapseState((prev) => ({ setAnalysisCollapseState((prev) => ({
@ -13769,7 +13769,7 @@ export function CanvasChatPanel({
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"pt-3 text-[15px] font-medium leading-7 text-[#2f3640] dark:text-zinc-200", "pt-2.5 text-[13px] font-medium leading-6 text-[#2f3640] dark:text-zinc-200",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
"outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in" "outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
)} )}
@ -14121,14 +14121,14 @@ export function CanvasChatPanel({
<div <div
className={cn( className={cn(
isWorkspacePresentation isWorkspacePresentation
? "border-t bg-white/92 p-5 backdrop-blur-sm dark:bg-zinc-950/92" ? "border-t bg-white/92 p-4 backdrop-blur-sm dark:bg-zinc-950/92"
: "bg-white px-[10px] pb-[10px] pt-0 dark:bg-zinc-950" : "bg-white px-2 pb-2 pt-0 dark:bg-zinc-950"
)} )}
> >
<div <div
className={cn( className={cn(
"relative flex flex-col overflow-hidden border border-[#e2e5ea] bg-white shadow-[0_22px_42px_-30px_rgba(15,23,42,0.48)] transition-all duration-200 focus-within:border-[#d8dde5] focus-within:shadow-[0_24px_48px_-30px_rgba(15,23,42,0.55)] dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25", "relative flex flex-col overflow-hidden border border-[#e2e5ea] bg-white shadow-[0_18px_36px_-28px_rgba(15,23,42,0.42)] transition-[border-color,box-shadow] duration-150 focus-within:border-[#cfd5de] focus-within:shadow-[0_20px_40px_-28px_rgba(15,23,42,0.5)] dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25",
"min-h-[184px] rounded-[28px]" "min-h-[148px] rounded-[18px]"
)} )}
data-canvas-chat-input-anchor="true" data-canvas-chat-input-anchor="true"
> >
@ -14886,12 +14886,12 @@ export function CanvasChatPanel({
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */} {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div <div
className={cn( className={cn(
"relative px-3 pb-0 pt-3 sm:px-4", "relative px-3 pb-0 pt-2.5",
isDetailGenerationMode isDetailGenerationMode
? "min-h-[112px]" ? "min-h-[96px]"
: activeComposerMode === "image" : activeComposerMode === "image"
? "min-h-[118px]" ? "min-h-[92px]"
: "min-h-[132px]" : "min-h-[104px]"
)} )}
> >
{!isWorkspacePresentation && {!isWorkspacePresentation &&
@ -14901,7 +14901,7 @@ export function CanvasChatPanel({
<div className="mb-2 flex"> <div className="mb-2 flex">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 rounded-full bg-[#f3f4f6] px-3 py-1.5 text-sm font-medium text-[#4b5563] transition-colors hover:bg-[#e9ebef] dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12" className="inline-flex items-center gap-1.5 rounded-full bg-[#f3f4f6] px-2.5 py-1 text-[12px] font-medium text-[#4b5563] transition-colors duration-150 hover:bg-[#e9ebef] dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12"
onClick={() => { onClick={() => {
setSelectedSkillTagId(null); setSelectedSkillTagId(null);
pendingSelectedClawdSkillTagRef.current = null; pendingSelectedClawdSkillTagRef.current = null;
@ -14925,9 +14925,9 @@ export function CanvasChatPanel({
</div> </div>
) : null} ) : null}
{isClawdMode && activeClawdSkillTag && !activeClawdSkillTagPromptApplied ? ( {isClawdMode && activeClawdSkillTag && !activeClawdSkillTagPromptApplied ? (
<div className="mb-2 flex px-1 animate-in fade-in-0 slide-in-from-bottom-2 zoom-in-95 duration-300"> <div className="mb-2 flex px-1 animate-in fade-in-0 slide-in-from-bottom-1 zoom-in-95 duration-150">
<div <div
className="inline-flex max-w-full items-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-3 py-1.5 text-sm font-medium text-violet-700 shadow-sm dark:border-violet-400/25 dark:bg-violet-500/15 dark:text-violet-100" className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-violet-200 bg-violet-50 px-2.5 py-1 text-[12px] font-medium text-violet-700 shadow-sm dark:border-violet-400/25 dark:bg-violet-500/15 dark:text-violet-100"
title={`首次发送会带入「${activeClawdSkillTag.name}」快捷标签提示词`} title={`首次发送会带入「${activeClawdSkillTag.name}」快捷标签提示词`}
> >
{activeClawdSkillTag.iconUrl ? ( {activeClawdSkillTag.iconUrl ? (
@ -15333,7 +15333,7 @@ export function CanvasChatPanel({
className={cn( className={cn(
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "px-1" ? "px-1"
: "px-4 pt-3" : "px-2 pt-2"
)} )}
> >
<div <div
@ -15343,10 +15343,10 @@ export function CanvasChatPanel({
className={cn( className={cn(
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]", "max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "min-h-[38px] text-[19px] leading-7 sm:text-[22px] sm:leading-8" ? "min-h-[36px] text-[16px] leading-6 md:text-[14px]"
: activeComposerMode === "image" : activeComposerMode === "image"
? "min-h-[44px] text-[16px] leading-7 sm:text-[16px] sm:leading-7" ? "min-h-[38px] text-[16px] leading-6 md:text-[14px]"
: "min-h-[64px] text-[16px] leading-7 sm:text-[17px] sm:leading-7", : "min-h-[50px] text-[16px] leading-6 md:text-[14px]",
isVideoWorkspace isVideoWorkspace
? "text-foreground empty:before:text-muted-foreground/70" ? "text-foreground empty:before:text-muted-foreground/70"
: "text-[#1f2328] empty:before:text-[#a8abb2] dark:text-zinc-100 dark:empty:before:text-zinc-500" : "text-[#1f2328] empty:before:text-[#a8abb2] dark:text-zinc-100 dark:empty:before:text-zinc-500"
@ -15554,7 +15554,7 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */} {/* Bottom Toolbar */}
<div <div
className={cn( className={cn(
"mt-auto flex min-h-[44px] items-center justify-between gap-1.5 px-3 pb-2 pt-1 dark:bg-zinc-950 sm:px-3 sm:pb-2" "mt-auto flex min-h-10 items-center justify-between gap-1.5 px-2.5 pb-2 pt-1 dark:bg-zinc-950"
)} )}
> >
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pr-1 scrollbar-hide"> <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pr-1 scrollbar-hide">
@ -15629,7 +15629,7 @@ export function CanvasChatPanel({
type="button" type="button"
variant="ghost" variant="ghost"
className={cn( className={cn(
"relative h-9 max-w-[122px] shrink-0 cursor-pointer rounded-full px-2.5 text-[15px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0", "relative h-8 max-w-[110px] shrink-0 cursor-pointer rounded-full px-2.5 text-[13px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isVideoToolbarDark isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]" ? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
: "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14" : "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14"
@ -15637,28 +15637,28 @@ export function CanvasChatPanel({
> >
<span className="flex min-w-0 items-center"> <span className="flex min-w-0 items-center">
{activeComposerMode === "clawd" ? ( {activeComposerMode === "clawd" ? (
<MessageCircle className="mr-1.5 h-5 w-5 shrink-0" /> <MessageCircle className="mr-1 h-4 w-4 shrink-0" />
) : activeComposerMode === "video" ? ( ) : activeComposerMode === "video" ? (
<CanvasToolbarIcon <CanvasToolbarIcon
className="mr-1.5 h-5 w-5 shrink-0" className="mr-1 h-4 w-4 shrink-0"
fallback={Video} fallback={Video}
item={getToolbarItem("chatModeVideoActive")} item={getToolbarItem("chatModeVideoActive")}
/> />
) : activeComposerMode === "detail" ? ( ) : activeComposerMode === "detail" ? (
<CanvasToolbarIcon <CanvasToolbarIcon
className="mr-1.5 h-5 w-5 shrink-0" className="mr-1 h-4 w-4 shrink-0"
fallback={LayoutGrid} fallback={LayoutGrid}
item={getToolbarItem("chatModeDetailActive")} item={getToolbarItem("chatModeDetailActive")}
/> />
) : activeComposerMode === "image" ? ( ) : activeComposerMode === "image" ? (
<CanvasToolbarIcon <CanvasToolbarIcon
className="mr-1.5 h-5 w-5 shrink-0" className="mr-1 h-4 w-4 shrink-0"
fallback={ImagePlus} fallback={ImagePlus}
item={getToolbarItem("chatModeImageActive")} item={getToolbarItem("chatModeImageActive")}
/> />
) : ( ) : (
<CanvasToolbarIcon <CanvasToolbarIcon
className="mr-1.5 h-5 w-5 shrink-0" className="mr-1 h-4 w-4 shrink-0"
fallback={Bot} fallback={Bot}
item={null} item={null}
/> />
@ -15677,7 +15677,7 @@ export function CanvasChatPanel({
side="top" side="top"
sideOffset={10} sideOffset={10}
className={cn( className={cn(
"w-[200px] space-y-1 rounded-[18px] border border-[#d8dce3] bg-white p-2 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)]", "w-[184px] space-y-0.5 rounded-[12px] border border-[#d8dce3] bg-white p-1.5 text-[#20242a] shadow-[0_18px_44px_rgba(15,23,42,0.14)]",
isVideoToolbarDark isVideoToolbarDark
? "border-white/10 bg-zinc-900/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)] backdrop-blur-xl" ? "border-white/10 bg-zinc-900/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)] backdrop-blur-xl"
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100" : "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
@ -15905,8 +15905,8 @@ export function CanvasChatPanel({
size={isWorkspacePresentation ? "default" : "icon"} size={isWorkspacePresentation ? "default" : "icon"}
className={cn( className={cn(
isWorkspacePresentation isWorkspacePresentation
? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0" ? "h-9 rounded-full px-3.5 transition-all duration-150 flex-shrink-0"
: "h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0", : "h-8 w-8 rounded-full transition-all duration-150 flex-shrink-0",
isClawdStopAvailable isClawdStopAvailable
? "bg-rose-500 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400" ? "bg-rose-500 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400"
: isVideoToolbarDark : isVideoToolbarDark

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ export const Action = ({
const button = ( const button = (
<Button <Button
className={cn( className={cn(
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground", "relative size-8 p-1.5 text-muted-foreground hover:text-foreground",
className className
)} )}
size={size} size={size}

View File

@ -30,7 +30,7 @@ export const ConversationContent = ({
className, className,
...props ...props
}: ConversationContentProps) => ( }: ConversationContentProps) => (
<StickToBottom.Content className={cn("p-4", className)} {...props} /> <StickToBottom.Content className={cn("p-3", className)} {...props} />
); );
export type ConversationScrollButtonProps = ComponentProps<typeof Button>; export type ConversationScrollButtonProps = ComponentProps<typeof Button>;

View File

@ -10,7 +10,7 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
export const Message = ({ className, from, ...props }: MessageProps) => ( export const Message = ({ className, from, ...props }: MessageProps) => (
<div <div
className={cn( className={cn(
"group flex w-full items-end justify-end gap-2 py-4", "group flex w-full items-end justify-end gap-2 py-3",
from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end", from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end",
"[&>div]:max-w-[80%]", "[&>div]:max-w-[80%]",
className className
@ -28,7 +28,7 @@ export const MessageContent = ({
}: MessageContentProps) => ( }: MessageContentProps) => (
<div <div
className={cn( className={cn(
"min-w-0 flex flex-col gap-2 rounded-lg px-4 py-3 text-foreground text-sm", "min-w-0 flex flex-col gap-1.5 rounded-lg px-3 py-2.5 text-[13px] leading-5 text-foreground",
"group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground", "group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
"group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground", "group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
"is-user:dark", "is-user:dark",

View File

@ -24,7 +24,7 @@ export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => ( export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form <form
className={cn( className={cn(
"w-full overflow-hidden rounded-xl border bg-background shadow-xs", "w-full overflow-hidden rounded-[10px] border bg-background shadow-xs",
className className
)} )}
{...props} {...props}
@ -72,7 +72,7 @@ export const PromptInputTextarea = ({
return ( return (
<Textarea <Textarea
className={cn( className={cn(
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0", "w-full resize-none rounded-none border-none p-2.5 shadow-none outline-hidden ring-0",
disableAutoResize disableAutoResize
? "field-sizing-fixed" ? "field-sizing-fixed"
: resizeOnNewLinesOnly : resizeOnNewLinesOnly

View File

@ -56,12 +56,12 @@ const AGENT_SECTION_TITLES = new Set([
"输出设置", "输出设置",
]); ]);
const MARKDOWN_HEADING_CLASS_NAME = const MARKDOWN_HEADING_CLASS_NAME =
"[&_h1]:!mb-3 [&_h1]:!mt-4 [&_h1]:!text-[26px] [&_h1]:!font-semibold [&_h1]:!leading-[1.25] [&_h1]:!tracking-normal " + "[&_h1]:!mb-2.5 [&_h1]:!mt-4 [&_h1]:!text-[22px] [&_h1]:!font-semibold [&_h1]:!leading-[1.3] [&_h1]:!tracking-normal " +
"[&_h2]:!mb-2.5 [&_h2]:!mt-4 [&_h2]:!text-[22px] [&_h2]:!font-semibold [&_h2]:!leading-[1.3] [&_h2]:!tracking-normal " + "[&_h2]:!mb-2 [&_h2]:!mt-3.5 [&_h2]:!text-[18px] [&_h2]:!font-semibold [&_h2]:!leading-[1.35] [&_h2]:!tracking-normal " +
"[&_h3]:!mb-2 [&_h3]:!mt-3 [&_h3]:!text-[18px] [&_h3]:!font-semibold [&_h3]:!leading-[1.35] [&_h3]:!tracking-normal " + "[&_h3]:!mb-1.5 [&_h3]:!mt-3 [&_h3]:!text-[16px] [&_h3]:!font-semibold [&_h3]:!leading-[1.4] [&_h3]:!tracking-normal " +
"[&_h4]:!mb-1.5 [&_h4]:!mt-3 [&_h4]:!text-[16px] [&_h4]:!font-semibold [&_h4]:!leading-[1.4] [&_h4]:!tracking-normal " + "[&_h4]:!mb-1.5 [&_h4]:!mt-2.5 [&_h4]:!text-[14px] [&_h4]:!font-semibold [&_h4]:!leading-[1.45] [&_h4]:!tracking-normal " +
"[&_h5]:!text-[15px] [&_h5]:!font-semibold [&_h5]:!leading-[1.4] [&_h5]:!tracking-normal " + "[&_h5]:!text-[13px] [&_h5]:!font-semibold [&_h5]:!leading-[1.45] [&_h5]:!tracking-normal " +
"[&_h6]:!text-[14px] [&_h6]:!font-semibold [&_h6]:!leading-[1.4] [&_h6]:!tracking-normal"; "[&_h6]:!text-[12px] [&_h6]:!font-semibold [&_h6]:!leading-[1.45] [&_h6]:!tracking-normal";
type AnalysisSection = { type AnalysisSection = {
before: string; before: string;

View File

@ -40,7 +40,7 @@ export const Suggestion = ({
return ( return (
<Button <Button
className={cn("cursor-pointer rounded-full px-4", className)} className={cn("cursor-pointer rounded-full px-3", className)}
onClick={handleClick} onClick={handleClick}
size={size} size={size}
type="button" type="button"

View File

@ -19,6 +19,20 @@ export function HomeAnimationEmbed({
frameClassName, frameClassName,
}: HomeAnimationEmbedProps) { }: HomeAnimationEmbedProps) {
const [interactive, setInteractive] = useState(false); const [interactive, setInteractive] = useState(false);
const [frameUrl, setFrameUrl] = useState("");
useEffect(() => {
if (!htmlContent.trim()) {
setFrameUrl("");
return;
}
const blob = new Blob([htmlContent], { type: "text/html;charset=utf-8" });
const nextFrameUrl = URL.createObjectURL(blob);
setFrameUrl(nextFrameUrl);
return () => URL.revokeObjectURL(nextFrameUrl);
}, [htmlContent]);
useEffect(() => { useEffect(() => {
if (!interactive) { if (!interactive) {
@ -78,16 +92,18 @@ export function HomeAnimationEmbed({
frameClassName, frameClassName,
)} )}
> >
<iframe {frameUrl ? (
title={title || "首页互动动画"} <iframe
srcDoc={htmlContent} title={title || "首页互动动画"}
sandbox="allow-scripts allow-forms allow-pointer-lock" src={frameUrl}
loading="lazy" sandbox="allow-scripts allow-forms allow-pointer-lock"
className={cn( loading="lazy"
"h-full w-full border-0 transition-opacity", className={cn(
interactive ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-95", "h-full w-full border-0 transition-opacity",
)} interactive ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-95",
/> )}
/>
) : null}
{!interactive ? ( {!interactive ? (
<button <button

View File

@ -461,7 +461,8 @@ export function LovartHome({
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null); const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [typedPreviewText, setTypedPreviewText] = useState(""); const [typedPreviewText, setTypedPreviewText] = useState("");
const [previewCursorVisible, setPreviewCursorVisible] = useState(true); const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
const [isSecondaryDataReady, setIsSecondaryDataReady] = useState(false); const [isThemeReady, setIsThemeReady] = useState(false);
const isSecondaryDataReady = true;
const [isFeaturedDataReady, setIsFeaturedDataReady] = useState(false); const [isFeaturedDataReady, setIsFeaturedDataReady] = useState(false);
const [isHomeAnimationReady, setIsHomeAnimationReady] = useState(false); const [isHomeAnimationReady, setIsHomeAnimationReady] = useState(false);
const projectSectionRef = useRef<HTMLDivElement | null>(null); const projectSectionRef = useRef<HTMLDivElement | null>(null);
@ -469,6 +470,10 @@ export function LovartHome({
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null); const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
const filesRef = useRef<UploadFileItem[]>([]); const filesRef = useRef<UploadFileItem[]>([]);
useEffect(() => {
setIsThemeReady(true);
}, []);
const { data: sessionData, mutate: mutateSession } = useSWR<HomeSessionResponse>( const { data: sessionData, mutate: mutateSession } = useSWR<HomeSessionResponse>(
"/api/auth/validate-session", "/api/auth/validate-session",
fetcher, fetcher,
@ -481,30 +486,6 @@ export function LovartHome({
const user = sessionData?.user ?? initialUser; const user = sessionData?.user ?? initialUser;
const isAuthenticated = sessionData?.valid ?? initialIsAuthenticated; const isAuthenticated = sessionData?.valid ?? initialIsAuthenticated;
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const loadSecondaryData = () => setIsSecondaryDataReady(true);
const idleWindow = window as Window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (typeof idleWindow.requestIdleCallback === "function") {
const idleId = idleWindow.requestIdleCallback(loadSecondaryData, {
timeout: 1200,
});
return () => idleWindow.cancelIdleCallback?.(idleId);
}
const timer = window.setTimeout(loadSecondaryData, 550);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || isFeaturedDataReady) { if (typeof window === "undefined" || isFeaturedDataReady) {
return; return;
@ -673,8 +654,9 @@ export function LovartHome({
siteSettingsData?.lovartHeroSubtitle ?? initialHeroSubtitleText; siteSettingsData?.lovartHeroSubtitle ?? initialHeroSubtitleText;
const surpriseCategories = const surpriseCategories =
siteSettingsData?.surpriseCategories ?? initialSurpriseCategories; siteSettingsData?.surpriseCategories ?? initialSurpriseCategories;
const effectiveResolvedTheme = isThemeReady ? resolvedTheme : undefined;
const displayLogo = const displayLogo =
resolvedTheme === "dark" effectiveResolvedTheme === "dark"
? siteLogoDark || siteLogo || "" ? siteLogoDark || siteLogo || ""
: siteLogo || siteLogoDark || ""; : siteLogo || siteLogoDark || "";
@ -1081,21 +1063,41 @@ export function LovartHome({
}, [files]); }, [files]);
useEffect(() => { useEffect(() => {
if (!showImageEditor || ImageAnnotatorLazy) { if (ImageAnnotatorLazy) {
return; return;
} }
let disposed = false; let disposed = false;
void import("@/components/image-annotator").then((module) => { const preloadImageAnnotator = () => void import("@/components/image-annotator").then((module) => {
if (!disposed) { if (!disposed) {
setImageAnnotatorLazy(() => module.ImageAnnotator); setImageAnnotatorLazy(() => module.ImageAnnotator);
} }
}); });
const idleWindow = window as Window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (typeof idleWindow.requestIdleCallback === "function") {
const idleId = idleWindow.requestIdleCallback(preloadImageAnnotator, {
timeout: 900,
});
return () => {
disposed = true;
idleWindow.cancelIdleCallback?.(idleId);
};
}
const timer = window.setTimeout(preloadImageAnnotator, 180);
return () => { return () => {
disposed = true; disposed = true;
window.clearTimeout(timer);
}; };
}, [ImageAnnotatorLazy, showImageEditor]); }, [ImageAnnotatorLazy]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -1550,7 +1552,9 @@ export function LovartHome({
}; };
}, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]); }, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]);
const selectedThemeMode: ThemeMode = const selectedThemeMode: ThemeMode =
theme === "light" || theme === "dark" || theme === "system" ? theme : "system"; isThemeReady && (theme === "light" || theme === "dark" || theme === "system")
? theme
: "system";
const selectedThemeOption = const selectedThemeOption =
THEME_MODE_OPTIONS.find((option) => option.value === selectedThemeMode) || THEME_MODE_OPTIONS.find((option) => option.value === selectedThemeMode) ||
THEME_MODE_OPTIONS[0]; THEME_MODE_OPTIONS[0];
@ -1559,7 +1563,7 @@ export function LovartHome({
return ( return (
<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"> <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">
<BrandTopBar <BrandTopBar
contentClassName="pl-[98px]" contentClassName="pl-[80px]"
logo={displayLogo} logo={displayLogo}
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
showBrand={false} showBrand={false}
@ -1820,10 +1824,10 @@ export function LovartHome({
tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)} tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)}
tutorialVideos={tutorialVideos} tutorialVideos={tutorialVideos}
videoCanvasEnabled={siteSettingsData?.videoCanvasEnabled !== false} videoCanvasEnabled={siteSettingsData?.videoCanvasEnabled !== false}
tone={resolvedTheme === "dark" ? "dark" : "light"} tone={effectiveResolvedTheme === "dark" ? "dark" : "light"}
/> />
<main className="ml-[118px] mr-[56px] w-auto max-w-none pb-20 pt-[84px] max-lg:mx-6"> <main className="ml-[92px] mr-10 w-auto max-w-none pb-16 pt-[68px] max-lg:mx-5">
{!isProjectsOnlyView && ( {!isProjectsOnlyView && (
<LovartHeroComposer <LovartHeroComposer
composerPlaceholder={composerPlaceholder} composerPlaceholder={composerPlaceholder}
@ -1882,7 +1886,7 @@ export function LovartHome({
<button <button
type="button" type="button"
title="回到顶部" title="回到顶部"
className="fixed bottom-8 right-8 z-20 flex h-11 w-11 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow hover:text-black dark:border-white/12 dark:bg-[#141416] dark:text-white/55 dark:hover:text-white" className="fixed bottom-6 right-6 z-20 flex h-9 w-9 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow-sm transition-colors duration-150 hover:text-black dark:border-white/12 dark:bg-[#141416] dark:text-white/55 dark:hover:text-white"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
> >
<ArrowUp className="h-4 w-4" /> <ArrowUp className="h-4 w-4" />
@ -1976,23 +1980,23 @@ export function LovartHome({
onClick={() => setLoginDialogOpen(false)} onClick={() => setLoginDialogOpen(false)}
> >
<div <div
className="w-full max-w-[460px] rounded-[24px] bg-white p-6 shadow-2xl dark:bg-[#141416] sm:p-7" className="w-full max-w-[400px] rounded-[18px] bg-white p-5 shadow-2xl dark:bg-[#141416]"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="mb-6 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{displayLogo ? ( {displayLogo ? (
<img <img
src={displayLogo} src={displayLogo}
alt={siteName} alt={siteName}
className="h-7 w-7 rounded-full object-cover" className="h-6 w-6 rounded-full object-cover"
/> />
) : ( ) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black"> <div className="flex h-6 w-6 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3 w-3" />
</div> </div>
)} )}
<span className="text-[18px] font-semibold leading-none tracking-tight text-black dark:text-white"> <span className="text-[16px] font-semibold leading-none text-black dark:text-white">
{siteName} {siteName}
</span> </span>
</div> </div>
@ -2005,9 +2009,9 @@ export function LovartHome({
</button> </button>
</div> </div>
<div className="mb-5 text-center"> <div className="mb-4 text-center">
<h3 className="text-[18px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3> <h3 className="text-[16px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3>
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45"> <p className="mt-1.5 text-[12px] text-black/45 dark:text-white/45">
{canUseRegistration ? "登录或注册以继续" : "登录以继续"} {canUseRegistration ? "登录或注册以继续" : "登录以继续"}
</p> </p>
</div> </div>
@ -2059,7 +2063,7 @@ export function LovartHome({
) : null} ) : null}
<form <form
className="space-y-3" className="space-y-2.5"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
handleEmailLogin(); handleEmailLogin();
@ -2070,18 +2074,18 @@ export function LovartHome({
onChange={(event) => setLoginEmail(event.target.value)} onChange={(event) => setLoginEmail(event.target.value)}
placeholder="电子邮箱" placeholder="电子邮箱"
type="email" type="email"
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35" className="h-10 rounded-[9px] border-black/20 px-3 text-[16px] md:text-[13px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35"
/> />
<Input <Input
value={loginPassword} value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)} onChange={(event) => setLoginPassword(event.target.value)}
placeholder="密码" placeholder="密码"
type="password" type="password"
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35" className="h-10 rounded-[9px] border-black/20 px-3 text-[16px] md:text-[13px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35"
/> />
<Button <Button
type="submit" type="submit"
className="mt-1 h-12 w-full rounded-xl bg-black text-[15px] text-white hover:bg-black/90" className="mt-1 h-10 w-full rounded-[9px] bg-black text-[13px] text-white transition-colors duration-150 hover:bg-black/90"
disabled={isLoggingIn} disabled={isLoggingIn}
> >
{isLoggingIn ? ( {isLoggingIn ? (
@ -2096,7 +2100,7 @@ export function LovartHome({
</form> </form>
{canUseRegistration ? ( {canUseRegistration ? (
<div className="mt-3 text-center text-[13px] text-black/55 dark:text-white/55"> <div className="mt-2.5 text-center text-[12px] text-black/55 dark:text-white/55">
还没有账号? 还没有账号?
<button <button
type="button" type="button"

View File

@ -24,18 +24,18 @@ export function LovartFeaturedGallery({
setActiveCategory, setActiveCategory,
}: LovartFeaturedGalleryProps) { }: LovartFeaturedGalleryProps) {
return ( return (
<section ref={featuredSectionRef} className="mt-[70px]"> <section ref={featuredSectionRef} className="mt-14">
<h2 className="text-[18px] font-semibold leading-none tracking-[-0.01em] text-[#1f1f1f] dark:text-white"> <h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
灵感发现 灵感发现
</h2> </h2>
<div className="mt-[18px] flex flex-wrap items-center gap-x-[24px] gap-y-2"> <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={`h-[30px] rounded-[8px] px-[13px] text-[13px] font-normal leading-none transition ${ className={`h-7 rounded-[7px] px-3 text-[12px] font-normal leading-none transition-colors duration-150 ${
activeCategory === category activeCategory === category
? "bg-[#f2f2f2] text-[#202020] dark:bg-[#24242a] dark:text-white" ? "bg-[#f2f2f2] text-[#202020] dark:bg-[#24242a] dark:text-white"
: "text-[#5f5f5f] hover:bg-[#f7f7f7] hover:text-[#202020] dark:text-white/55 dark:hover:bg-[#17171b]" : "text-[#5f5f5f] hover:bg-[#f7f7f7] hover:text-[#202020] dark:text-white/55 dark:hover:bg-[#17171b]"
@ -64,7 +64,7 @@ export function LovartFeaturedGallery({
alt={item.prompt || "灵感图"} alt={item.prompt || "灵感图"}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.01] 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">

View File

@ -1,5 +1,5 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles } from "lucide-react"; import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -19,7 +19,7 @@ const HomeAnimationEmbed = dynamic(
), ),
{ {
loading: () => ( loading: () => (
<div className="h-[360px] w-full rounded-[24px] border border-black/10 bg-black/[0.03] md:h-[420px] dark:border-white/10 dark:bg-white/[0.04]" /> <div className="h-[340px] w-full rounded-[16px] border border-black/10 bg-black/[0.03] md:h-[400px] dark:border-white/10 dark:bg-white/[0.04]" />
), ),
ssr: false, ssr: false,
} }
@ -79,73 +79,78 @@ export function LovartHeroComposer({
selectedModelId, selectedModelId,
}: LovartHeroComposerProps) { }: LovartHeroComposerProps) {
return ( return (
<section className="mt-8 text-center"> <section className="mt-6 text-center">
{isMembershipEnabled ? ( {isMembershipEnabled ? (
<button <button
type="button" type="button"
onClick={openUpgradeDialog} onClick={openUpgradeDialog}
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]" 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]"
> >
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span> <span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"} <span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span> {heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
</span>
<span className="shrink-0 text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button> </button>
) : ( ) : (
<div className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]"> <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="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"} <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>
)} )}
<div className="mt-8 flex items-center justify-center gap-2"> <div className="mt-7 flex items-center justify-center gap-1.5">
{displayLogo ? ( {displayLogo ? (
<img <img
src={displayLogo} src={displayLogo}
alt={siteName} alt={siteName}
className="h-10 w-10 rounded-full object-cover" className="h-8 w-8 rounded-full object-cover"
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black"> <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-4 w-4" /> <Sparkles className="h-3.5 w-3.5" />
</div> </div>
)} )}
<h1 className="text-[34px] font-semibold tracking-tight dark:text-white"> <h1 className="text-[28px] font-semibold leading-tight sm:text-[30px] dark:text-white">
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`} {heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1> </h1>
</div> </div>
<p className="mt-2 text-[13px] text-black/35 dark:text-white/45"> <p className="mt-1.5 text-[12px] text-black/38 dark:text-white/48">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"} {heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p> </p>
<div className="mx-auto mt-[34px] w-full max-w-[900px] rounded-[22px] border border-[#e1e4e8] bg-white px-[24px] pb-[12px] pt-[18px] shadow-[0_10px_26px_rgba(0,0,0,0.06)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"> <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
value={prompt} value={prompt}
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder} placeholder={composerPlaceholder}
className="min-h-[78px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-[1.4] text-[#20242a] shadow-none placeholder:text-[#b8b8b8] focus-visible:ring-0 md:!text-[16px] 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 key={item.id} className="group relative h-12 w-12 overflow-hidden rounded-lg border border-black/10 dark:border-white/10"> <div key={item.id} 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" /> <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-xs 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"
onClick={() => removeFile(item.id)} onClick={() => removeFile(item.id)}
aria-label={`移除${item.file.name}`}
> >
x <X className="h-3 w-3" />
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="flex min-h-[54px] items-center justify-between pt-[12px]"> <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-9 w-9 cursor-pointer items-center justify-center rounded-full text-[#20242a] 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">
<Plus className="h-5 w-5" strokeWidth={1.9} /> <Plus className="h-4 w-4" strokeWidth={1.9} />
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
@ -159,11 +164,11 @@ export function LovartHeroComposer({
</label> </label>
</div> </div>
<div className="flex h-11 items-center gap-5 pl-2"> <div className="flex h-9 items-center gap-3 pl-2">
<button <button
type="button" type="button"
className={cn( className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-full p-0 leading-none transition-colors focus:outline-none focus:ring-0", "grid h-8 w-8 shrink-0 place-items-center rounded-full p-0 leading-none transition-colors duration-150 focus:outline-none focus:ring-0",
isAgentModeActive isAgentModeActive
? "bg-[#eaf1ff] text-[#2563eb] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.16)] dark:bg-[#102042] dark:text-[#8db4ff] dark:shadow-[inset_0_0_0_1px_rgba(141,180,255,0.18)]" ? "bg-[#eaf1ff] text-[#2563eb] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.16)] dark:bg-[#102042] dark:text-[#8db4ff] dark:shadow-[inset_0_0_0_1px_rgba(141,180,255,0.18)]"
: "text-[#20242a] hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8" : "text-[#20242a] hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8"
@ -172,28 +177,28 @@ export function LovartHeroComposer({
onClick={onStartAgentMode} onClick={onStartAgentMode}
title="开启 Agent 模式" title="开启 Agent 模式"
> >
<Lightbulb className="block h-5 w-5" strokeWidth={1.9} /> <Lightbulb className="block h-4 w-4" strokeWidth={1.9} />
</button> </button>
<Select value={selectedModelId} onValueChange={onModelChange}> <Select value={selectedModelId} onValueChange={onModelChange}>
<SelectTrigger <SelectTrigger
aria-label="选择模型" aria-label="选择模型"
className="!flex !h-9 !w-9 shrink-0 !items-center !justify-center rounded-full border-0 !bg-transparent !p-0 text-[#20242a] leading-none shadow-none hover:bg-black/5 focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white/72 dark:hover:bg-white/8 [&>svg]:hidden" className="!flex !h-8 !w-8 shrink-0 !items-center !justify-center rounded-full border-0 !bg-transparent !p-0 text-[#20242a] leading-none shadow-none transition-colors duration-150 hover:bg-black/5 focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white/72 dark:hover:bg-white/8 [&>svg]:hidden"
title="选择模型" title="选择模型"
> >
<div className="flex h-9 w-9 items-center justify-center"> <div className="flex h-8 w-8 items-center justify-center">
<Box className="block h-5 w-5" strokeWidth={1.9} /> <Box className="block h-4 w-4" strokeWidth={1.9} />
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
align="end" align="end"
sideOffset={10} sideOffset={10}
className="w-[210px] rounded-[18px] border-black/10 bg-white p-1.5 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-[#141416] dark:text-white" className="w-[200px] rounded-[12px] border-black/10 bg-white p-1 text-[#20242a] shadow-[0_18px_44px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-[#141416] dark:text-white"
> >
{modelOptions.map((model) => ( {modelOptions.map((model) => (
<SelectItem <SelectItem
key={model.id} key={model.id}
value={model.id} value={model.id}
className="h-9 rounded-[12px] text-[13px] [&>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">
<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"> <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">
@ -216,12 +221,12 @@ export function LovartHeroComposer({
<Button <Button
onClick={onSubmit} onClick={onSubmit}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)} disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-11 w-11 rounded-full bg-[#eeeeef] p-0 text-[#c9cdd4] hover:bg-[#e7e7e8] hover:text-[#8f96a3] 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" />
) : ( ) : (
<ArrowUp className="h-5 w-5" strokeWidth={1.8} /> <ArrowUp className="h-4 w-4" strokeWidth={1.8} />
)} )}
</Button> </Button>
</div> </div>

View File

@ -29,34 +29,34 @@ export function LovartProjectsSection({
return ( return (
<section <section
ref={projectSectionRef} ref={projectSectionRef}
className={isProjectsOnlyView ? "mt-6" : "mt-[156px]"} className={isProjectsOnlyView ? "mt-5" : "mt-[112px]"}
> >
<div className="mb-[32px] flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h2 className="text-[26px] font-semibold leading-none tracking-[-0.01em] text-[#1f1f1f] dark:text-white"> <h2 className="text-[20px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
{isProjectsOnlyView ? "所有项目" : "最近项目"} {isProjectsOnlyView ? "所有项目" : "最近项目"}
</h2> </h2>
{!isProjectsOnlyView && hasMoreProjects ? ( {!isProjectsOnlyView && hasMoreProjects ? (
<button <button
type="button" type="button"
className="inline-flex items-center gap-[7px] text-[18px] font-normal leading-none text-[#8b8b8b] transition hover:text-[#1f1f1f] dark:text-white/45 dark:hover:text-white" className="inline-flex items-center gap-1 text-[13px] font-normal leading-none text-[#7d7d7d] transition-colors duration-150 hover:text-[#1f1f1f] dark:text-white/50 dark:hover:text-white"
onClick={onViewAllProjects} onClick={onViewAllProjects}
> >
查看全部 查看全部
<ChevronRight className="h-[18px] w-[18px]" strokeWidth={1.8} /> <ChevronRight className="h-3.5 w-3.5" strokeWidth={1.8} />
</button> </button>
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-1 items-start gap-x-[16px] gap-y-[36px] md:grid-cols-2 lg:grid-cols-5"> <div className="grid grid-cols-1 items-start gap-x-4 gap-y-7 md:grid-cols-2 lg:grid-cols-5">
<button <button
type="button" type="button"
onClick={onCreateProject} onClick={onCreateProject}
className="group block w-full cursor-pointer appearance-none bg-transparent p-0 text-left" className="group block w-full cursor-pointer appearance-none bg-transparent p-0 text-left"
> >
<div className="flex aspect-[16/9] items-center justify-center rounded-[10px] border border-dashed border-[#d8d8d8] bg-[#f4f4f4] transition group-hover:border-[#c8c8c8] group-hover:bg-[#f2f2f2] dark:border-white/15 dark:bg-[#18191d]"> <div className="flex aspect-[16/9] items-center justify-center rounded-[8px] border border-dashed border-[#d8d8d8] bg-[#f4f4f4] transition-colors duration-150 group-hover:border-[#c8c8c8] group-hover:bg-[#f0f0f0] dark:border-white/15 dark:bg-[#18191d]">
<span className="text-[34px] font-light leading-none text-[#8e8e8e]">+</span> <span className="text-[26px] font-light leading-none text-[#8e8e8e]">+</span>
</div> </div>
<div className="mt-[20px] text-[16px] font-normal leading-none tracking-[-0.01em] text-[#202020] dark:text-white"> <div className="mt-3 text-[14px] font-normal leading-none text-[#202020] dark:text-white">
新建项目 新建项目
</div> </div>
</button> </button>
@ -71,23 +71,23 @@ export function LovartProjectsSection({
onClick={() => onOpenProject(item.id)} onClick={() => onOpenProject(item.id)}
className="block w-full cursor-pointer text-left" className="block w-full cursor-pointer text-left"
> >
<div className="aspect-[16/9] overflow-hidden rounded-[10px] border border-[#dddddd] bg-[#f4f4f4] dark:border-white/10 dark:bg-[#1b1c20]"> <div className="aspect-[16/9] overflow-hidden rounded-[8px] border border-[#dddddd] bg-[#f4f4f4] dark:border-white/10 dark:bg-[#1b1c20]">
{item.thumbnail ? ( {item.thumbnail ? (
<img <img
src={item.thumbnail} src={item.thumbnail}
alt={item.title} alt={item.title}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.025]" className="h-full w-full object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.018]"
/> />
) : ( ) : (
<div className="h-full w-full" /> <div className="h-full w-full" />
)} )}
</div> </div>
<div className="mt-[20px] truncate text-[16px] font-normal leading-none tracking-[-0.01em] text-[#202020] dark:text-white"> <div className="mt-3 truncate text-[14px] font-normal leading-none text-[#202020] dark:text-white">
{item.title || "Untitled"} {item.title || "Untitled"}
</div> </div>
<div className="mt-[10px] text-[13px] font-normal leading-none text-[#8e8e8e] dark:text-white/35"> <div className="mt-2 text-[11px] font-normal leading-none text-[#858585] dark:text-white/42">
{formatRelativeTime(item.updatedAt || item.createdAt)} {formatRelativeTime(item.updatedAt || item.createdAt)}
</div> </div>
</button> </button>

View File

@ -83,10 +83,10 @@ export function LovartSidebar({
}: LovartSidebarProps) { }: LovartSidebarProps) {
const isDark = tone === "dark"; const isDark = tone === "dark";
const sidebarPopupBaseClass = const sidebarPopupBaseClass =
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_24px_64px_rgba(0,0,0,0.42)]"; "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-9 w-9 shrink-0 items-center justify-center rounded-[11px] text-[#202020] transition-colors 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-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white" ? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
: "", : "",
@ -110,7 +110,7 @@ export function LovartSidebar({
> >
{renderIcon(key, { {renderIcon(key, {
active: options?.active, active: options?.active,
sizeClassName: "h-[18px] w-[18px]", sizeClassName: "h-4 w-4",
})} })}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -131,13 +131,13 @@ export function LovartSidebar({
className={sidebarButtonClass()} className={sidebarButtonClass()}
title={getItem(key).label} title={getItem(key).label}
> >
{renderIcon(key, { sizeClassName: "h-[18px] w-[18px]" })} {renderIcon(key, { sizeClassName: "h-4 w-4" })}
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent <HoverCardContent
side="right" side="right"
align="start" align="start"
className={`w-[248px] ${sidebarPopupBaseClass}`} className={`w-[232px] ${sidebarPopupBaseClass}`}
> >
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42"> <div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42">
{getItem(key).label} {getItem(key).label}
@ -147,7 +147,7 @@ export function LovartSidebar({
<button <button
key={link.id} key={link.id}
type="button" type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8" className="flex w-full items-center justify-between rounded-[8px] px-2.5 py-2 text-left text-[12px] text-black/80 transition-colors duration-150 hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8"
onClick={() => onNavLink(link.url)} onClick={() => onNavLink(link.url)}
> >
<span className="line-clamp-1">{link.name}</span> <span className="line-clamp-1">{link.name}</span>
@ -162,19 +162,19 @@ export function LovartSidebar({
return ( return (
<aside <aside
className={[ className={[
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[62px] 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-[#e9e9e9] bg-white", : "border-[#e9e9e9] bg-white",
].join(" ")} ].join(" ")}
> >
<TooltipProvider delayDuration={120}> <TooltipProvider delayDuration={120}>
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-4 pt-[15px] [-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
type="button" type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-opacity hover:opacity-80" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-opacity duration-150 hover:opacity-80"
onClick={onHome} onClick={onHome}
title={siteName} title={siteName}
> >
@ -182,10 +182,10 @@ export function LovartSidebar({
<img <img
src={siteLogo} src={siteLogo}
alt={siteName} alt={siteName}
className="h-8 w-8 rounded-full object-cover" className="h-7 w-7 rounded-full object-cover"
/> />
) : ( ) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-[13px] font-semibold text-white dark:bg-white dark:text-black"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()} {siteName.slice(0, 1).toUpperCase()}
</span> </span>
)} )}
@ -194,7 +194,7 @@ export function LovartSidebar({
<TooltipContent side="right">{siteName}</TooltipContent> <TooltipContent side="right">{siteName}</TooltipContent>
</Tooltip> </Tooltip>
<div className="mt-6 flex w-full flex-col items-center gap-[18px]"> <div className="mt-5 flex w-full flex-col items-center gap-3.5">
{isEnabled("newProject") ? ( {isEnabled("newProject") ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -205,7 +205,7 @@ export function LovartSidebar({
title={getItem("newProject").label} title={getItem("newProject").label}
> >
{renderIcon("newProject", { {renderIcon("newProject", {
sizeClassName: "h-[18px] w-[18px]", sizeClassName: "h-4 w-4",
})} })}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -225,13 +225,13 @@ export function LovartSidebar({
className={sidebarButtonClass()} className={sidebarButtonClass()}
title={getItem("tutorial").label} title={getItem("tutorial").label}
> >
{renderIcon("tutorial", { sizeClassName: "h-[18px] w-[18px]" })} {renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent <HoverCardContent
side="right" side="right"
align="start" align="start"
className={`w-[300px] ${sidebarPopupBaseClass}`} className={`w-[280px] ${sidebarPopupBaseClass}`}
> >
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42"> <div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42">
{getItem("tutorial").label} {getItem("tutorial").label}
@ -242,7 +242,7 @@ export function LovartSidebar({
<button <button
key={video.id} key={video.id}
type="button" type="button"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04] dark:hover:bg-white/8" className="flex w-full items-center gap-2.5 rounded-[8px] p-2 text-left transition-colors duration-150 hover:bg-black/[0.04] dark:hover:bg-white/8"
onClick={() => onTutorial(video)} onClick={() => onTutorial(video)}
> >
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8"> <div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">

View File

@ -39,13 +39,13 @@ export function BrandTopBar({
<header <header
className={cn( className={cn(
positionClass, positionClass,
"h-[68px] bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90", "h-14 bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90",
className className
)} )}
> >
<div <div
className={cn( className={cn(
"flex h-full items-center px-7", "flex h-full items-center px-5",
showBrand ? "justify-between" : "justify-end", showBrand ? "justify-between" : "justify-end",
contentClassName contentClassName
)} )}
@ -59,15 +59,15 @@ export function BrandTopBar({
{logo ? ( {logo ? (
<img <img
alt={resolvedSiteName} alt={resolvedSiteName}
className="h-8 w-8 rounded-full object-cover" className="h-7 w-7 rounded-full object-cover"
src={logo} src={logo}
/> />
) : ( ) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black"> <div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-3.5 w-3.5" />
</div> </div>
)} )}
<span className="text-[20px] font-semibold leading-none tracking-tight dark:text-zinc-100"> <span className="text-[17px] font-semibold leading-none dark:text-zinc-100">
{resolvedSiteName} {resolvedSiteName}
</span> </span>
</button> </button>

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--ui-radius-control)] text-[13px] font-medium ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@ -20,10 +20,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-9 px-3.5",
sm: "h-9 rounded-md px-3", sm: "h-8 px-2.5 text-xs",
lg: "h-11 rounded-md px-8", lg: "h-10 px-5 text-sm",
icon: "h-10 w-10", icon: "h-9 w-9",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -8,6 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card"
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-sm",
className className
@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-title"
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight",
className className
@ -50,6 +53,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
@ -60,7 +64,12 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div
ref={ref}
data-slot="card-content"
className={cn("p-6 pt-0", className)}
{...props}
/>
)) ))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
@ -70,6 +79,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-slot="card-footer"
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />

View File

@ -22,7 +22,7 @@ const DialogOverlay = forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-[9998] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-[9998] bg-black/55 duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -42,15 +42,15 @@ const DialogContent = forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-[9999] isolate grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg ring-1 ring-border/50 backdrop-blur-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-[9999] isolate grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-3 border bg-background p-5 shadow-lg ring-1 ring-border/50 backdrop-blur-none duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-[var(--ui-radius-panel)]",
className className
)} )}
{...props} {...props}
> >
{children} {children}
{hideCloseButton ? null : ( {hideCloseButton ? null : (
<DialogPrimitive.Close className="absolute right-4 top-4 z-[100] rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-3 top-3 z-[100] flex h-7 w-7 items-center justify-center rounded-md opacity-60 ring-offset-background transition-opacity hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@ -66,7 +66,7 @@ function DialogHeader({
return ( return (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1 text-center sm:text-left",
className className
)} )}
{...props} {...props}
@ -98,7 +98,7 @@ const DialogTitle = forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-base font-semibold leading-tight",
className className
)} )}
{...props} {...props}
@ -112,7 +112,7 @@ const DialogDescription = forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[13px] leading-5 text-muted-foreground", className)}
{...props} {...props}
/> />
)); ));

View File

@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-[var(--ui-radius-control)] border border-input bg-background px-2.5 py-1.5 text-base ring-offset-background file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-[13px]",
className className
)} )}
ref={ref} ref={ref}

View File

@ -17,6 +17,7 @@ const Label = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}
data-slot="label"
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />

View File

@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 isolate w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md ring-1 ring-border/50 outline-none backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 isolate w-68 rounded-[var(--ui-radius-panel)] border bg-popover p-3 text-[13px] text-popover-foreground shadow-md ring-1 ring-border/50 outline-none backdrop-blur-none duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className className
)} )}
{...props} {...props}

View File

@ -19,14 +19,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between rounded-[var(--ui-radius-control)] border border-input bg-background px-2.5 py-1.5 text-[13px] ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-3.5 w-3.5 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ))
@ -79,7 +79,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", "relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-[var(--ui-radius-control)] border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 origin-[--radix-select-content-transform-origin]",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
@ -119,7 +119,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} className={cn("py-1.5 pl-7 pr-2 text-[13px] font-semibold", className)}
{...props} {...props}
/> />
)) ))
@ -132,7 +132,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-[5px] py-1.5 pl-7 pr-2 text-[13px] outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className
)} )}
{...props} {...props}

View File

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 isolate gap-4 bg-background p-6 shadow-lg ring-1 ring-border/50 backdrop-blur-none transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 isolate gap-3 bg-background p-5 shadow-lg ring-1 ring-border/50 backdrop-blur-none transition ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200",
{ {
variants: { variants: {
side: { side: {
@ -65,8 +65,8 @@ const SheetContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-md opacity-60 ring-offset-background transition-opacity hover:bg-secondary hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
<span className="sr-only">关闭</span> <span className="sr-only">关闭</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
@ -80,7 +80,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-1 text-center sm:text-left",
className className
)} )}
{...props} {...props}
@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title <SheetPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold text-foreground", className)} className={cn("text-base font-semibold text-foreground", className)}
{...props} {...props}
/> />
)) ))
@ -120,7 +120,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description <SheetPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[13px] leading-5 text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))

View File

@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", "inline-flex h-9 items-center justify-center rounded-[var(--ui-radius-control)] bg-muted p-1 text-muted-foreground",
className className
)} )}
{...props} {...props}
@ -28,7 +28,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", "inline-flex items-center justify-center whitespace-nowrap rounded-[5px] px-2.5 py-1 text-[13px] font-medium ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className className
)} )}
{...props} {...props}
@ -52,4 +52,3 @@ const TabsContent = React.forwardRef<
TabsContent.displayName = TabsPrimitive.Content.displayName; TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }; export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex min-h-[72px] w-full rounded-[var(--ui-radius-control)] border border-input bg-background px-2.5 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-[13px]",
className className
)} )}
ref={ref} ref={ref}

View File

@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-[10000] isolate overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md ring-1 ring-border/50 backdrop-blur-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]", "z-[10000] isolate overflow-hidden rounded-[5px] border bg-popover px-2 py-1 text-[11px] leading-4 text-popover-foreground shadow-md ring-1 ring-border/50 backdrop-blur-none duration-100 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 origin-[--radix-tooltip-content-transform-origin]",
className className
)} )}
{...props} {...props}

View File

@ -0,0 +1,221 @@
# Claw 模式数据技能使用宣导
更新时间:2026-07-09
Claw 模式技能面向运营、店铺负责人、商品负责人和管理者,提供 Shopee、TikTok、Temu 等平台的经营诊断、广告投产、库存风险、售后原因、供应商和负责人维度分析。当前技能以公司数据库中已经接入的数据为准,会优先使用确定性计算结果,避免让模型凭感觉估算业务数字。
## 怎么使用
在 Claw 模式下,可以直接输入自然语言问题,也可以点快捷技能标签后补充 SKU、商品 ID、店铺、平台、时间范围等条件。
建议提问时尽量包含:
- 平台:Shopee、TikTok、Temu,或跨平台
- 对象:SKU、商品 ID、店铺、负责人、供应商、区域
- 时间:近 7 天、近 30 天、本月、上月,或明确日期范围
- 目标:看销售、利润、广告投产、库存风险、售后原因、排行、异常清单
示例:
- `Shopee SKU ABC123 近30天经营诊断`
- `Shopee SKU ABC123 近7天利润诊断,看看亏不亏`
- `查 Shopee 商品ID 123456789 近7天广告投产`
- `TikTok 近30天 SKU 销售排行 TOP20`
- `Temu 店铺经营看板,按币种对比当前和上期`
- `近7天店铺负责人维度分析,看销售额、广告消耗和费比`
## 当前真实可分析维度
| 维度 | 当前能分析什么 | 覆盖平台 |
| --- | --- | --- |
| SKU / 商品 | 销售、估算利润、成本拆解、跨平台表现、销售排行、退货风险 | Shopee、TikTok、Temu |
| 商品 ID / 广告对象 | 广告消耗、推广销售、ROAS、费比、异常投放 | Shopee |
| 店铺 | 店铺销售、订单、销量、广告消耗、ROAS、费比、动销 SKU | Shopee、TikTok、Temu |
| 店铺负责人 | 按负责人汇总店铺数、订单、销量、销售额、广告消耗、ROAS、费比 | Shopee、TikTok |
| 库存 | 可用库存、可售天数、库存风险、低周转或缺货风险 | Shopee 库存口径 |
| 售后 / 退货 | 售后原因、退款金额、退货件数、退货率、风险 SKU | Shopee、TikTok、Temu |
| 供应商 | 供应商销售、库存、到货数量、成本 | Shopee |
| 区域 | 省份 / 城市销售分布 | Shopee |
| 回款 / 退款 | 结算销售、放款金额、退款、平台费用和扣减 | Shopee |
当前宣导时只按上表介绍,不把尚未接入的数据源或规划中的市场洞察能力当成已上线技能。
## 当前技能总览
| 技能 | 适合谁用 | 能解决什么问题 | 常见问法 |
| --- | --- | --- | --- |
| Shopee SKU经营诊断 | Shopee 运营、商品负责人 | 查看指定 SKU 的销售、利润、成本拆解和经营问题 | `Shopee SKU ABC123 近30天经营诊断` |
| Shopee利润诊断 / 利润反推策略 | Shopee 运营、投放、负责人 | 汇总销售额、净回款、商品成本、广告费、估算利润和问题 SKU,给出控投、提价、补成本或补映射建议 | `Shopee SKU ABC123 近7天利润诊断,看看亏不亏` |
| Shopee广告投产诊断 | 投放、运营 | 按商品 ID / 店铺看广告消耗、销售、ROAS、投产表现 | `Shopee 商品ID 123456789 近7天广告投产怎么样` |
| 库存可售天数 | 运营、供应链 | 结合可用库存和近期销量判断还能卖多少天 | `SKU ABC123 库存还能卖几天` |
| Temu成本定价测算 | Temu 运营、商品、定价负责人 | 查看成本、申报价、毛利和定价空间 | `Temu SKU ABC123 成本定价测算` |
| 跨平台SKU看板 | 商品负责人、管理者 | 对比同一 SKU 在 Shopee、TikTok、Temu 的表现 | `SKU ABC123 跨平台看板` |
| Shopee店铺经营概览 | 店铺负责人、管理者 | 汇总 Shopee 店铺销售、广告消耗和 TOP 店铺表现 | `Shopee 近30天店铺经营概览` |
| Shopee SKU销售排行 | 运营、商品负责人 | 按销售额或销量查看 Shopee SKU 排行 | `Shopee 近7天 SKU 销售排行 TOP20` |
| Shopee广告异常清单 | 投放、运营负责人 | 找出映射缺失、低 ROAS、花费无销售等广告异常 | `Shopee 广告异常清单,先列烧钱无销售的` |
| Temu广告投产 | Temu 投放、运营 | 按币种查看广告费、销售额和投产表现 | `Temu 近30天广告投产` |
| Temu售后原因分析 | Temu 运营、售后、商品负责人 | 汇总 Temu 退货 / 售后原因,找高频问题 | `Temu 售后原因分析` |
| TikTok经营概览 | TikTok 运营、管理者 | 汇总 TikTok 销售、广告消耗和经营结构 | `TikTok 近30天经营概览` |
| Shopee回款退款分析 | 财务、运营、管理者 | 查看结算销售、放款金额、退款、费用和扣减 | `Shopee 近30天回款退款分析` |
| Shopee库存风险预警 | 运营、供应链 | 按可用库存和日销识别缺货、低周转、高风险 SKU | `Shopee 库存风险预警 TOP20` |
| Shopee区域销售分析 | 运营、区域策略负责人 | 按省份 / 城市看 Shopee 销售分布 | `Shopee 近30天区域销售分析` |
| TikTok广告投产明细 | TikTok 投放、运营 | 按订单和店铺匹配广告消耗与销售,查看投产明细 | `TikTok 广告投产明细` |
| Temu店铺经营看板 | Temu 店铺负责人、管理者 | 按店铺和币种看经营表现,并对比当前期与上期 | `Temu 店铺经营看板` |
| TikTok SKU销售排行 | TikTok 运营、商品负责人 | 按销售额或销量查看 TikTok SKU 排行 | `TikTok SKU 销售排行 TOP20` |
| TikTok退货SKU分析 | TikTok 运营、售后、商品负责人 | 统计 SKU 退货数量、退货率和风险 SKU | `TikTok 退货 SKU 分析` |
| Shopee供应商维度分析 | 供应链、商品负责人、管理者 | 按供应商汇总近期 Shopee 销售、可用库存、到货数量和成本 | `Shopee 近30天供应商维度分析 TOP20` |
| 跨平台售后维度分析 | 售后、商品负责人、管理者 | 对比 Shopee、TikTok、Temu 的售后风险和原因结构 | `跨平台售后维度分析` |
| 店铺负责人维度分析 | 店铺负责人、团队管理者 | 按 Shopee / TikTok 店铺负责人汇总店铺数、订单、销量、销售额、广告消耗、ROAS、费比和动销 SKU | `近7天店铺负责人维度分析` |
## 重点技能说明
### Shopee 经营与投放
Shopee 相关技能覆盖 SKU、店铺、广告、库存、区域、回款退款、供应商等多个经营视角。适合日常看盘、投放复盘、异常排查和周会汇报。
可以用于:
- 快速判断某个 SKU 是否值得继续放大
- 判断某个 SKU / 商品 ID 是否亏损,并反推先控投、提价、谈成本还是补数据映射
- 找到广告消耗高但销售弱的商品
- 识别库存不足或库存周转慢的 SKU
- 看 Shopee 店铺整体经营表现
- 追踪区域销售结构和重点省市
- 复盘回款、退款、平台费用对利润的影响
### TikTok 经营与广告
TikTok 技能主要覆盖经营概览、广告投产明细、SKU 销售排行和退货 SKU 分析。适合 TikTok 店铺运营和投放复盘。
可以用于:
- 看 TikTok 店铺近期销售和广告结构
- 查广告花费和销售匹配情况
- 找出销售 TOP SKU
- 识别退货率高或退货数量高的 SKU
### Temu 成本、广告与店铺
Temu 技能覆盖成本定价、广告投产、售后原因和店铺经营看板。Temu 广告和店铺数据会按币种分别计算,不混合不同币种。
可以用于:
- 判断 SKU 成本、申报价、毛利是否健康
- 看 Temu 广告投产表现
- 分析 Temu 售后 / 退货原因
- 按店铺和币种看经营表现,并对比当前期与上期
### 跨平台分析
跨平台技能适合商品负责人和管理者从全局视角看同一 SKU 或售后问题。
可以用于:
- 对比同一 SKU 在不同平台的经营表现
- 找出跨平台共性售后问题
- 辅助判断商品是否需要改款、优化详情、调整投放或控制库存
### 店铺负责人维度分析
这是本次检查到的新增技能。它会按 Shopee / TikTok 店铺负责人汇总经营表现,适合团队负责人做管理看板。
输出内容包括:
- 平台
- 店铺负责人
- 店铺数
- 订单数
- 销量
- 销售额
- 销售占比
- 广告消耗
- ROAS
- 费比
- 动销 SKU
口径说明:
- Shopee 店铺负责人来自 `shopee_shop.directly -> sys_user`
- TikTok 店铺负责人来自 `tictok_shop.directly -> sys_user`
- Temu 当前没有可解析负责人姓名字段,暂未纳入负责人维度
## 使用者提问模板
### 看 SKU
```text
Shopee SKU ABC123 近30天经营诊断
Shopee SKU ABC123 近7天利润诊断,看看亏不亏
Shopee 商品ID 123456789 近30天利润反推,今天先处理什么
TikTok SKU ABC123 销售排行里表现怎么样
SKU ABC123 跨平台看板
```
### 看广告
```text
Shopee 商品ID 123456789 近7天广告投产
Shopee 广告异常清单,先列低ROAS和花费无销售
TikTok 近30天广告投产明细
Temu 近30天广告投产
```
### 看库存
```text
SKU ABC123 库存还能卖几天
Shopee 库存风险预警 TOP20
```
### 看店铺与负责人
```text
Shopee 近30天店铺经营概览
TikTok 近30天经营概览
Temu 店铺经营看板
近7天店铺负责人维度分析
我负责的店铺近30天经营数据怎么样
```
### 看售后
```text
Temu 售后原因分析
TikTok 退货 SKU 分析
跨平台售后维度分析
```
### 看供应商
```text
Shopee 近30天供应商维度分析 TOP20
看供应商销售、库存、到货数量和成本
```
## 数据口径和权限提醒
- Claw 模式技能优先使用数据库确定性计算,不让模型自行编业务数字。
- 同一轮对话支持追问,例如先问 `TikTok经营概览`,后续可以继续问 `退货呢`、`看TOP20`。
- 如果新问题明确提到平台,会覆盖上一轮平台上下文。
- 涉及负责人、名下店铺、团队范围等问题时,会按登录身份和权限范围过滤。
- 非最高权限账号不能横向查看不属于自己范围的明细数据。
- Shopee 库存口径只统计可用库存,`blind=0`,锁定库存不计入可售库存。
- Shopee 利润诊断是估算利润口径:销售来自订单明细,净回款来自结算表,成本来自 SKU 成本表,广告通过商品 ID 到 SKU 映射;输出会展示成本覆盖率、回款覆盖率和广告映射率。
- Temu 广告和店铺经营按币种分别计算,不混合不同币种。
- Temu 售后原因表当前没有日期字段,分析结果会按现有记录口径说明。
- 跨平台售后中,Shopee 是金额口径,TikTok 是数量口径,Temu 是原因记录口径,不应简单混算成同一个比例。
- 当前版本不宣导公开市场趋势、全网竞品销量、平台大盘份额、Google Trends、Similarweb、Sensor Tower 等外部市场能力;这些属于后续可规划方向,不属于当前已上线技能。
## 宣导话术
Claw 模式不是普通聊天助手,而是把经营数据、广告数据、库存数据、售后数据和负责人权限打通后的业务分析助手。使用者不需要自己找表、导数、算指标,只要用业务语言提问,就能得到带口径说明的结论和表格,适合日常看盘、问题排查和管理汇报。
推荐先从这几类高频问题开始推广:
- 今天 / 近 7 天哪个 SKU 最值得放大?
- 哪些 SKU 现在是亏损的,今天先控投还是提价?
- 哪些广告在花钱但没有带来销售?
- 哪些 SKU 库存风险最大?
- 各店铺负责人最近经营表现怎么样?
- 售后问题主要集中在哪些平台、SKU 或原因?
- 供应商维度下,哪些商品销售好但库存或到货存在风险?

View File

@ -0,0 +1,197 @@
# Claw 模式数据技能使用宣导
更新时间:2026-07-09
Claw 模式技能面向运营、店铺负责人、商品负责人和管理者,提供 Shopee、TikTok、Temu 等平台的经营诊断、广告投产、库存风险、售后原因、供应商和负责人维度分析。技能会优先使用数据库里的确定性计算结果,避免让模型凭感觉估算业务数字。
## 怎么使用
在 Claw 模式下,可以直接输入自然语言问题,也可以点快捷技能标签后补充 SKU、商品 ID、店铺、平台、时间范围等条件。
建议提问时尽量包含:
- 平台:Shopee、TikTok、Temu,或跨平台
- 对象:SKU、商品 ID、店铺、负责人、供应商、区域
- 时间:近 7 天、近 30 天、本月、上月,或明确日期范围
- 目标:看销售、利润、广告投产、库存风险、售后原因、排行、异常清单
示例:
- `Shopee SKU ABC123 近30天经营诊断`
- `查 Shopee 商品ID 123456789 近7天广告投产`
- `TikTok 近30天 SKU 销售排行 TOP20`
- `Temu 店铺经营看板,按币种对比当前和上期`
- `近7天店铺负责人维度分析,看销售额、广告消耗和费比`
## 当前技能总览
| 技能 | 适合谁用 | 能解决什么问题 | 常见问法 |
| --- | --- | --- | --- |
| Shopee SKU经营诊断 | Shopee 运营、商品负责人 | 查看指定 SKU 的销售、利润、成本拆解和经营问题 | `Shopee SKU ABC123 近30天经营诊断` |
| Shopee广告投产诊断 | 投放、运营 | 按商品 ID / 店铺看广告消耗、销售、ROAS、投产表现 | `Shopee 商品ID 123456789 近7天广告投产怎么样` |
| 库存可售天数 | 运营、供应链 | 结合可用库存和近期销量判断还能卖多少天 | `SKU ABC123 库存还能卖几天` |
| Temu成本定价测算 | Temu 运营、商品、定价负责人 | 查看成本、申报价、毛利和定价空间 | `Temu SKU ABC123 成本定价测算` |
| 跨平台SKU看板 | 商品负责人、管理者 | 对比同一 SKU 在 Shopee、TikTok、Temu 的表现 | `SKU ABC123 跨平台看板` |
| Shopee店铺经营概览 | 店铺负责人、管理者 | 汇总 Shopee 店铺销售、广告消耗和 TOP 店铺表现 | `Shopee 近30天店铺经营概览` |
| Shopee SKU销售排行 | 运营、商品负责人 | 按销售额或销量查看 Shopee SKU 排行 | `Shopee 近7天 SKU 销售排行 TOP20` |
| Shopee广告异常清单 | 投放、运营负责人 | 找出映射缺失、低 ROAS、花费无销售等广告异常 | `Shopee 广告异常清单,先列烧钱无销售的` |
| Temu广告投产 | Temu 投放、运营 | 按币种查看广告费、销售额和投产表现 | `Temu 近30天广告投产` |
| Temu售后原因分析 | Temu 运营、售后、商品负责人 | 汇总 Temu 退货 / 售后原因,找高频问题 | `Temu 售后原因分析` |
| TikTok经营概览 | TikTok 运营、管理者 | 汇总 TikTok 销售、广告消耗和经营结构 | `TikTok 近30天经营概览` |
| Shopee回款退款分析 | 财务、运营、管理者 | 查看结算销售、放款金额、退款、费用和扣减 | `Shopee 近30天回款退款分析` |
| Shopee库存风险预警 | 运营、供应链 | 按可用库存和日销识别缺货、低周转、高风险 SKU | `Shopee 库存风险预警 TOP20` |
| Shopee区域销售分析 | 运营、区域策略负责人 | 按省份 / 城市看 Shopee 销售分布 | `Shopee 近30天区域销售分析` |
| TikTok广告投产明细 | TikTok 投放、运营 | 按订单和店铺匹配广告消耗与销售,查看投产明细 | `TikTok 广告投产明细` |
| Temu店铺经营看板 | Temu 店铺负责人、管理者 | 按店铺和币种看经营表现,并对比当前期与上期 | `Temu 店铺经营看板` |
| TikTok SKU销售排行 | TikTok 运营、商品负责人 | 按销售额或销量查看 TikTok SKU 排行 | `TikTok SKU 销售排行 TOP20` |
| TikTok退货SKU分析 | TikTok 运营、售后、商品负责人 | 统计 SKU 退货数量、退货率和风险 SKU | `TikTok 退货 SKU 分析` |
| Shopee供应商维度分析 | 供应链、商品负责人、管理者 | 按供应商汇总近期 Shopee 销售、可用库存、到货数量和成本 | `Shopee 近30天供应商维度分析 TOP20` |
| 跨平台售后维度分析 | 售后、商品负责人、管理者 | 对比 Shopee、TikTok、Temu 的售后风险和原因结构 | `跨平台售后维度分析` |
| 店铺负责人维度分析 | 店铺负责人、团队管理者 | 按 Shopee / TikTok 店铺负责人汇总店铺数、订单、销量、销售额、广告消耗、ROAS、费比和动销 SKU | `近7天店铺负责人维度分析` |
## 重点技能说明
### Shopee 经营与投放
Shopee 相关技能覆盖 SKU、店铺、广告、库存、区域、回款退款、供应商等多个经营视角。适合日常看盘、投放复盘、异常排查和周会汇报。
可以用于:
- 快速判断某个 SKU 是否值得继续放大
- 找到广告消耗高但销售弱的商品
- 识别库存不足或库存周转慢的 SKU
- 看 Shopee 店铺整体经营表现
- 追踪区域销售结构和重点省市
- 复盘回款、退款、平台费用对利润的影响
### TikTok 经营与广告
TikTok 技能主要覆盖经营概览、广告投产明细、SKU 销售排行和退货 SKU 分析。适合 TikTok 店铺运营和投放复盘。
可以用于:
- 看 TikTok 店铺近期销售和广告结构
- 查广告花费和销售匹配情况
- 找出销售 TOP SKU
- 识别退货率高或退货数量高的 SKU
### Temu 成本、广告与店铺
Temu 技能覆盖成本定价、广告投产、售后原因和店铺经营看板。Temu 广告和店铺数据会按币种分别计算,不混合不同币种。
可以用于:
- 判断 SKU 成本、申报价、毛利是否健康
- 看 Temu 广告投产表现
- 分析 Temu 售后 / 退货原因
- 按店铺和币种看经营表现,并对比当前期与上期
### 跨平台分析
跨平台技能适合商品负责人和管理者从全局视角看同一 SKU 或售后问题。
可以用于:
- 对比同一 SKU 在不同平台的经营表现
- 找出跨平台共性售后问题
- 辅助判断商品是否需要改款、优化详情、调整投放或控制库存
### 店铺负责人维度分析
这是本次检查到的新增技能。它会按 Shopee / TikTok 店铺负责人汇总经营表现,适合团队负责人做管理看板。
输出内容包括:
- 平台
- 店铺负责人
- 店铺数
- 订单数
- 销量
- 销售额
- 销售占比
- 广告消耗
- ROAS
- 费比
- 动销 SKU
口径说明:
- Shopee 店铺负责人来自 `shopee_shop.directly -> sys_user`
- TikTok 店铺负责人来自 `tictok_shop.directly -> sys_user`
- Temu 当前没有可解析负责人姓名字段,暂未纳入负责人维度
## 使用者提问模板
### 看 SKU
```text
Shopee SKU ABC123 近30天经营诊断
TikTok SKU ABC123 销售排行里表现怎么样
SKU ABC123 跨平台看板
```
### 看广告
```text
Shopee 商品ID 123456789 近7天广告投产
Shopee 广告异常清单,先列低ROAS和花费无销售
TikTok 近30天广告投产明细
Temu 近30天广告投产
```
### 看库存
```text
SKU ABC123 库存还能卖几天
Shopee 库存风险预警 TOP20
```
### 看店铺与负责人
```text
Shopee 近30天店铺经营概览
TikTok 近30天经营概览
Temu 店铺经营看板
近7天店铺负责人维度分析
我负责的店铺近30天经营数据怎么样
```
### 看售后
```text
Temu 售后原因分析
TikTok 退货 SKU 分析
跨平台售后维度分析
```
### 看供应商
```text
Shopee 近30天供应商维度分析 TOP20
看供应商销售、库存、到货数量和成本
```
## 数据口径和权限提醒
- Claw 模式技能优先使用数据库确定性计算,不让模型自行编业务数字。
- 同一轮对话支持追问,例如先问 `TikTok经营概览`,后续可以继续问 `退货呢`、`看TOP20`。
- 如果新问题明确提到平台,会覆盖上一轮平台上下文。
- 涉及负责人、名下店铺、团队范围等问题时,会按登录身份和权限范围过滤。
- 非最高权限账号不能横向查看不属于自己范围的明细数据。
- Shopee 库存口径只统计可用库存,`blind=0`,锁定库存不计入可售库存。
- Temu 广告和店铺经营按币种分别计算,不混合不同币种。
- Temu 售后原因表当前没有日期字段,分析结果会按现有记录口径说明。
- 跨平台售后中,Shopee 是金额口径,TikTok 是数量口径,Temu 是原因记录口径,不应简单混算成同一个比例。
## 宣导话术
Claw 模式不是普通聊天助手,而是把经营数据、广告数据、库存数据、售后数据和负责人权限打通后的业务分析助手。使用者不需要自己找表、导数、算指标,只要用业务语言提问,就能得到带口径说明的结论和表格,适合日常看盘、问题排查和管理汇报。
推荐先从这几类高频问题开始推广:
- 今天 / 近 7 天哪个 SKU 最值得放大?
- 哪些广告在花钱但没有带来销售?
- 哪些 SKU 库存风险最大?
- 各店铺负责人最近经营表现怎么样?
- 售后问题主要集中在哪些平台、SKU 或原因?
- 供应商维度下,哪些商品销售好但库存或到货存在风险?

Binary file not shown.

View File

@ -0,0 +1,364 @@
# 视频画布竞品对比记录
## 当前测试环境
- 本地服务: http://127.0.0.1:3003(3000 被旧进程占用;使用 127.0.0.1 避免旧 localhost Cookie 干扰)
- 本地测试画布: `/canvas/bc8dcef2-b2b4-4a00-8712-52888158b934?mode=video&workspaceType=video`
- 本地测试账号: `codex-video-test@example.com`
- 测试数据源: 服务器生产库复制到隔离 schema `codex_video_canvas_local`
- 已复制数据: `AiProvider` 34 条、`AiModel` 23 条、`SystemSettings` 60 条、`SiteSettings` 1 条、存储/导航/教程/Agent preset 配置、测试用户 1 个
- 本地调整: 仅测试 schema 中开启 `points_system_config.enabled`,用于显示/验证积分余额
- 本地调试开关: `CANVAS_E2E_LOCAL=true` 时仅对 loopback 的 `/api/auth/session` 与 `/api/auth/validate-session` 跳过会话限流,避免本地高频浏览器验证触发登录失效
- 本地模型回退: 测试 schema 中 Moonshot `kimi-k2.5` 返回 401 `Invalid Authentication`,已在隔离 schema 标记为 `auto_disabled`;代码已修复自动代理模型选择,避免继续选中 `auto_disabled` 的默认模型,当前本地脚本生成实际走 `qwen3-vl-plus`
## 已操作的竞品功能
- 初始视频画布布局: 暗色点阵无限画布、四个启动卡、底部工具栏、缩放/吸附/小地图等控制
- 底部主工具栏布局: 竞品在 `1512 × 805` 视口下主入口为底部居中横排 32px 图标按钮,坐标依次为 `添加节点 x=616 y=753`、`打开工具箱 x=656`、`素材库 x=696`、`角色库 x=736`、`历史记录 x=776`、`快捷键 x=825`、`教程 x=865`;`历史记录` 与 `快捷键` 之间有额外约 `17px` 间隔,其余相邻间隔约 `8px`
- 故事脚本生成: 默认提示词、GVLM 3.1、消耗 6 点、脚本工作流创建、18 镜头表格
- 剧本三步工作流: 确认镜头、准备资产、合成提示词
- 剧本三步工作流入口: 从脚本节点点击 `打开脚本节点 →` 后进入全屏深色工作流,顶部三步为 `确认镜头 / 准备资产 / 合成提示词`,分别显示 `18个镜头已就绪`、`7/7 已生成`、`18/18 已合成`,右上显示 `3/3 完成后可批量生视频` 和关闭按钮
- 确认镜头表格: Step 1 使用全屏表格,列顺序为 `镜号 / 时长 / 画面描述 / 景别 / 光影氛围 / 对白·旁白 / 音效 / 运镜 / 最终提示词 / 操作`;可见 18 镜,时长以 `5s/6s` 为主,内容通过 `@现代沈昭昭`、`@金銮殿`、`@沈昭昭`、`@侍卫长刀` 等资产引用串联剧情;底部左侧为 `添加镜头`,右侧为 `→ 下一步:准备资产`
- 最终提示词弹窗: 点击单行 `查看提示词` 后打开居中弹窗 `第 1 镜:最终提示词`,分为 `分镜提示词` 和 `视频运动提示词` 两块,状态均为绿色 `已生成`;分镜提示词为中文长句并追加 `[视觉风格:盛唐·国风二次元]`,视频运动提示词按 `起始状态 / 动作过程 / 结束状态 / 音效` 组织,底部显示 `GVLM 3.1 Flash`、消耗 `2`、`重新合成提示词`
- 准备资产页: Step 2 为资产网格,顶部显示 `全局风格 盛唐·国风二次元`;分组为 `角色`、`场景`、`道具`,资产卡包含 `现代沈昭昭 / 沈昭昭 / 皇帝`、`金銮殿 / 长安城白昼 / 长安城夜景`、`侍卫长刀`,每组末尾有虚线 `新增` 卡;hover 卡片右上出现菜单和放大镜,菜单项为 `替换图片`、`AI 生角色`、`清除图片`、红色 `删除`
- 合成提示词页: Step 3 回到 18 镜表格并高亮最终提示词链路,底部主按钮为 `一键合成全部提示词`;本轮只确认入口、状态和表格呈现,未重复点击生成以避免再次扣点或改写竞品项目
- 一键准备资产: 角色/场景/道具模板、Lib Image、标准画质、2K、2:1、7 个资产、实际扣 126 点
- 一键合成提示词: GVLM 3.1 Flash、18 镜头、图像提示词和视频运动提示词拆分、实际扣 22 点
- 批量生成分镜: 创建 18 个图片生成器组、Lib Image、2K、自适应
- 批量生视频: Seedance 2.0 VIP、16:9、720P、5s、18 个视频生成器组、整组执行成本 2316 点
- 单个视频生成结果: 结果卡显示 1280x720、播放进度、剪辑/裁剪/高清/解析/字幕/音频分离等工具
- 视频结果卡 hover 控件: hover `分镜视频-#1` 后底部出现播放圆钮、时间 `0:00 / 0:05`、进度条、右下帧截取圆钮;帧截取菜单为 `截取首帧`、`截取尾帧`、`截取当前帧`
- 视频帧截取复查: 竞品菜单项可命中;本轮点击 `截取首帧` 后节点数仍为 33、图片节点数仍为 7,未出现成功 toast,页面已有多条历史 `失败` 文案,推测当前素材/状态下帧截取未落新节点
- 视频结果工具条二级功能: 选中生成结果 `分镜视频-#1` 后顶部工具条顺序为 `剪辑`、`裁剪`、`高清`、`解析`、`智能去字幕`、`音频分离`
- 视频结果裁剪: 点击 `裁剪` 后不打开 modal,而是在视频卡内出现裁剪框,裁剪框和视频卡下方内联编辑条均显示 `1024 x 576`;底部条左侧按钮为 `退出裁剪`,右侧圆形上箭头按钮为 `生成裁剪`
- 视频裁剪生成: 点击 `生成裁剪` 后页面提示 `正在裁剪视频,请稍候…`,按钮进入 `生成中 37%` 等进度态;竞品前端成功路径文案为 `已创建裁剪视频节点并开始上传`,对应行为是创建下游裁剪视频节点并上传裁剪后的 blob;本次实测页面最终未出现可见新节点且余额仍为 `531`,疑似当前跟随/任务状态下裁剪任务未完成落点
- 视频结果高清: 点击 `高清` 打开小型参数面板,包含 `Topazlabs`、`1080P`、`2K`、`4K`、`不补帧`、`1x`,点击选项前不会直接生成
- 视频高清落节点: 复查画布当前状态时,`分镜视频-#1` 已连出一个 `高清(1080P)` 节点,节点 class 为 `react-flow__node-video`,文本为 `配置参数生成高清视频`;边为 `Edge from v-kfwaCZvhsJ to v-2BDan8Sb2j`,说明高清不是 toast/浮层结果,而是下游视频工具节点
- 视频结果智能去字幕: 点击 `智能去字幕` 后出现说明 `AI一键去除视频字幕,仅支持中英文字幕`,下拉项为 `智能擦除`、`框选擦除`;点击 `智能擦除` 后进入视频卡下方内联生成器,原工具条收起,含 `关闭去字幕生成器`、标签 `智能擦除`、消耗 `0`、右侧图标生成按钮
- 视频智能擦除生成: 点击生成按钮后余额仍为 `531`,节点数从 30 增至 31,连线数从 45 增至 46;新增下游视频任务节点 `视频一键去字幕-分镜视频-#1`,边为 `Edge from v-kfwaCZvhsJ to v-khP9pMw41s`,节点文本先为 `生成中... 取消`,约 12s 后为 `生成中 2%... 取消`,约 42s 后为 `生成中 5%... 取消`
- 视频智能擦除取消: 复制 `分镜视频-#1_无声` 得到干净副本后触发 `智能擦除`,余额仍为 `531`,新增边 `M3662,-632 ... 4463,-1237` 指向视口外 pending 节点;沿边定位到 `视频一键去字幕-分镜视频-#1_无声 - 副本`,节点文案为 `生成中... / 取消`,随后进度到 `1%/2%/3%/7%/11%`;点击节点内 `取消` 不直接删除,而是弹出确认框 `取消生成 / 正在生成中,即将完成!/ 停止将不退还已消耗算力。/ 取消 / 确定`;点击 `确定` 后弹窗关闭,但至少 35s 内任务节点和连线仍保留、继续显示 `生成中 ... / 取消`,无 toast、无失败态、无乐观删除
- 生成失败态排查: 当前竞品项目未找到真实失败任务样本;历史记录面板的 `图片历史(8)`、`视频历史(10)`、`音频历史(6)` 中均未出现 `失败` 或 `重试`;资产管理侧栏 `画布元素` 点击 `搜索节点` 后输入 `失败`,搜索框 placeholder 为 `搜索节点`,结果区显示 `当前画布没有相关搜索结果`,同时侧栏底部仍显示 `共 96 节点`;可见节点、React Flow 节点文本、localStorage/sessionStorage 中也未命中 `失败/重试/failed/error`,本轮未获得竞品真实 failed 节点视觉
- 视频框选擦除入口: 点击 `智能去字幕 -> 框选擦除` 后,竞品自动放大/聚焦当前视频卡,原顶部视频工具条收起,视频卡下方出现 49px 高悬浮操作条;操作条包含 `关闭去字幕生成器`、标签 `框选擦除`、帮助问号、`框选区域`、`撤销`、`重做`、`重置`、费用和右侧生成按钮;未框选时费用显示 `-`,撤销/重做/重置和生成按钮为禁用态
- 视频框选擦除帮助: hover 问号显示 tooltip,文案为 `使用方法 / 在画面上拖拽鼠标,框选要擦除的区域 / 支持框选多个区域,所有框内文字将被擦除 / 擦除作用于整段视频,不仅是当前帧`
- 视频框选擦除选区: 在视频画面内拖拽后出现青色半透明矩形,样式为 `rgba(7, 184, 221, 0.15)` 填充、`rgb(6, 144, 174)` 边框、四角 `rgb(9, 202, 245)` 拖拽点;支持多个区域,本次实测两个区域同时存在
- 视频框选擦除编辑历史: 有区域后费用变为 `0`,生成按钮启用;`撤销` 会移除最后一个区域并启用 `重做`,`重做` 恢复后再次禁用;`重置` 清空所有区域,费用回到 `-`,生成按钮禁用,同时 `撤销` 仍启用,说明重置也进入历史栈
- 视频框选擦除生成: 生成前余额为 `531`、节点数 9、边数 27;点击生成后余额仍为 `531`,边数增至 28,新增边 `Edge from v-kfwaCZvhsJ to v-Ajx1SDnP6W`;当前视口未渲染目标节点,疑似目标节点位于 React Flow 虚拟化视口外或生成后归入视频组,已确认至少创建了从源视频到新任务/结果节点的下游边;竞品前端 chunk 显示框选模式后端参数为 `model: volcano-subtitle-eraser`、`mode: Text`、`erase_ratio_location` 归一化区域数组
- 视频结果音频分离: 点击 `音频分离` 后出现 `人声分离`、`音视频分离`;hover `人声分离` 后右侧二级菜单为 `仅保留人声`、`仅保留背景音`;`音视频分离` 的说明为 `分离内嵌音轨为独立音频节点`
- 音视频分离落节点: 点击 `音视频分离` 后节点数从 34 增至 36、音频节点数从 1 增至 2;先出现 `音频上传中(100%)...`,约 3.5s 后落为新音频节点 `分镜视频-#1_音轨`,时长显示 `00:00 / 00:05`,并同时连出 `分镜视频-#1_无声` 视频节点
- 人声分离仅保留人声: 点击 `人声分离 -> 仅保留人声` 后,源视频工具条按钮变为 `分离中`,页面提示 `人声分离处理中,请稍候…`,余额保持 `531`;约 6.5s 后节点数从 11 增至 13、边数从 28 增至 30,新增音频节点 `分镜视频-#1_人声` 和视频节点 `分镜视频-#1_无声`,两条边均从源视频 `分镜视频-#1` 连出
- 人声分离仅保留背景音: 在已执行过人声/音视频分离的 `分镜视频-#1` 上点击该项后,同样出现 `分离中` 和 `人声分离处理中,请稍候…`;约 20s 后节点数仍为 13、未出现 `分镜视频-#1_背景音`,边数从 30 增至 32,疑似复用已有分离结果或需干净源视频复测
- 人声分离仅保留背景音(干净副本): 将可见视频结果 `视频一键去字幕-分镜视频-#1` 复制为 `视频一键去字幕-分镜视频-#1 - 副本` 后,点击 `人声分离 -> 仅保留背景音`,源视频工具条立即变为 `分离中`,页面提示 `人声分离处理中,请稍候…`,余额保持 `531`;约 8s 后边数从 6 增至 8,向右平移画布后发现两个结果节点同坐标 `translate(5286px, -962px)`,先渲染 350x350 视频节点 `视频一键去字幕-分镜视频-#1 - 副本_无声`,再渲染 350x148 音频节点 `视频一键去字幕-分镜视频-#1 - 副本_背景音`
- 视频结果解析: 点击 `分镜视频-#1` 的 `解析` 后余额从 `533` 变为 `531`,按钮文案变为 `解析中`,页面出现 `正在解析视频...`
- 视频解析落节点: 约 2.5s 后出现 `解析完成,已创建视频故事节点`;新增边 `e-Mjyng4PRdE`,`aria-label` 为 `Edge from v-kfwaCZvhsJ to b-YCox1NJ2cN`,源节点为 `分镜视频-#1`,目标节点为 `视频故事`
- 视频故事节点: 节点 class 为 `react-flow__node-video-story`,标题 `视频故事`,表头顺序为 `镜号 / 开始时间 / 结束时间 / 时长 / 画面描述 / 叙事内容 / 景别 / 摄影机角度 / 摄影机运动 / 焦距与景深 / 光线 / 背景音乐 / 人声/音效 / 图像生成提示词 / 视频运动提示词 / 关键帧`
- 视频故事内容: 当前 5s 视频生成 1 行,时间 `0 / 5 / 5`;画面描述为深夜办公室加班女性,叙事内容强调职场压抑和文件压迫感,镜头为 `中景至近景 / 平视 / 缓慢推镜头 (Slow Zoom In) / 浅景深`,图像和视频运动提示词使用英文
- 添加节点菜单: 文本、图片、视频、视频合成 Beta、导演台 NEW、音频、脚本子菜单、素材库子菜单、上传、从生成历史选择
- 添加资源入口: `上传` 打开文件选择器;`从生成历史选择` 打开历史资产选择面板
- 上传入口文件选择器: 点击 `添加节点 -> 上传` 可打开 file chooser,chooser 支持 multiple;使用无敏感测试图片 `/tmp/codex-video-canvas-upload.png` 执行 `setFiles` 时,Chrome 扩展返回 `Not allowed / fileChooser.setFiles failed`,节点数保持 13,未出现测试文件名或新节点;按 Chrome 插件说明需在 `chrome://extensions` 为 Codex 扩展开启 `Allow access to file URLs` 后才能继续真实上传复测
- 图片节点: 图生图、图片高清、参考/标记/风格、Lib Image、标准画质、2K、1 张、18 点
- 视频节点: 首尾帧/首帧模式、参考/标记/特效/角色库/运镜、Seedance 2.0、文生视频、16:9/720P/5s/1 个
- 视频模型菜单: Seedance 2.0 VIP/Fast/Mini、Happy Horse 1.1/1.0、Kling O3/3.0/O1/2.6、Wan 2.7、动作迁移、MJ Video
- 视频参数菜单: Auto/16:9/4:3/1:1/3:4/9:16/21:9、480P/720P/1080P/4K、时长、音频开关、数量 1/2/4
- Prompt 辅助: 8 个运镜卡、特效库九宫格、角色库选择器
- 音频生视频预设: 自动创建音频/图片/视频节点组并连接
- 视频合成 Beta: 空态提示“空空如也,请连接视频节点后操作”
- 导演台 NEW: 3D 导演台全屏编辑器、导演/机位视角、场景树、属性、FOV、坐标、截图工具
- 素材库入口: 左侧小弹层,包含 `风格库 新增风格节点 NEW` 和 `特效库 新增特效节点 NEW`
- 特效库广场: tabs 为 `特效广场 / 我的收藏 / 最近使用`,搜索框 placeholder `搜索特效名称、作者`,分类 `推荐 / 全部`,卡片显示名称、`商用`、作者、使用数
- 风格库广场: tabs 为 `风格广场 / 我的收藏 / 最近使用`,搜索框 placeholder `搜索风格名称、作者`,分类包含 `推荐`、`Midjourney`、`摄影写真`、`电商营销`、`动漫游戏`、`风格插画`、`平面设计`、`建筑及室内设计`、`创意玩法`、`文创周边`、`小说推文`,带 `仅看可商用` checkbox
- 历史记录面板: 标题 `历史资产`,顶部显示 `100%`,tabs 为 `图片历史(8)`、`视频历史(1)`、`音频历史(0)`,排序按钮 `时间降序`,操作入口 `批量操作`
- 历史资产时间排序: 点击 `时间降序` 不打开菜单,而是按钮文案立即切为 `时间升序`;再次点击 `时间升序` 立即切回 `时间降序`;本次竞品临时页历史面板以居中大浮层展示,内容框约 `x=76 y=80 w=1361 h=645`、外层 z-index `601`
- 历史资产面板布局: 本轮临时页为居中大浮层,不是左侧抽屉;外层 `pointer-events: none`、内容框 `pointer-events: auto`、背景 `rgb(38,38,38)`、圆角 `16px`;标题位于 `x=92 y=99`,右上含 `100%` 缩放组和 28px 无文字关闭图标,点击关闭图标后 `历史资产` 消失;tabs 行位于 `y=159`,右侧 `时间降序` 与 `批量操作` 均为 `86 × 32`;素材区按 `144 × 144` 方形卡片横向网格排列,首行 x 约 `96/252/408/564/720/876...`,卡片 hover 层含 `查看/使用/下载`
- 历史资产缩放组: 顶部 `100%` 左右两个无文字 28px 按钮为离散缩放控件;从 `100%` 点左侧按钮变为 `75%`,首卡从 `144 × 144` 变为 `130 × 130`,前三卡 x 为 `96/238/380`;点右侧按钮依次回到 `100%`、再到 `125%`(卡片 `182 × 182`,x 为 `96/290/484`)、`150%`(卡片 `248 × 248`,x 为 `96/356/616`)
- 历史资产项: 按日期分组,如 `2026-07-09`,每项有 `查看`、`使用`、`下载`
- 历史资产批量操作: 点击 `批量操作` 后顶部切换为 `已选择 0 项`,操作按钮为 `删除`、`下载`、`使用`、`取消选择`;未选中素材时前三项禁用、`取消选择` 可用;`视频历史(1)` 下日期组和素材项均出现 checkbox;选中 1 条素材后变为 `已选择 1 项`,`删除/下载/使用` 全部启用
- 历史资产批量布局: 大浮层批量模式下顶部操作区位于 tabs 行右侧,`已选择 0 项` 在 `x=1048 y=164 w=67 h=20`,`删除/下载/使用/取消选择` 分别为 `x=1123/1200/1277/1352 y=161`,尺寸为 `69×28 / 69×28 / 50×28 / 68×28`;日期组 checkbox 为 `x=96 y=209 16×16`,素材 checkbox 在卡片左上,首行 x 为 `102/258/414/570/726/882/1038/1194`、y=`245`
- 历史资产批量下载: 当前视频历史为 `视频历史(5)`;批量模式选中 1 条视频素材后 `删除/下载/使用/取消选择` 均启用;点击 `下载` 后未触发浏览器 download 事件,四个按钮立即全部进入禁用态,至少 8s 内未恢复,页面无额外成功/失败 toast
- 历史资产批量使用: 在临时竞品页打开 `历史资产`,进入批量模式并选中 1 条默认图片历史素材后点击顶部 `使用`;面板关闭,页面出现 toast `已应用到画布`,画布新增并选中图片节点 `图片28`,节点 class 为 `react-flow__node-image`,可见参数区含 `Lib Image / 自适应 · 标准画质 · 2K · 1张 / 18`
- 历史资产列表单项下载: 点击默认图片历史第一项列表内 `下载` 后,6s 内未触发浏览器 download 事件;历史面板保留,未打开预览层,被点击的 `下载` 按钮保持文案 `下载` 但进入 disabled;同时弹出 `AI 生成内容水印管理规则` modal,modal 外层 z-index `801`、内容框 `740 × 404`、位置约 `x=386 y=201`,含 `去 AI 水印` 和 `保存设置`
- 历史资产水印规则弹窗关闭: 弹窗打开后页面没有可见关闭 X;按 `Escape` 不关闭水印规则 modal,而是继续传到底层并关闭历史资产面板,modal 仍以 z-index `801` 保留;点击弹窗外遮罩也不关闭 modal;未点击 `保存设置`,避免修改竞品账号水印配置
- 历史资产水印规则开关: `去 AI 水印` 行为左侧 Mantine switch,input `role="switch"`、`type="checkbox"`、`checked=false`,轨道约 `38 × 20`、位置约 `x=410 y=445`、灰色 `rgba(255,255,255,0.25)`,圆点在左侧且为深色;点击轨道、文字、外层行和精确 input 均未改变 checked 状态,弹窗保持打开;正文滚动区约 `160px` 高,提示文案在 switch 下方,保存按钮位于 `x=972 y=537`
- 历史资产单项查看: 点击默认图片历史素材的 `查看` 后,不关闭历史面板、不选中画布节点,而是在其上打开全屏黑色预览层;遮罩为 `bg-black/80`、z-index `700`,中心图片 `object-contain`,底部显示 `1 / 8` 和 `下载`,右侧有下一张箭头,右上角有关闭按钮
- 历史资产预览关闭: 预览打开后,点击黑色遮罩空白处、点击右上角关闭按钮、按 `Escape` 都只关闭预览层;历史面板继续保留在底层,画布节点仍不选中
- 历史资产预览切换: 第一张只显示右侧下一张箭头,点击后计数从 `1 / 8` 变为 `2 / 8`,左右箭头同时出现并贴近视口边缘(左 `x=16`、右 `x=1456`);按 `ArrowRight` 继续到 `3 / 8`,按 `ArrowLeft` 回到 `2 / 8`
- 历史资产预览下载: 点击预览层底部 `下载` 后,6s 内未触发浏览器 download 事件;预览层和历史面板都保留,底部按钮变为禁用态 `下载中...`,约 13s 后仍保持 pending,按钮宽约 118px
- 音频历史空态: 显示 `暂无历史记录`
- 快捷键面板: 非 modal 浮层,有关闭按钮 `关闭快捷键面板`
- 快捷键面板布局: 点击底部 `快捷键` 后打开 z-index `300` 的居中大浮层,外壳约 `x=180 y=300 w=1152 h=436`、背景约 `#444/95`、圆角 `16px`;没有可见标题,右上 28px 关闭按钮位于 `x=1292 y=313`;内容四列,列标题 `创作/缩放/移动画布/其他` 分别位于 `x=205/493/781/1069 y=325`,列宽约 `239`,行高 `28`、行距 `12`;按 `Escape` 和点击面板外均不关闭,只能点右上关闭按钮
- 快捷键分组:
- 创作: `成组 G`、`合并分镜组 Option+G`、`解组 Shift+G`、`连线 L`、`复制节点和连线 D`、`生成 Enter`、`新建节点 Tab`、`节点复制 Option+拖动节点`
- 缩放: `放大`、`缩小`、`适应画布 0`
- 移动画布: `Space`、触控板、鼠标
- 其他: `整理画布 Option+Shift+F`、`撤销 Z`、`重做 Shift+Z`、`删除`
- 教程菜单: 点击 `教程` 后显示小菜单 `使用教程`、`联系客服`、`联系销售`、`关注公众号`
- 教程菜单布局: 点击底部 `教程` 后在图标上方弹出 z-index `300` 小菜单,外壳约 `x=865 y=582 w=104 h=154`、背景 `rgb(38,38,38)`、圆角 `12px`;四个纯文字按钮均为 `95 × 36`,`使用教程/联系客服/联系销售/关注公众号` y 坐标分别为 `587/623/659/695`;本轮自动化点击 `使用教程` 未捕获到新 tab,但历史手动/前轮记录显示该项会打开飞书 `LibTV使用指南`
- 使用教程: 点击 `使用教程` 会打开飞书 `LibTV使用指南` 标签页
- 资产管理入口: 闭合态左下按钮为带文字的 `资产管理`,`1512 × 805` 视口下约 `x=22 y=759 w=94 h=28`;打开后左下 dock 仍贴视口底部,面板内右上 `资产管理` 图标按钮位于 `x=200 y=101 w=32 h=32`
- 资产管理面板布局: 打开后是 Mantine Drawer 式左侧全高侧栏,外壳 `x=0 y=0 w=240 h=805`、背景 `rgb(23,23,23)`、圆角 `0`、drawer inner z-index `30`;顶部工作区输入约 `x=12 y=56 w=78 h=20`,项目按钮 `画布 1` 约 `x=99 y=52 w=68 h=28`;tabs 行 `画布 / 资产` 位于 `x=8/60 y=102 w=44 h=30`,active tab 背景 `rgba(255,255,255,0.15)`
- 资产管理画布 tab 控件: 标题区 `x=0 y=142 h=52`,行内 `画布元素` 在 `x=16 y=158`,`收起全部分组` `x=70 y=154 w=28 h=28`,类型筛选 `x=148 y=154 w=54 h=28`,搜索按钮 `x=204 y=154 w=28 h=28`
- 资产管理宽度拖拽复测: Drawer 右侧有 `role=separator`、aria `调整工作区面板宽度` 的 12px 热区,class 含 `workspace-panel-drawer-resize-handle`、cursor 为 `col-resize`;从默认 240px 沿 `x=233 -> 360` 拖拽后侧栏宽变 `360`,separator 到 `x=347.5 w=12`,关闭按钮/筛选/搜索分别到 `x=319.5/267.5/323.5`,列表主定位按钮宽约 `335.5`,更多/定位按钮到 `x=293.5/319.5`,底部统计宽约 `359.5`;左下 dock 随侧栏宽度移动到 `整理/小地图/隐藏连线/吸附/缩放 x=382/414/446/478/510`,画布 viewport transform 不变。本轮从 360 回拖未稳定复现缩窄,不把最大/回缩边界作为已确认事实
- 资产管理收起全部分组复测: 点击 `收起全部分组 x=70 y=154 w=28 h=28` 后按钮 aria 变为 `展开全部分组`,但当前可见的普通节点行仍保留,列表可见定位行数量保持 `28`;滚动内容总高度从约 `4130` 缩到 `2072`,说明只收起分组内部内容,不会把非分组节点隐藏成空态,底部统计仍为 `共 98 节点`
- 资产管理搜索: 点击 `搜索节点` 后标题行原地切换为输入框,placeholder `搜索节点`,输入框约 `x=29 y=158 w=101 h=20`,右侧按钮变为 `关闭搜索` 并保持 `x=204 y=154 w=28 h=28`;输入 `镜头 1` 后列表即时过滤为 `镜头 1/10/11/...`
- 资产管理类型筛选: `全部`、`文本`、`图片`、`视频`、`视频合成`、`导演台`、`音频`、`脚本`、`脚本(旧版)`;下拉菜单 `role=menu`,约 `x=148 y=186 w=180 h=333`、背景 `rgb(23,23,23)`、圆角 `12px`、z-index `410`,每项 `171 × 32`
- 资产管理音频类型筛选复测: 在类型菜单中点击 `音频 x=152 y=410.5 w=171 h=32` 后,筛选按钮 aria/text 变为 `筛选类型:音频 / 音频`,菜单关闭;列表只保留音频类主行,本轮可见 `分镜视频-#1_背景音`、`分镜视频-#1_人声`、`分镜视频-#1_音轨`、`视频一键去字幕-分镜视频-#1 - 副本_背景音`、`音频节点 10/13/5/4` 共 8 条,其中 `预设 - 音频生视频` 组内子项缩进到 `x=24`;底部统计仍显示整张画布 `共 98 节点`,不改为筛选结果数量,也无空态
- 资产管理内容: 按节点和分组列出当前画布,如 `导演台 6`、`脚本生成器`、`剧本`、`分镜图 · 我在盛唐写天下`、`视频组 · 我在盛唐写天下`、`我在盛唐写天下资产组` 等;普通行外壳 `x=8 y=202 w=224 h=40`,主定位按钮 `x=8 y=206 w=216 h=32`,更多按钮 `x=174 y=210`,定位图标 `x=200 y=210`;底部统计区 `x=0 y=753 w=240 h=53`,本轮竞品账号为 `共 76 节点`
- 资产管理定位行为复测: 在资产 Drawer 打开且当前 viewport 为 `matrix(0.1,0,0,0.1,269.1,107.6)` 时,点击列表行 `导演台 12 机位2-shot-01` 后不增删节点/边,目标节点立即选中,viewport 变为 `matrix(1,0,0,1,-6201,199.5)`;选中节点屏幕框为 `x=701 y=91.5 w=350 h=622`,节点中心 `x=876 y=402.5`,即对齐到左侧 240px Drawer 右侧主画布区域中心;本轮顶部黄色 `正在跟随` 条在点击前已由此前教程残留存在,点击 `取消ESC` 仍未清除,因此不作为本轮定位新增证据
- 资产管理更多操作: 单项 `更多操作` 弹出菜单为 `重命名`、`复制`,菜单约 `x=174 y=240 w=180 h=81`、背景 `rgb(23,23,23)`、圆角 `12px`、z-index `410`,两项分别约 `x=178 y=249` 和 `x=178 y=285`
- 资产管理复制行为复测: 本轮在竞品资产管理第一行 `导演台 12` 点击 `更多操作 -> 复制`,菜单项 `复制` 坐标约 `x=178 y=284.5 w=171 h=32`;复制后 `.react-flow__node` 从 `94` 增至 `95`,`.react-flow__edge` 保持 `106`,新节点立即选中,标题为 `导演台 12 - 副本`,在列表中插入到原节点下一行 `y=248`,画布世界坐标为原节点同 `x=6192`、`y=-108 -> 302`(即 `height 350 + 60` 的纵向偏移),没有成功 toast
- 资产管理资产 tab: 点击 `资产` 后 active 背景移到资产 tab;内容区变为素材搜索/筛选而非画布大纲,搜索输入 placeholder `请输入搜索内容`,约 `x=8 y=154 w=192 h=30`,搜索按钮 `x=172 y=158 w=22 h=22`,`筛选素材类型` 按钮 `x=204 y=155 w=28 h=28`;当前显示 `暂无资产` 空态,空态容器约 `x=8 y=196 w=224 h=188`
- 资产管理资产 tab 标签筛选: 点击 `筛选素材类型` 后弹出标签筛选浮层,约 `x=204 y=193 w=322 h=189`、背景 `rgb(33,33,33)`、圆角 `12px`、z-index `410`;标题 `标签`,可选标签 `其它/人物/场景/物品/风格/音效`,底部按钮 `清空/应用`
- 视图跟随态: 通过资产管理/节点定位后顶部显示 `正在跟随`,按钮文案 `取消ESC`,下方提示 `按 ESC 退出`;复查 DOM 后未发现该状态生成临时连接路径,应与节点定位跟随相关,不能等同于连线模式
- 端点拖拽建连: 竞品节点使用 React Flow 透明 handle,DOM 为 `.react-flow__handle`、`data-handleid="target/source"`、`data-handlepos="left/right"`、`connectablestart/connectableend`;实测从 `分镜视频-#3` source handle 拖到 `分镜视频-#9` target handle 后,`.react-flow__edge` 数量从 73 增至 74,新增边 `e-SYnsgmeqw7`,没有弹出菜单或成功 toast
- 连线选择/删除: 竞品点击新增边 `e-SYnsgmeqw7` 后,该边 DOM class 变为 `react-flow__edge react-flow__edge-default nopan selected selectable`,selected nodes 为空;按 `Delete` 后该边消失,`.react-flow__edge` 数量从 74 回到 73,没有弹窗或 toast
- 选中线中点剪刀删除: 另测临时边 `e-al9CYM8T8M`,创建后 `.react-flow__edge` 数量从 73 增至 74;点击选中后边 class 为 `react-flow__edge react-flow__edge-default nopan selected selectable`,中点出现 `.scissors-enter` 深色圆形剪刀按钮;点击剪刀后边数从 74 回到 73,未出现弹窗或 toast
- 连线重连/目标高亮复查: 选中线后 DOM 未发现 `.edgeupdater` 或可拖拽重连手柄;静态 hover 目标 handle 仍只是普通 `.react-flow__handle ... connectionindicator`,未发现稳定额外高亮 class
- 端点拖拽目标瞬时高亮: 竞品从 source handle `x=1017.6 y=536.9` 拖到目标节点/handle `x=759 y=536.9` 时,目标节点内容上方瞬时出现无类名黑色透明遮罩,采样为 `rgba(0,0,0,0.35)`,矩形约 `x=759 y=407.6 w=258.6 h=258.6`;目标节点本身未新增 `valid/connecting` class、outline 或 shadow
- 左下工具条闭合态: 竞品 `1512 × 805` 视口下左下 dock 坐标为 `资产管理 x=22 y=759 w=94 h=28`、`整理画布,Option+Shift+F x=120`、`切换小地图 x=152`、`隐藏节点连线 x=184`、`网格吸附 x=216`、`缩放选项 x=248 w=42`,缩放按钮文字为 `100%`;默认不显示 `- / +` 独立缩放按钮
- 左下工具条资产面板打开态: 资产管理 Drawer 打开后,左下 dock 去掉 `资产管理` 文字入口并整体右移 `240px`,首个按钮 `整理画布` 位于 `x=262`,随后 `切换小地图/隐藏节点连线/网格吸附/缩放选项` 分别位于 `x=294/326/358/390`
- 小地图打开态复测: 点击 `切换小地图` 后按钮背景变为 `rgba(255,255,255,0.15)`;弹出的 `.react-flow__panel.react-flow__minimap` 为原生 SVG 小地图,资产面板打开态坐标 `x=295 y=642 w=150 h=110`,computed 样式为背景透明、无边框、圆角 `12px`、无阴影、`overflow: visible`、`cursor: auto`;内部 `.react-flow__minimap-svg` 同为 `150 × 110`,`viewBox` 使用画布世界坐标,节点为 `.react-flow__minimap-node`,遮罩为 `.react-flow__minimap-mask`,fill `rgba(255,255,255,0.04)`
- 小地图交互复测: 在小地图内部点击会立即改变 `.react-flow__viewport` transform,本轮从 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)` 跳到 `matrix(0.743079,0,0,0.743079,-2122.7,-2015.25)`;在小地图内拖拽继续平移到 `matrix(0.743079,0,0,0.743079,-6570.03,-4683.64)`,小地图保持打开
- 小地图关闭态: 再次点击 `切换小地图` 后 `.react-flow__minimap` 从 DOM 消失,按钮恢复普通 hover 类,实测背景读数为 `rgba(255,255,255,0.1)`(鼠标仍悬停时)
- 隐藏节点连线复测: 资产面板打开态按钮 `隐藏节点连线 x=326 y=759 w=28 h=28`;点击后按钮 aria 变为 `显示节点连线`,已有 `.react-flow__edge` 不从 DOM 移除,仍保留 1 个 edge group 和 5 条 path,主线 `stroke rgb(192,200,208) / 2px`、流动高亮 path 仍可见;变化发生在节点连接端点,`.react-flow__handle` 仍保留但多数 connectable handle 从 `opacity=1 / pointer-events=auto` 变为 `opacity=0 / pointer-events=none`;再次点击恢复为 `隐藏节点连线`,handle 恢复可交互,viewport 不变、无 toast
## 本地已验证
- 本地测试账号可登录,余额显示 `200,000`
- 本地视频画布可创建并加载 UUID 画布
- 本地服务器配置已生效,`/api/models` 和 `/api/video-canvas/config` 可正常返回
- 本地添加节点菜单可打开
- 本地添加节点菜单已补齐竞品结构: `脚本 NEW`、`脚本(旧版) Beta`、`素材库 NEW`、`风格库/特效库`、`从生成历史选择`
- 本地添加节点菜单层级已修正: 画布内容层固定为低层级,添加面板可在其上命中;添加面板允许右侧子菜单溢出显示,避免 `脚本/素材库` 子菜单被滚动容器裁掉
- 本地添加资源入口已修正: `素材库` 继续打开风格/特效库;`从生成历史选择` 改为打开 `历史资产` 面板
- 本地主工具栏布局已对齐: 原先主入口为左侧竖排工具栏;现改为竞品式底部居中横排 32px 图标按钮,并使用 fixed 定位避免页面滚动状态改变坐标。Chrome 复测 `1512 × 805` 视口下坐标为 `添加节点 x=616 y=753`、`打开工具箱 x=656`、`素材库 x=696`、`角色库 x=736`、`历史记录 x=776`、`快捷键 x=825`、`教程 x=865`,与竞品一致;底部仍保留 `资产管理`/缩放/吸附控制区
- 本地素材库入口已验证: `风格库 NEW 新增风格节点`、`特效库 NEW 新增特效节点`
- 本地特效库/风格库广场已验证: tabs、搜索 placeholder、分类、商用标签、作者和使用数均按竞品结构呈现
- 本地角色库已验证: 展示 `角色库`、角色详情、角色标签、`应用至画布`、`角色筛选`、`最近使用`、横向角色卡片;点击 `应用至画布` 会新增 `甜妹/清新少女` 角色资源节点并选中
- 本地工具箱已验证: 展示 `我的工具箱`、分类 `周星驰经典名场面 / 电商展示 / 镜头运动 / 人物动画 / 全部`、横向模板卡、卡片 `使用` 按钮、模板详情和 `模板工作流` 预览;点击 `【预设】左弧滑行` 的 `使用` 后关闭面板并新增 `参考图 -> 【预设】左弧滑行` 工具箱工作流
- 本地快捷键面板布局已对齐: 原先为左侧 `300px` 小浮层;现改为竞品式居中四列大浮层,外壳 `x=180 y=300 w=1152 h=436`、z-index `300`、圆角 `16px`;无可见标题,右上关闭按钮 `x=1292 y=313`;四列标题 `创作/缩放/移动画布/其他` 位于 `x=205/493/781/1068 y=325`,主要行距和分组内容与竞品一致;`Escape` 和点外面不关闭,只能点击关闭按钮
- 本地教程菜单布局已对齐: 原先为带标题和关闭按钮的 `220px` 小面板;现改为竞品式图标上方小菜单,外壳 `x=865 y=582 w=104 h=154`、背景 `rgb(38,38,38)`、圆角 `12px`、z-index `300`;四项按钮 `使用教程/联系客服/联系销售/关注公众号` 均为 `95 × 36`,坐标 y 为 `587/623/659/695`;`使用教程` 继续打开指南页,其余保留待接入提示
- 本地故事脚本工作流已验证: 点击 `故事脚本生成` 创建 `剧本` 和 `脚本生成器` 两个节点;选中脚本生成器后顶部浮动工具已改为竞品同款 `重新生成 / 批量生成分镜 / 批量生视频`
- 本地脚本顶部工具栏已对齐: 竞品复测 `1512 × 805` 视口下脚本选中浮层为 `x=411 y=60 w=393 h=41`,按钮为 `重新生成 x=415 w=96`、`批量生成分镜 x=515 w=122`、`批量生视频 x=641 w=109`;本地复测为浮层 `w=393 h=41`,按钮 `w=96/122/109 h=32`,文本不再带多余首字图标
- 本地故事脚本表格已对齐: `生成完整脚本` 后脚本生成器节点显示竞品式 9 列表格,列名为 `镜号 / 时长 / 画面描述 / 景别 / 光影氛围 / 对白·旁白 / 音效 / 运镜 / 最终提示词`;Chrome DOM 复测确认列名全部存在,镜头编号 `01` 到 `18` 全部生成,节点状态为 `故事脚本已生成`
- 本地脚本全屏三步工作流已对齐: 脚本生成器节点生成完成后出现 `打开脚本节点 →`,点击后打开 fixed 全屏深色工作流;Chrome 复测顶栏为 `脚本生成器 / 确认镜头 18个镜头已就绪 / 准备资产 7/7 已生成 / 合成提示词 18/18 已合成 / 3/3 完成后可批量生视频`,外壳为 `1512 × 805`,表格区域 `x=25 y=180 w=1464`,行高固定 `60px`
- 本地脚本准备资产页已对齐: 点击 `准备资产` 后显示 `全局风格 盛唐·国风二次元`,并按 `角色 / 场景 / 道具` 分组展示 7 个资产 `现代沈昭昭 / 沈昭昭 / 皇帝 / 金銮殿 / 长安城白昼 / 长安城夜景 / 侍卫长刀`;资产图实测 `w≈275 h=156`,每组保留虚线 `新增` 卡和 hover 菜单结构
- 本地脚本提示词弹窗已对齐: 在 `合成提示词` 页点击首行 `查看提示词` 后打开居中弹窗,Chrome 复测 modal `x=376 y=109 w=760 h=587`,含 `第 01 镜:最终提示词`、`分镜提示词 / 分镜图提示词·已生成`、`视频运动提示词 / 视频运动提示词·已生成`、`GVLM 3.1 Flash / 消耗 2 / 重新合成提示词`
- 本地脚本批量分镜已对齐: 在干净画布按 `故事脚本生成 -> 生成完整脚本 -> 批量生成分镜` 顺序复测,toast 为 `已批量生成 18 个分镜图片节点`,画布生成 `分镜 01` 到 `分镜 18` 共 18 个图片生成器节点
- 本地脚本批量生视频已对齐前端编排: 先批量生成分镜后重新选中脚本节点点击 `批量生视频`,toast 为 `已创建 18 个视频生成器组`,生成 `分镜视频-#1` 到 `分镜视频-#18` 共 18 个视频任务节点;所有节点显示 `720P · 5s`,无 `720P · 6s` 漏出,任务参数写入 `Seedance 2.0 VIP / 16:9 / 720p / 5s`、分镜图引用、图像提示词、视频运动提示词和原始镜头时长 `sourceShotDuration`
- 本地连线快捷键已验证: 按 `L` 进入连线跟随态,顶部浮层对齐竞品 `正在跟随 / 取消ESC / 按 ESC 退出`;选择起点和目标节点后会新增连线并退出跟随态
- 本地连线视觉预览已验证: 选择 `剧本` 为起点后,画布 SVG 出现 `#a7afff` 虚线预览路径和末端光圈;移动鼠标时路径终点变化;按 `ESC` 后预览路径和光圈移除
- 本地透明端点单击已验证: 节点左右 handle 已改为透明 hot zone,带 `data-handleid="target/source"`、`data-handlepos="left/right"`、`data-nodeid`;单击 `脚本生成器` 右侧 source handle 后只显示顶部 `正在跟随 / 取消ESC / 按 ESC 退出`,没有连接菜单,handle 文本为空
- 本地透明端点拖拽建连已验证: 从 `脚本生成器` source handle 拖到 `图片节点 3` 后静默新增连线,`.edge-flow-segments path` 从 3 增至 6,未出现 `已连接` toast、未出现连接菜单,预览线和光圈释放后清理为 0
- 本地端点拖拽目标瞬时高亮已对齐: 新增 `lineConnectState.hoverNodeId` 并在 `CanvasNodeShell` 内渲染 `data-connection-target-overlay` 黑色透明遮罩;Chrome 复测从首帧节点 source `x=890.1 y=421.1` 拖到视频节点 target `x=974.8 y=421.1` 时,目标节点出现 `bg-black/35`、`pointer-events-none` 遮罩,矩形 `x=974.8 y=255.5 w=431.2 h=331.1`,顶部跟随提示和紫色预览线仍存在;松手后遮罩和预览线均清理为 0
- 本地连线选择/删除已验证: 每条线现在带 `.react-flow__edge` 与 `data-id`,透明粗 hit path 可点击;点击 `脚本生成器 -> 图片节点 3` 的测试线后 class 增加 `selected`,`selectedNodes` 为空;按 `Delete` 后 edge 数量从 2 回到 1,节点数量保持 3,没有弹窗或 toast
- 本地选中线中点剪刀删除已验证: 点击测试线后 `.react-flow__edge` class 增加 `selected`,中点显示 `.scissors-enter` 深色圆形剪刀按钮;按钮位于节点层之上且可命中,点击后 edge 数量从 2 回到 1,节点数量保持 3,剪刀按钮清理为 0
- 本地视频结果卡 hover 控件已验证: 通过 `脚本 -> 视频参考生成分镜脚本` 创建视频参考资源节点,卡片内出现竞品式播放圆钮、`0:00 / 0:05` 时间、进度条、右下 `截取视频帧` 圆钮和 `截取首帧/截取尾帧/截取当前帧` 菜单
- 本地视频帧截取已验证: 打开右下帧菜单后点击 `截取首帧`,节点数从 2 增至 3、edge 数从 1 增至 2,新增 `视频-首帧` 图片节点,显示 `1280 × 720`,并保留与原视频节点的连线
- 本地视频结果工具条二级功能已验证: 选中带真实 mp4 地址的视频资源节点后,顶部工具条按竞品顺序显示 `剪辑`、`裁剪`、`高清`、`解析`、`智能去字幕`、`音频分离`;`高清` 按钮已修正为只显示 `高清`,不再露出额外 `HD` 文本
- 本地视频裁剪态已验证: 点击 `裁剪` 后,视频卡上出现 80% 裁剪框和 `1024 x 576` 标签,视频卡下方出现 `退出裁剪`、`1024 x 576`、`生成裁剪` 的内联操作条,形态对齐竞品
- 本地视频裁剪生成已验证: 点击 `生成裁剪` 后出现 `正在裁剪视频,请稍候…`,按钮进入 `生成中 68%` 禁用态;随后新增下游 `视频_裁剪` 视频节点,标题旁显示 `1024 × 576`,保留从原视频到裁剪结果的连线,并继续显示视频结果卡 hover/工具条能力
- 本地视频高清面板已验证: 点击 `高清` 后出现 `Topazlabs`、`1080P`、`2K`、`4K`、`不补帧`、`1x`
- 本地视频高清落节点已验证: 点击视频工具条 `高清 -> 1080P` 后新增下游 `高清(1080P)` 节点,文本为 `配置参数 / 生成高清视频`,并保留从源视频到高清节点的连线;点击 `生成高清视频` 后节点进入 `生成中`,约 2s 后变为 `生成结果 1920 × 1080`,节点内显示视频预览和 `1080P`
- 本地智能去字幕已验证: 点击 `智能去字幕` 后下拉项为 `智能擦除`;点击后下方出现 `关闭去字幕生成器`、标签 `智能擦除`、消耗 `0`、右侧 `生成擦除` 按钮
- 本地智能擦除生成已验证: 点击 `生成擦除` 后新增下游 `视频一键去字幕-视频` 任务节点,显示 `生成中 4%... / 取消`,并保留从源视频到任务节点的连线;本地模拟完成延迟已从约 3.6s 拉长到约 42s,以贴近竞品长 pending 节奏;完成后转为普通视频结果卡,标题旁显示 `1280 × 720`,保留 hover 播放、帧截取和二级工具条
- 本地智能擦除取消已对齐: 修复 `VIDEO_SUBTITLE_ERASE` pending 节点取消按钮的 pointer 事件,点击 `取消` 后弹出确认框 `取消生成 / 正在生成中,即将完成!/ 停止将不退还已消耗算力。/ 取消 / 确定`;点击 `确定` 后弹窗关闭,任务节点继续保持 `生成中 ... / 取消`,不 toast、不立即删除、不转失败态,和本轮竞品实测一致
- 本地生成失败态已验证: 在本地 `localhost:3003` 选中首帧图生视频的右侧视频目标节点后点击生成,因当前本地浏览器会话未登录,接口返回 `未登录` 并进入 failed;视频节点保留首帧模糊背景,中间黑色胶囊显示 `未登录 / 移除`,同时出现 toast `未登录`;点击 `移除` 后失败视频节点和连线立即清理,只保留首帧节点。未接入首帧时则只 toast `首帧图生视频需要先接入首帧图片`,不会创建 failed 节点
- 本地框选擦除已实现: `智能去字幕` 下拉补齐说明 `AI一键去除视频字幕,仅支持中英文字幕` 和两项 `智能擦除/框选擦除`;`框选擦除` 进入视频卡区域编辑态,支持在视频画面拖拽多个青色半透明矩形,底部操作条含关闭、帮助、框选、撤销、重做、重置、费用和生成按钮
- 本地框选擦除参数已实现: 未框选时费用 `-`、生成按钮禁用;有区域后费用 `0`、生成按钮启用;撤销/重做/重置按竞品历史栈语义工作;点击生成会创建下游 `VIDEO_SUBTITLE_ERASE` 任务节点,draft 写入 `model: volcano-subtitle-eraser`、智能模式 `mode: Subtitle`、框选模式 `mode: Text`,并同时写入 `erase_ratio_location` / `eraseRatioLocation` 归一化区域数组
- 本地音频分离已验证: 点击 `音频分离` 后下拉项为 `人声分离`、`音视频分离`,`人声分离` hover 右侧子菜单为 `仅保留人声`、`仅保留背景音`,`音视频分离` 带说明 `分离内嵌音轨为独立音频节点`
- 本地人声分离已对齐: 点击 `人声分离 -> 仅保留人声` 后立即显示 toast `人声分离处理中,请稍候…`,源视频工具条按钮变为 `分离中`,期间不新增节点;约 6.4s 后新增 `分镜视频-#1_人声` 音频节点和 `分镜视频-#1_无声` 视频节点,`.react-flow__edge` 数为 2,两个节点均由源视频连出
- 本地背景音分离已对齐: 点击 `人声分离 -> 仅保留背景音` 后按钮同样变为 `分离中`,toast 使用竞品父级文案 `人声分离处理中,请稍候…`;约 6.4s 后生成 `分镜视频-#1_无声` 350x350 视频节点和 `分镜视频-#1_背景音` 350x148 音频节点,两个结果同坐标落在源视频右侧,正文/DOM 顺序为 `_无声` 在前、`_背景音` 在后,`.react-flow__edge` 数为 2
- 本地视频解析已验证: 选中带真实 mp4 地址的视频节点后,点击 `解析`,按钮变为 `解析中`,没有出现旧的浮动解析面板;后端返回后新增 `视频故事` 节点并选中,节点含竞品同款 16 列表头,`镜号/开始时间/结束时间/时长/画面描述/叙事内容/景别/摄影机角度/摄影机运动/焦距与景深/光线/背景音乐/人声/音效/图像生成提示词/视频运动提示词/关键帧` 均可见
- 本地视频故事落节点已验证: 通过 `定位选中` 可将生成的 `video-story` 节点居中,表格节点使用暗色卡片、横向滚动长提示词;本次本地 demo 视频返回 3 行镜头,首行时间为 `0 / 5 / 5`,并保留从原视频节点到故事节点的画布连线
- 本地视频解析后端 schema 复测: 更新 `/api/canvas/workflow/analyze` 后,新建第二个视频参考节点再点 `解析`,按钮同样变 `解析中`;返回新 `视频故事` 节点 1 行,字段包含非默认摄影机/光线内容,如 `全景`、`静态固定镜头 (Static Fixed Shot)`、`无限景深`、`极低照度`,确认后端已按竞品 16 列 JSON 契约输出
- 本地视频首屏项目加载已修复: 本地 `AUTH_URL/NEXTAUTH_URL` 调整为 `http://127.0.0.1:3003` 后,使用 `codex-video-test@example.com` 登录可直接打开测试视频画布;视频画布组件现在优先水合服务端 `initialProject`,即使 Chrome 当前拦截直开 `/api/*`,也不会再落到空画布
- 本地框选擦除浏览器复测已完成: 在 Chrome 中选中 `分镜视频-#1` 后,顶部工具条显示 `剪辑/裁剪/高清/解析/智能去字幕/音频分离`;`智能去字幕` 下拉显示说明 `AI一键去除视频字幕,仅支持中英文字幕` 和 `智能擦除/框选擦除`
- 本地框选擦除初始态已验证: 点击 `框选擦除` 后,视频卡下方出现操作条,含 `关闭去字幕生成器`、`框选擦除`、帮助问号、`框选区域`、`撤销`、`重做`、`重置`、费用和 `生成擦除`;未框选时费用为 `-`,撤销/重做/重置/生成禁用
- 本地框选擦除帮助已验证: hover 问号显示 `使用方法 / 在画面上拖拽鼠标,框选要擦除的区域 / 支持框选多个区域,所有框内文字将被擦除 / 擦除作用于整段视频,不仅是当前帧`,与竞品一致
- 本地框选擦除选区与历史已验证: 拖拽可创建青色半透明矩形,实测样式为半透明青色填充、`rgb(6, 144, 174)` 边框、`rgb(9, 202, 245)` 四角点;支持多个区域;有区域后费用为 `0` 且生成启用;撤销移除最新区域并启用重做,重做恢复区域后禁用,重置清空区域、费用回 `-`、生成禁用且撤销仍可用
- 本地框选擦除生成已验证: 重置后重新框选并点击 `生成擦除`,创建下游 `视频一键去字幕-分镜视频-#1` 节点,先显示 `生成中 6%... / 取消`,并保留从源视频到任务节点的连线;约 5s 后切为普通视频结果卡,标题旁显示 `1280 × 720`,保留播放、帧截取和二级工具条能力
- 本地添加节点二级菜单复测修正: `脚本/素材库` hover 子菜单和主菜单之间的间隙已收窄,避免鼠标从主菜单移到子项时丢失 hover 导致子项无法点击
- 竞品添加节点菜单最终复测: 底部 `添加节点` 按钮为 `x=616 y=753 w=32 h=32`;主菜单为 `x=533 y=291 w=196 h=445`,背景 `rgb(38,38,38)`,边框 `0.5px solid rgb(54,54,54)`,圆角 `16px`,padding `8px`,gap `4px`;主行 `x=542 w=179 h=32`,y 依次为 `添加节点 300`、`文本 336`、`图片 372`、`视频 408`、`视频合成 444`、`导演台 480`、`音频 516`、`脚本 552`、`素材库 588`、`添加资源 624`、`上传 660`、`从生成历史选择 696`;`Beta x=630 y=452 w=34 h=16`,`NEW x=617 y=488/596 w=36 h=16`
- 本地添加节点菜单最终对齐: Chrome 复测本地主菜单同为 `x=533 y=291 w=196 h=445`,背景、边框、圆角、padding、gap、行坐标与竞品一致;文本起点 `x=572`,`Beta x=630 y=452 w=34 h=16`,`导演台 NEW x=617 y=488 w=36 h=16`,`素材库 NEW x=617 y=596 w=36 h=16`,主菜单 DOM 文本为 `添加节点文本图片视频视频合成Beta导演台NEW音频脚本素材库NEW添加资源上传从生成历史选择`
- 本地添加节点子菜单最终对齐: `脚本` hover 时外层 `x=721 y=552 w=204 h=85`、内层 `x=729 y=552 w=196 h=85`,文本 `脚本NEW / 脚本(旧版)Beta`;`素材库` hover 时外层 `x=721 y=588 w=204 h=85`、内层 `x=729 y=588 w=196 h=85`,文本 `风格库 / 特效库`,不再带多余 `NEW` 徽标
- 本地故事脚本生成已验证: 后端 `story-script` 模式已改为直接要求模型输出 18 镜头 JSON,schema 补齐 `duration`、`shotSize`、`lighting`、`dialogue`、`voiceAndSfx`、`cameraMovement`、`imagePrompt`、`motionPrompt` 等字段,并要求 motion prompt 采用 `起始状态 / 动作过程 / 结束状态 / 音效` 结构;本地 `CANVAS_E2E_LOCAL=true` 时对 loopback 提供确定性的 18 镜测试数据,Chrome 复测节点展示 `01` 到 `18`
- 本地生成分镜已验证: 点击 `生成分镜` 后创建 18 个 `分镜 01` 到 `分镜 18` 图片节点,当前画布共 20 节点
- 本地资产管理入口已对齐: 原先左下入口是 `26 × 26` 小图标且随页面滚动偏移;现改为 fixed 视口定位的竞品式文字按钮,Chrome 复测 `1512 × 805` 下为 `资产管理 x=22 y=759 w=94 h=26`,并保留 aria/title `资产管理`
- 本地资产管理面板布局已对齐: 原先是左下 `340px` 浮层;现改为左侧全高 Drawer,外壳 `x=0 y=0 w=240 h=805`、背景 `rgb(23,23,23)`、圆角 `0`、z-index `30`;顶部工作区输入 `x=12 y=56 w=78`,`画布 1` 按钮 `x=99 y=52 w=68`,tabs `画布/资产` 在 `x=8/60 y=102`,面板内 `资产管理` 关闭按钮 `x=199 y=101 w=32`
- 本地资产管理画布 tab 已对齐: 控制行 `画布元素/收起全部分组/全部/搜索节点` 坐标分别为 `x=16/70/147/203 y=154`,行列表起点 `x=8 y=202`,主定位按钮 `x=8 y=206 w=216 h=32`,更多按钮 `x=174 y=210`,定位图标 `x=200 y=210`,底部统计区 `x=0 y=752 w=239 h=53`
- 本地资产管理定位最终对齐: 原先点击资产行只选中节点,viewport 保持 `matrix(1,0,0,1,0,0)`,离屏副本仍在视口下方;现改为竞品式定位。Chrome 复测本地 `文本节点 1 - 副本` 行: 点击前副本世界位置 `left=756 top=813 w=350 h=350`,点击后 viewport 为 `matrix(1,0,0,1,-55,-585)`,副本屏幕框 `x=701 y=228 w=350 h=350`,中心 `x=876 y=403`,正对齐资产 Drawer 右侧主画布中心,无 toast、不增删节点/边
- 本地资产管理收起全部分组最终对齐: 原先点击 `收起全部分组` 会把所有普通节点隐藏,并显示 `分组已收起`,与竞品普通节点仍保留不一致;现改为只切换按钮状态。Chrome 复测本地两条普通文本节点时,点击后按钮 aria 变 `展开全部分组`,定位行数量保持 `4`(两条主定位 + 两个定位图标),`文本节点 1 / 文本节点 1 - 副本` 仍位于 `y=206/248`,不出现 `分组已收起`,底部仍为 `共 2 节点`
- 本地资产管理搜索已对齐: 点击 `搜索节点` 后标题行原地切换为输入框并聚焦,placeholder `搜索节点`,Chrome 复测输入框 `x=29 y=158 w=101 h=20`、关闭按钮 `x=203 y=154 w=28 h=28`;输入 `分镜` 后即时过滤并保留同款行布局
- 本地资产管理类型筛选已对齐: 菜单项为 `全部/文本/图片/视频/视频合成/导演台/音频/脚本/脚本(旧版)`,Chrome 复测菜单 `x=147 y=186 w=180 h=333`、背景 `rgb(23,23,23)`、圆角 `12px`、z-index `410`,item 坐标从 `x=152 y=195` 开始,接近竞品 `x=148 y=186 w=180 h=333`
- 本地资产管理音频类型筛选最终对齐: 通过底部 `添加节点 -> 音频` 创建 `音频节点 3` 后重新打开资产管理;选择类型菜单 `音频 x=152 y=411 w=170 h=32` 后,筛选按钮 aria/text 变为 `筛选类型:音频 / 音频`,列表只剩 `音频节点 3`,行坐标 `x=8 y=206 w=216 h=32`,定位图标 `x=200 y=210`;底部仍显示总节点数 `共 3 节点` 而不是筛选结果 `1`,无空态,行为与竞品一致
- 本地资产管理更多操作已对齐: 单项菜单显示 `重命名`、`复制`,Chrome 复测菜单 `x=174 y=240 w=180 h=82`、背景 `rgb(23,23,23)`、圆角 `12px`、z-index `410`,两项从 `x=179 y=249/285` 附近开始;复制行为已改为竞品式无 toast,标题追加 ` - 副本`,列表副本紧跟原节点下一行,本地复测 `文本节点 1 -> 文本节点 1 - 副本`、底部 `共 1 节点 -> 共 2 节点`,画布位置从 `left=756 top=403 size=350` 复制到 `left=756 top=813`,即 `height + 60` 纵向偏移
- 本地资产管理资产 tab 已实现: 点击 `资产` 后 active 背景切换到资产 tab,内容显示素材搜索输入 `请输入搜索内容`,输入框 `x=8 y=154 w=191 h=30`,搜索按钮 `x=173 y=158 w=22 h=22`,`筛选素材类型` `x=203 y=155 w=28 h=28`,空态 `暂无资产` 容器 `x=8 y=196 w=223 h=188`
- 本地资产管理资产 tab 标签筛选已对齐: 点击 `筛选素材类型` 后弹出标签筛选浮层,Chrome 复测 `x=203 y=193 w=322 h=190`、背景 `rgb(33,33,33)`、圆角 `12px`、z-index `410`;含 `标签`、`其它/人物/场景/物品/风格/音效`、`清空/应用`
- 本地历史资产已验证: 基于画布图片/视频/音频节点聚合,列表含缩略图、类型状态、`查看`、`使用`、`下载`;点击历史素材的 `使用` 后关闭面板并复制一份历史素材回画布,toast 已统一为竞品式 `已应用到画布`
- 本地历史资产时间排序已对齐: 原先 `时间降序` 是静态按钮;现改为竞品式即时两态切换,点击 `时间降序` 后变为 `时间升序`,再次点击回到 `时间降序`,素材数组按状态正序/倒序显示,不打开额外菜单
- 本地历史资产面板布局已对齐: 原先为左侧 `420px` 小列表抽屉;现改为竞品式居中大浮层,外层 z-index `601`、内容框 `h=calc(100vh-160px)`、`w=min(90vw,1600px)`、背景 `#262626`、圆角 `16px`;顶部保留标题、`100%` 缩放组和无文字关闭按钮,tabs/排序/批量操作行改为 `32px` 高透明按钮,素材列表改为 `144 × 144` 方形卡片网格,卡片 hover 层仅显示 `查看/使用/下载`;Chrome 复测本地 `视频历史(1)` 外壳为 `x=76 y=80 w=1361 h=645`,日期 `x=96 y=207`,首卡 `x=96 y=239 w=144 h=144`,三按钮分别为 `x=107/149/191 y=297`
- 本地历史资产缩放组已对齐: 原先顶部 `- / +` 为 no-op;现改为竞品式离散档位 `75% / 100% / 125% / 150%`。Chrome 复测本地从 `100%` 点左侧按钮变 `75%` 且前三卡为 `130 × 130`、x `96/238/380`;连续点右侧按钮回到 `100%` 后到 `125%`(`182 × 182`,x `96/290/484`)和 `150%`(`248 × 248`,x `96/356/616`)
- 本地历史资产默认图片历史已对齐: 原先默认 `图片历史(8)` 只有计数、内容区为空;现真实图片少于 8 条时用本地示例图片补足 8 张,真实图片仍排在前面;Chrome 复测默认图片页 `cardCount=8`,首行卡片 x 为 `96/252/408/564/720/876/1032/1188`、均为 `144 × 144`,`暂无历史记录` 不再出现,前三个按钮坐标为 `查看 x=107`、`使用 x=149`、`下载 x=191`
- 本地历史资产默认图片操作已验证: 默认补位图片点击 `查看` 后打开 z-index `700` 全屏预览层,底部显示 `1 / 8` 和 `下载`,历史面板保留在底层;关闭预览后点击第一张 `使用`,历史面板关闭并出现 toast `已应用到画布`,画布出现 `角色图` 资源及图片节点工具条
- 本地素材引用已验证: 先从 `角色库` 应用 `甜妹/清新少女` 到画布,再从 `添加节点 -> 从生成历史选择` 打开历史资产,点击该角色素材的 `使用` 后关闭面板并复制回画布,toast 使用 `已应用到画布`
- 本地历史资产批量操作已验证: 创建本地 `首帧图生视频` 工作流后,`视频历史(1)` 正常列出视频素材;点击 `批量操作` 后显示 `已选择 0 项`,`删除/下载/使用` 禁用、`取消选择` 可用,日期组和素材项出现 checkbox;勾选素材后显示 `已选择 1 项`,日期组和素材 checkbox 同步选中,`删除/下载/使用` 全部启用;点击 `取消选择` 后回到普通列表并恢复 `时间降序/批量操作`
- 本地历史资产批量布局已对齐: 大浮层网格化后复测 `图片历史(8) -> 批量操作`,顶部 `删除/下载/使用/取消选择` 坐标与竞品一致为 `x=1123/1200/1277/1352 y=161`,尺寸为 `69×28 / 69×28 / 50×28 / 68×28`;日期组 checkbox 为 `x=96 y=209 16×16`,素材 checkbox 首行 x 为 `102/258/414/570/726/882/1038/1194`、y=`245`;选中第一张后 `已选择 1 项` 出现,四个顶部按钮均启用
- 本地历史资产批量下载已对齐: 当前本地视频历史为 `视频历史(3)`;选中 1 条视频素材后 `删除/下载/使用/取消选择` 全部启用;点击 `下载` 后不再逐个触发浏览器 download 事件,而是进入竞品式批量下载 pending 态,四个按钮立即全部禁用,8s 后仍保持禁用
- 本地历史资产批量使用已对齐: `视频历史(1)` 进入批量模式后,选中 1 条素材显示 `已选择 1 项` 且顶部 `删除/下载/使用/取消选择` 启用;点击 `使用` 后历史面板关闭,新增并选中一份历史素材节点,toast 为 `已应用到画布`
- 竞品历史资产批量删除确认复测: 在 `图片历史(8)` 进入批量模式并选中第一张后,顶部显示 `已选择 1 项`,`删除/下载/使用/取消选择` 全部启用;点击 `删除 x=1122.9 y=160.5 w=69 h=28` 后不会立即删除,而是打开居中确认弹窗。弹窗外壳 `x=584 y=335.4 w=344 h=134.1`、背景 `rgb(38,38,38)`、圆角 `12px`、边框 `white/10`,标题 `批量删除 x=601 y=352.4`,正文 `确认批量删除 1 项 x=601 y=384.4`,按钮 `取消 x=799 y=420.6 w=50 h=32`、`删除 x=861 y=420.6 w=50 h=32`;为避免破坏竞品账号历史资产,本轮只点击 `取消`,取消后弹窗关闭并保留批量模式和 `已选择 1 项`
- 本地历史资产批量删除最终对齐: 原先本地点击批量 `删除` 只弹 toast `批量删除会从历史资产中移除记录,后端接口待接入。`,没有确认弹窗;现补齐竞品式确认 modal。Chrome 复测本地 `图片历史(8) -> 批量操作 -> 选中第一张 -> 删除`: 弹窗 `x=584 y=335.8 w=344 h=133.5`,标题/正文/按钮为 `批量删除 / 确认批量删除 1 项 / 取消 / 删除`,按钮坐标 `x=799/861 y=420.3 w=50 h=32`;点击 `取消` 后保留 `已选择 1 项`,再次确认删除后本地显示 toast `删除成功`,`图片历史(8)` 变 `图片历史(7)`,顶部回到 `已选择 0 项` 且 `删除/下载/使用` 禁用
- 本地历史资产列表单项下载已对齐: 原先列表内 `下载` 会触发浏览器 download 并显示 `已开始下载当前资产`;现改为竞品式水印规则流程,点击后不触发浏览器 download,历史面板保留,被点击按钮 disabled 且文案仍为 `下载`,弹出 `AI 生成内容水印管理规则` modal,内容框为 `740 × 404`,含 `去 AI 水印` 和 `保存设置`
- 本地历史资产水印规则关闭已对齐: 原先水印 modal 挂在 `HistoryAssetsPanel` 内,按 `Escape` 会随着历史面板一起卸载;现将 modal 状态提升到画布层,Chrome 复测 `视频历史(1) -> 下载 -> Escape` 后历史面板关闭但 `AI 生成内容水印管理规则` 仍保留,遮罩点击也不关闭 modal,和竞品一致
- 本地历史资产水印规则开关已对齐: 原先 `去 AI 水印` 是右侧蓝色开启态静态开关,且被底部 `保存设置` 区域覆盖,点击右侧开关会误触保存并关闭弹窗;现改为竞品式左侧灰色关闭态 switch,input `role="switch"`、`checked=false`,正文滚动区固定 `160px`,switch、提示文案和保存按钮分离;Chrome 复测点击 switch 和文字后状态仍为 false、弹窗保持打开
- 本地历史资产单项查看已对齐: Chrome 实测 `历史记录 -> 视频历史(1) -> 查看` 后,历史面板保留在底层、不选中画布节点,打开 `1512 × 805` 全视口黑色预览层;底部显示 `1 / 1` 和 `下载`,右上角关闭按钮位置贴近竞品,视频以 `object-contain` 方式居中展示并保留播放器 controls
- 本地历史资产预览关闭已对齐: 遮罩空白点击、右上角 `关闭预览`、`Escape` 均只关闭预览层并保留历史面板;本轮修复了 `Escape` 同时触发主画布全局关闭导致历史面板一起关闭的问题
- 本地历史资产预览切换已对齐: 为测试切换先用本地 `使用` 复制出第二条视频历史素材;`视频历史(2)` 打开预览后显示 `1 / 2`,右箭头贴视口 `x=1456`,点击后变为 `2 / 2` 且左箭头贴视口 `x=16`;`ArrowLeft` 可回到 `1 / 2`,历史面板保留、画布节点不选中
- 本地历史资产预览下载已对齐: 原先预览层底部 `下载` 会立即触发浏览器 download 事件;现改为竞品式 pending,点击后不触发浏览器 download,预览层和历史面板保留,按钮禁用并显示 `下载中...`,pending 宽度固定为 118px
- 本地上传复测状态: `上传` 按钮已触发 Chrome 文件选择器,但当前 Chrome 扩展未允许文件 URL 访问,`setFiles` 返回 `Not allowed`;需开启扩展文件访问后复测实际文件选择
- 本地上传入口复测补充: 本地 `添加节点 -> 上传` 也可打开 file chooser,chooser 支持 multiple;用同一测试图片执行 `setFiles` 同样返回 `Not allowed / fileChooser.setFiles failed`,节点数保持不变且无测试文件名,因此当前真实上传验证被 Chrome 扩展文件访问权限统一阻塞,不属于本地 UI 路径缺失
- 本地左下工具条已对齐: 原先左下 dock 为 `资产管理 / 画布总览 / 定位选中 / 吸附 / - / 100% / +`;现改为竞品式 `资产管理 / 整理画布,Option+Shift+F / 切换小地图 / 隐藏节点连线 / 网格吸附 / 缩放选项`,Chrome 复测闭合态坐标为 `x=22/120/152/184/216/248`,按钮尺寸 `资产管理 94×28`、普通按钮 `28×28`、缩放 `42×28`
- 本地资产面板打开态 dock 已对齐: `assetManagerOpen` 时左下 dock 隐藏 `资产管理` 文字入口并 `translateX(240px)`,使按钮坐标对齐竞品打开态 `整理画布 x=262`、`切换小地图 x=294`、`隐藏节点连线 x=326`、`网格吸附 x=358`、`缩放选项 x=390`
- 本地资产管理宽度拖拽已对齐: 原先只有 12px 透明热区但无 `role=separator`、无 `col-resize`、拖拽无响应;现补齐 `workspace-panel-drawer-resize-handle`、`role=separator` 和 pointer 拖拽,默认宽 240px,宽度按指针 x 坐标更新并钳制在 `240..520`。Chrome 复测 `x=234 -> 360` 后侧栏 `w=360`、separator `x=347 w=12`、关闭/筛选/搜索为 `x=319/267/323`,dock 为 `x=382/414/446/478/510`,与竞品 360px 态一致;再从 `x=353 -> 190` 回拖会钳制回 `w=240`,dock 回到 `x=262/294/326/358/390`
- 本地小地图最终对齐: 原先是 `232px` 宽、带 `画布总览 / N 个节点` 标题的深色自绘弹窗;现改为竞品式透明 SVG 小地图,外壳 `.react-flow__panel.react-flow__minimap bottom right`,`150 × 110`、圆角 `12px`、背景透明、无边框、无阴影、`overflow: visible`、`cursor: auto`,无可见标题;资产面板打开态复测按钮 `x=294 y=759 w=28 h=28`,小地图 `x=294 y=641 w=150 h=110`,与竞品 `x=295 y=642` 仅 1px 取整差异
- 本地小地图交互已对齐: 小地图内部改为 `.react-flow__minimap-svg`、`.react-flow__minimap-node` 和 `.react-flow__minimap-mask`,mask fill `rgba(255,255,255,0.04)`;点击后按钮进入 `bg-white/15` 选中态,再次点击关闭并移除 `.react-flow__minimap`;本地复测点击 `x=370 y=700` 后世界 transform 从 `translate(144px,100px) scale(0.77)` 变为 `translate(-17px,51px) scale(0.77)`,拖拽 `345,685 -> 420,730` 后变为 `translate(-399px,-178px) scale(0.77)`,小地图保持打开
- 本地隐藏节点连线最终对齐: 原先 `edgesHidden` 会把已有边和剪刀按钮整体过滤掉,与竞品“只隐藏连接端点、不隐藏已有边线”不一致;现改为已有边始终渲染,隐藏态只让 `[data-handleid][data-handlepos]` 端点 `opacity=0 / pointer-events=none / tabIndex=-1`。Chrome 复测本地闭合态按钮 `x=184 y=759 w=28 h=28`,先通过文本 source `1106,578` 拖到音频 target `1208,575` 创建 1 条 edge;点击隐藏后按钮 aria/title 变 `显示节点连线`,edge 仍为 1 组 5 path 且主线/流动高亮可见,4 个 handle 全部不可见不可命中;再次点击恢复为 `隐藏节点连线`,edge 保留,handle 恢复 `opacity=1 / pointer-events=auto`
- 本地项目加载遮罩已改为非阻塞: 本地未登录或接口失败时不再出现 `z=120` 全屏遮罩阻挡工具条;改为顶部 `pointer-events-none` 小提示,便于继续测试画布功能。复测时 Chrome 当前会因未授权显示该提示,但左下工具条命中层已恢复为按钮本身
- 竞品缩放选项已实测: 资产面板打开态左下缩放按钮为 `x=390 y=759 w=42 h=28`、文本 `100%`;点击后菜单为 `x=318 y=474 w=187 h=277`,背景 `rgb(38,38,38)`、边框 `0.5px solid rgb(54,54,54)`、圆角 `12px`、padding `6px`、z-index `300`;顶部输入框 aria `缩放比例`,菜单项依次为 `放大⌘+ / 缩小⌘- / 适合屏幕⌘0 / 缩放至50% / 缩放至100% / 缩放至800%`
- 竞品缩放行为已实测: 点击 `缩放至50%/100%/800%` 后菜单保持打开,按钮和输入分别变为 `50/100/800`,画布 transform scale 分别为 `0.5/1/8`;`放大` 从 100% 到 120%,`缩小` 从 120% 回 100%;输入 `135` 未回车时只改输入框,按钮和画布保持 100%,回车后按钮变 `135%`、scale `1.35`
- 本地缩放选项已对齐: 闭合态菜单为 `x=176 y=473 w=187 h=278`;资产面板打开态复测按钮坐标 `整理画布/小地图/隐藏连线/吸附/缩放` 为 `x=262/294/326/358/390`,菜单为 `x=318 y=473 w=187 h=278`,菜单项尺寸 `174 × 36`、圆角 `8px`,颜色、边框、padding、z-index 与竞品一致
- 本地缩放交互已验证: 菜单点击后保持打开;`缩放至50%` 后按钮 `50%`、输入 `50`、transform `matrix(0.5,...)`;`缩放至800%` 后按钮宽 `44px`、输入 `800`、transform `matrix(8,...)`;`放大/缩小` 使用 20% 步进;自定义输入 `135` 未回车不改变按钮和 transform,回车后按钮 `135%`、transform `matrix(1.35,...)`;点画布外部关闭菜单
- 竞品整理画布已实测: 资产面板打开态按钮 `整理画布,Option+Shift+F` 位于 `x=262 y=759 w=28 h=28`;点击后 viewport 从 `matrix(1,0,0,1,-2236.5,-2546.5)` 变为 `matrix(0.1,0,0,0.1,64.8,192)`,缩放按钮变 `10%`,可见节点从 DOM 0 个变为 33 个,说明该功能会把全画布整理/适配到视口,最小缩放为 10%;再按 `Option+Shift+F` 结果保持一致
- 竞品整理确认条已实测: 点击整理后出现 `是否保留此次整理结果? / 还原 / 保留`;闭合态截图下按钮坐标 `还原 x=98 y=706 w=50 h=32`、`保留 x=156 y=706 w=50 h=32`,资产面板打开态按钮坐标 `x=240/298 y=706`;`还原` 移除确认条并恢复节点布局,但 viewport 保持整理后的 10% 视图
- 本地整理画布已对齐: 原先 `整理画布` 只是定位/fit 选中节点;现改为保存原节点和原 viewport、生成整理预览、fit 全画布并弹出确认条。Chrome 复测 starter 工作流 2 节点: 点击后出现 `是否保留此次整理结果?`,确认条闭合态 `x=50 y=662 w=168 h=88`,按钮 `还原/保留` 为 `x=98/156 y=706 w=50 h=32`;资产面板打开态确认条 `x=192 y=662 w=168 h=88`,按钮 `x=240/298 y=706`;点击 `还原` 后回到原 `matrix(0.77,0,0,0.77,144,100)` 和 `77%`
- 竞品网格吸附已实测: 按钮位于 `x=358 y=759 w=28 h=28`;点击前背景透明,点击后因 hover 读到 `rgba(255,255,255,0.1)`,aria/class 未变化、无确认弹窗、viewport 不变。当前仅能确认它是无文字即时开关/按钮,不暴露可读的 pressed 状态;本地保持同样的无文字按钮和拖拽网格吸附逻辑
- 竞品工具箱面板复测: 点击底部 `打开工具箱` 后,面板为 `x=516 y=276 w=480 h=460`,背景 `rgb(38,38,38)`,边框 `0.5px solid rgb(54,54,54)`,圆角 `12px`;顶部按钮为 `我的工具箱 x=532.5 y=288.5 w=78 h=28`、说明问号 `x=608.5 y=294.5 w=16 h=16`、`周星驰经典名场面 x=632.5 y=288.5 w=128 h=28`、关闭按钮 `x=955.5 y=290.5 w=24 h=24`;卡片为 3 列 `141px` 方图,首行 `x=532.5/685.5/838.5 y=345.5 h=178`,二行 `y=535.5 h=161.5`,三行 `y=709 h=161.5`;hover 后 `使用` 按钮为 `50 × 24`,首行坐标 `x=578/731/884 y=404`
- 竞品工具箱使用复测: 直接点击隐藏态按钮不触发;真实用户路径为 hover 卡片后点击 `使用`。点击第一张 `【预设】左弧滑行` 后面板关闭、无 toast,视口变为 `translate(137.4px, 529px) scale(0.1)`;新增并选中一个 `分组 3 个节点` group,屏幕坐标 `x=738.5 y=385 w=149.3 h=65.8`,子节点为 `参考图 x=742.5 y=389 w=35 h=52.5`、`提示词 x=791 y=389.5 w=35 h=20`、`成品 x=848.8 y=394.3 w=35 h=52.5`,并新增三条边 `参考图 -> 提示词`、`提示词 -> 成品`、`参考图 -> 成品`
- 本地工具箱面板最终对齐: Chrome 复测本地 `打开工具箱` 面板同为 `x=516 y=276 w=480 h=460`,背景、边框、圆角、头部按钮坐标与竞品一致;卡片首行 `x=532.5/685.5/838.5 y=345.5 h=178`,二行 `y=535.5 h=161.5`,三行 `y=709 h=161.5`,hover `使用` 按钮首行 `x=578/731/884 y=404`、二行 `y=594`、三行 `y=767.5`,DOM 文本顺序为 `我的工具箱 / 周星驰经典名场面 / 使用 / 【预设】左弧滑行 / 使用 / 【预设】电商手机弹出效果 / 使用 / 【预设】咖啡杯出场 ...`
- 本地工具箱使用最终对齐: 本地 hover 第一张模板后点击 `使用`,面板关闭且不 toast;画布 transform 为 `translate(137.4px, 529px) scale(0.1)`,新增 4 个 DOM 节点(group + 3 子节点)和 3 条 `.react-flow__edge`;group `分组 3 个节点` 坐标 `x=738.4 y=385 w=149.3 h=65.8`,子节点 `参考图 x=742.4 y=389 w=35 h=52.5`、`提示词 x=790.9 y=389.5 w=35 h=20`、`成品 x=848.8 y=394.3 w=35 h=52.5`,与竞品落点结构一致
- 竞品角色库面板复测: 点击底部 `角色库` 后打开居中大模态,外壳 `x=68 y=60 w=1376 h=685`、背景 `rgb(34,34,34)`、圆角 `16px`;头部 `角色库 x=85 y=76.6`,右上关闭按钮;上方预览区 `x=85 y=117 w=1342 h=375`,4 张预览图居中,坐标为 `角色立绘 x=230.5 y=173 w=191.3 h=255`、`脸部近景 x=429.7 y=173 w=191.3 h=255`、`表情参考 x=629 y=173 w=191.3 h=255`、`三视图 x=828.2 y=173 w=453.3 h=255`;下方说明和 `应用至画布` 按钮位于 `y=444`
- 竞品角色列表复测: 下方列表区 `x=69 y=508 h=236`;`角色筛选 x=85 y=525 w=89 h=28`、`最近使用 x=1339 y=525 w=88 h=28`;卡片从 `x=125 y=569 h=159` 开始横向滚动,缩略图约 `99 × 133`,默认展示 `甜妹/清新少女`、`霸总/精英大佬`、`温柔熟男/理想男友`、`清冷千金/白切黑女主`、`古风男主`、`古风女主` 等
- 竞品角色筛选复测: 点击 `角色筛选` 后弹出固定筛选菜单 `x=85 y=97 w=328 h=420`、背景 `rgb(38,38,38)`、圆角 `16px`、z-index `61`;包含 `性别/年龄段/种族/时代/文化区域/体型/发色`,每项为 `57 × 28` 胶囊按钮;点击 `时代 -> 古代` 后按钮变 `rgb(105,105,105)`,角色列表即时过滤为 `古风男主/古风女主`
- 竞品最近使用复测: `最近使用` 是 `role=checkbox`;点击后 `aria-checked=true`,列表区显示空态 `无最近使用的角色`,不关闭筛选菜单、不弹 toast;再次点击恢复筛选后的角色列表
- 竞品角色应用复测: 选中 `古风男主` 点击 `应用至画布` 后模态关闭,出现 `已应用到画布` 成功提示并进入跟随定位;视口变为 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)`;新增 4 个图片节点,无新增边,节点可见框为 `角色立绘 x=428.3 y=68 w=209.5 h=260.1`、`脸部近景 x=651.2 y=68 w=209.5 h=260.1`、`表情参考 x=874.1 y=68 w=209.5 h=260.1`、`三视图 x=428.3 y=371.2 w=652.4 h=367.8`
- 本地角色库面板最终对齐: 本地 `角色库` 模态复测为 `x=68 y=60 w=1376 h=685`,上方预览区 `x=85 y=117 w=1342 h=375`,下方列表区 `x=69 y=508 h=236`;`应用至画布 x=1302 y=444 w=109 h=32`、`角色筛选 x=85 y=526 w=87 h=28`、`最近使用 x=1339 y=526 w=88 h=28`,与竞品坐标仅有 1-2px 差异
- 本地角色筛选和最近使用最终对齐: 本地筛选菜单同为 `w=328 h=420` 固定浮层,点击 `古代` 后按钮背景变 `rgb(105,105,105)`,列表只剩 `古风男主/古风女主`;勾选 `最近使用` 后 `aria-checked=true`,显示 `无最近使用的角色`,取消后恢复古代筛选列表
- 本地角色应用最终对齐: 本地选择 `古代 -> 古风男主` 后点击 `应用至画布`,模态关闭并显示 `已应用到画布` toast;画布新增 4 个角色图片节点,父层 transform 对齐竞品落点,可见节点框为 `角色立绘 x=428.3 y=68 w=209.5 h=260.1`、`脸部近景 x=651.2 y=68 w=209.5 h=260.1`、`表情参考 x=874.2 y=68 w=209.5 h=260.1`、`三视图 x=428.3 y=371.2 w=652.4 h=367.8`;本地使用项目 demo 角色图和三视图素材,避免直接复制竞品资源
- 竞品素材库入口复测: 点击底部 `素材库 x=695.5 y=752.5 w=32 h=32` 后,在图标上方打开小弹层;外壳 `x=636 y=569.3 w=240 h=162.7`、背景 `rgb(38,38,38)`、边框 `0.5px solid rgb(54,54,54)`、圆角 `12px`、z-index `20`;标题 `素材库`,两行入口均为 `223 × 52`,`风格库 x=644.5 y=615.5`、`特效库 x=644.5 y=671.5`,正文包含 `新增风格节点/新增特效节点` 和 `NEW`
- 竞品风格库广场复测: 点击 `风格库` 后打开居中大浮层,外壳 `x=100 y=79.5 w=1312 h=646`、背景 `rgb(38,38,38)`、边框 `0.5px solid rgb(54,54,54)`、圆角 `12px`;tabs `风格广场/我的收藏/最近使用` 均为 `92 × 32`,坐标 `x=121/217/313 y=96`;关闭按钮 `x=1355.5 y=92 w=40 h=40`;搜索输入 placeholder `搜索风格名称、作者`,input `x=442 y=101.9 w=279 h=20.1`;分类行 `y=157`,含 `推荐/Midjourney/摄影写真/电商营销/动漫游戏/风格插画/平面设计/建筑及室内设计/创意玩法/文创周边/小说推文`,右侧 `仅看可商用` checkbox 默认未勾选,`全部 x=1341.5 y=157 w=54 h=28`
- 竞品风格库列表和搜索复测: 默认风格卡为 6 列,单卡 `x=116.5 y=197 w=203.2 h=318.1`,首行 `复古马卡龙/新中式/岁月港风/新国韵/清风竹林/小岛微风`;点击 `我的收藏` 后列表清空但不显示空态文案,`最近使用` 列表清空并出现 `暂无素材`;输入 `新中式` 不即时过滤,按 `Enter` 后才刷新为搜索结果,首行出现 `Qwen Image新中式宋式美学支持输入中文室内家居空间` 等后端结果;点击 `Midjourney` 仅切换按钮选中态,不清空已提交搜索词
- 竞品风格卡应用复测: 在风格搜索结果里点击第一张卡后市场关闭、无 toast、无新增边,新增 `react-flow__node-material-style` 节点;当前竞品视口 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)` 下,节点 transform `matrix(1,0,0,1,6068,389)`,可见框 `x=666.1 y=776.2 w=179.8 h=179.8`,文本为 `素材-风格-新中式宋式美学支持输入中文室内家居空间`
- 竞品特效库广场复测: 点击 `特效库` 后同样打开 `x=100 y=79.5 w=1312 h=646` 大浮层;tabs `特效广场/我的收藏/最近使用` 坐标仍为 `x=121/217/313 y=96`;搜索 placeholder 为 `搜索特效名称、作者`;分类只有左侧 `推荐 x=116.5 y=157 w=50 h=28` 和右侧 `全部 x=1341.5 y=157 w=54 h=28`;特效卡为 6 列,单卡 `x=116.5 y=197 w=203.2 h=252.4`,首行 `小蜜蜂运镜/穿云而入/飞跃地平线/逆转引力/地球缩放/环球缩放`
- 竞品特效库交互复测: `我的收藏` 列表清空无空态文案,`最近使用` 显示 `暂无素材`;输入 `产品` 不即时过滤,按 `Enter` 后首卡变为 `产品扫光`,后续含 `微距推镜/赛博化妆师/水下慢镜头`;hover 第一张特效卡只出现两个 24px 图标,左上无文字图标 `x=129.5 y=210`、右上 `收藏 x=282.7 y=210`;直接点击特效卡会关闭市场,但节点数、边数不变,无 toast
- 本地素材库入口最终对齐: 本地 `素材库` 入口复测外壳为 `x=636 y=569 w=240 h=163`、背景 `rgb(38,38,38)`、边框 `0.5px solid rgb(54,54,54)`、圆角 `12px`、z-index `20`;两行入口均为 `223 × 52`,`风格库 x=644.5 y=615.5`、`特效库 x=644.5 y=671.5`,已补齐 `新增风格节点/新增特效节点` 描述和 `NEW`
- 本地风格库广场最终对齐: 本地风格市场复测为 `x=100 y=79.5 w=1312 h=646`,tab、关闭按钮、搜索输入、分类行、`仅看可商用` 默认未勾选、`全部` 右侧按钮与竞品坐标一致;风格卡首行坐标 `x=116.5/331.7/546.8/762/977.2/1192.3 y=197 w=203.2 h=318`,输入 `新中式` 后不即时过滤,按 `Enter` 后刷新为 `Qwen Image...` 搜索结果
- 本地风格卡应用最终对齐: 本地点击风格搜索结果第一张卡后市场关闭、无 toast、无新增边,新增 1 个 `.react-flow__node-material-style` 风格节点;在本地 100% 视口下节点可见框为 `x=666 y=776 w=242 h=242`,若使用竞品同款 `0.743079` 缩放则屏幕尺寸约为 `179.8 × 179.8`;节点文本收敛为 `素材-风格-...`
- 本地特效库广场最终对齐: 本地特效市场复测外壳为 `x=100 y=79.5 w=1312 h=646`;tabs、搜索输入、`推荐/全部` 坐标对齐;特效卡首行 `x=116.5/331.7/546.8/762/977.2/1192.3 y=197 w=203.2 h=251.2`,接近竞品 `252.4`;输入 `产品` 不即时过滤,`Enter` 后首卡变为 `产品扫光`
- 本地特效卡点击最终对齐: 本地在特效搜索结果第一张卡点击后市场关闭,无 toast,节点数保持不变、边数保持 `0`,已有风格节点仍为 1 个;与竞品本次实测“直接点击特效卡关闭但不落节点”一致
- 竞品快捷键 `D/d` 复测: 快捷键面板标注 `复制节点和连线 D`,但在当前画布实测并未触发复制;未选中/选中 `古风男主 角色立绘`、尝试大写 `D` 和小写 `d` 后,节点数均保持 `11`、边数保持 `10`、无 toast、选中节点不变。本地同场景按 `D/d` 也保持 no-op,不额外实现复制
- 竞品键盘删除复测: 选中 `古风男主 角色立绘` 后按 `Delete`,节点数 `11 -> 10`、边数保持 `10`、选中态清空、无 toast;再选中 `古风男主 脸部近景` 后按 `Backspace`,节点数 `10 -> 9`、边数保持 `10`、选中态清空、无 toast
- 本地键盘删除最终对齐: 原先键盘 `Backspace/Delete` 删除节点会弹 `节点已删除`;现键盘删除走静默路径,保留右键菜单删除的提示。Chrome 复测选中 `甜妹/清新少女 表情参考` 后按 `Backspace`,节点数 `2 -> 1`、选中态清空、toast 为空,行为与竞品一致
- 竞品教程菜单点击复测: 点击 `联系客服`、`联系销售`、`关注公众号` 后菜单保持打开,不新开标签、不弹 toast、不出现额外弹窗,仅对应菜单项 class 切为 `bg-canvas-controls-active`;点击 `使用教程` 后新开 `https://resonate.feishu.cn/wiki/Loxfw6XHziYRk0kKzdjcFfp9nhb` 标签,原画布菜单仍保持打开,`使用教程` 变 active,无 toast
- 本地教程菜单点击最终对齐: 原先 `联系客服/联系销售/关注公众号` 会弹 `待接入` toast 且没有 active 态;现点击四项均只在菜单内切换 active,三项联系类无 toast/无新标签/无弹窗,`使用教程` 新开同款飞书 URL 且原菜单保留 active 状态。Chrome 复测四项坐标仍为 `x=869 y=587/623/659/695 w=95 h=36`
- 竞品添加文本节点复测: 在当前画布残留 `正在跟随 / 取消ESC / 按 ESC 退出` 状态下,`Escape`、点击 `取消ESC` 和文本定位点击均未退出该跟随条,但不影响底部 `添加节点` 菜单;点击 `添加节点 -> 文本` 后,节点数 `9 -> 10`、边数保持 `10`、viewport 保持 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)`、无 toast,新增并选中 `react-flow__node-text`,标题 `文本节点 2`,文本为 `尝试: 自己编写内容 文生视频 图片反推提示词 文字生音乐`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`
- 本地添加文本节点最终对齐: 原先底部菜单创建文本会弹 `已添加 文本节点 1` toast、节点尺寸 `436 × 470` 且落到左上负坐标;现 `添加节点 -> 文本` 无 toast、无新增边、选中新节点、viewport 不变,节点为 `350 × 350`,左上角按当前 viewport 中心落点。Chrome 复测空画布 100% 下新增 `文本节点 1` 屏幕框 `x=756 y=403 w=350 h=350`,内部文案为 `尝试: 自己编写内容 文生视频 图片反推提示词 文字生音乐`
- 竞品添加图片节点复测: 在同一画布上点击 `添加节点 -> 图片` 后,节点数 `10 -> 11`、边数保持 `10`、viewport 保持 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)`、无 toast;新增并选中 `react-flow__node-image`,世界坐标同当前文本节点为 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,说明基础图片节点也是 `350 × 350`;节点文本为 `图片节点 3 / 尝试: 图生图 图片高清 参考 标记 风格 / 可直接文字生图,或上传图片输入文字指令对图片进行编辑,如:将背景改为雪夜 / Lib Image 自适应 · 标准画质 · 2K · 1张 18`
- 本地添加图片节点最终对齐: 原先底部菜单创建图片会生成 `773 × 470` 横向节点,落点带图片偏移,且文案只含 `图生图 / HD 图片高清`;现基础 `图片` 节点改为 `350 × 350`,左上角按当前 viewport 中心落点,无 toast、无新增边、选中新节点,内部文案补齐 `图生图 图片高清 参考 标记 风格`、说明文案和 `Lib Image 自适应 · 标准画质 · 2K · 1张 18` 参数行。Chrome 复测空画布 100% 下新增 `图片节点 1` 屏幕框 `x=756 y=403 w=350 h=350`
- 竞品添加视频节点复测: 先点画布空白清掉图片节点参数层,再点击 `添加节点 -> 视频`;节点数 `11 -> 12`、边数保持 `10`、viewport 保持 `translate(-3842.91px,487.096px) scale(0.743079)`、无 toast;新增并选中 `react-flow__node-video`,世界坐标仍是 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=462.2 h=260.1`,反推基础尺寸约 `622 × 350`;节点文本为 `视频节点 4 / 尝试: 首尾帧生成视频 首帧生成视频 / 参考 标记 特效 角色库 运镜 / 描述你想要生成的画面内容,@引用素材 / 2.0 文生视频 16:9 · 720P · 5s · 1个 · 高级设置 联网搜索 自动校验素材`
- 本地添加视频节点最终对齐: 原先底部菜单创建视频会生成 `773 × 470` 大节点,并按居中节点加偏移落到屏幕左侧;现基础 `视频` 节点改为 `622 × 350`,和文本/图片一样按当前 viewport 中心左上角落点,无 toast、无新增边,视频卡片高度压到 350 内,选中后下方参数浮层改为 `660 × 191` 轻量面板。Chrome 复测空画布 100% 下新增 `视频节点 1` 屏幕框 `x=756 y=403 w=622 h=350`,浮层文本为 `参考 标记 特效 角色库 运镜 描述你想要生成的画面内容,@引用素材 2.0 文生视频 16:9 · 720P · 5s · 1个 · 高级设置 联网搜索 自动校验素材`
- 竞品视频节点高级设置复测: 选中 `视频节点 4` 后,参数栏右侧依次为 `提示词优化`、`翻译提示词`、无文字滑杆图标 `高级设置`、费用 `135`、白色生成按钮;普通 `1512 × 805` 视口下高级区默认折叠,DOM 中预渲染 `高级设置 / 联网搜索 / 自动校验素材` 但外层为 `grid-rows-[0fr]`、高度 0。点击滑杆图标后高级区变 `grid-rows-[1fr]`,高度约 `116.5px`,标题 `高级设置` 下有两行 `联网搜索` 和 `自动校验素材`,两行右侧 Mantine switch 均默认 `checked=true`,轨道 `38 × 20`、白底黑色 14px 滑块;点击 `联网搜索` 后仅第一行变 `checked=false`、轨道 `rgba(255,255,255,0.25)`、滑块移到左侧,第二行保持开启
- 本地视频节点高级设置最终对齐: 原先本地把 `高级设置 / 联网搜索 / 自动校验素材` 作为参数栏文字按钮直接排在同一行,且高阶会员下费用显示 `VIP`;现改为竞品式三枚图标按钮和折叠高级区,`2.0 / 文生视频 / 16:9 · 720P · 5s · 1个 · / 135` 位于同一参数栏,生成按钮空提示词时禁用并提示 `请输入提示词或添加参考素材`。Chrome 复测将节点拖入视口后,点击滑杆图标,高级区 `grid-rows-[1fr]`、高度 `117px`,两枚 `[role="switch"]` 均为 `aria-checked=true`;点击第一行后变 `aria-checked=false`、`bg-white/25`、滑块左移,第二行仍为 `aria-checked=true`
- 竞品视频模型菜单复测: 选中 `视频节点 4`,将节点拖入视口后点击参数栏 `2.0`;弹层为 Mantine popover,外框约 `x=614.5 y=215 w=369 h=409`、`role=dialog`、背景约 `#2d2d2d/95`、圆角 `16px`,内部滚动区 `360 × 400`、列表单项 `360 × 52`、图标底座 `34 × 34`、间距 `4px`。完整顺序共 31 项: `Seedance 2.0 VIP / Seedance 2.0 Fast VIP / Seedance 2.0 Mini / Happy Horse 1.1 / Happy Horse 1.0 / Kling O3 / Kling 3.0 Turbo / Kling 3.0 / Wan 2.7 / Kling O1 / Wan 2.6 / Hailuo 2.3 / Seedance1.5 Pro / Seedance 1.0 Pro / Seedance 1.0 Lite / Kling 2.6 / Hailuo 02 / Vidu Q2 / Vidu Q2 Pro / Vidu Q2 Turbo / Vidu Q3 Pro / OmniHuman 1.5 / Kling 2.5 / Kling 2.1 / Wan 2.2 / Wan 2.5 / Pixverse V5.5 / Pixverse V5 / Hailuo 2.3 Fast / Kling3.0 动作迁移 / MJ Video`;第一项 `data-selected=true`、背景 `rgba(255,255,255,0.15)`,末三项为 `cursor-not-allowed opacity-50` 禁用态。点击第二项后菜单关闭,参数按钮变 `2.0 Fast`、宽约 `114.6px`,`文生视频` 与参数摘要右移,费用仍为 `135`;再次打开可看到第二项高亮,点第一项恢复 `2.0`
- 本地视频模型菜单最终对齐: 原先本地模型菜单只有 12 项,弹层 `420 × 430`、单项 `402 × 69`,带序号和 `720P限时9折/限时4折` 标签,缺少 `Kling 3.0 Turbo/Wan 2.6/Hailuo/Vidu/OmniHuman/Pixverse` 等模型,且 `Kling3.0 动作迁移/MJ Video` 可点击;现改为竞品式 `369 × 410` 外框、`360 × 400` 滚动区、`360 × 52` 单项、31 项完整顺序,第一项 `data-selected=true`、背景 `bg-white/[0.15]`,末三项 disabled 且 `cursor-not-allowed opacity-50`。Chrome 复测空画布新增 `视频节点 1` 后拖入视口,点击 `2.0` 得到弹层 `x=750 y=216 w=369 h=410`、首项 `x=755 y=221 w=360 h=52`,切 `Seedance 2.0 Fast VIP` 后菜单关闭、按钮文本 `2.0 Fast`、宽 `115px`,再次选择首项恢复 `2.0`、宽 `91px`
- 竞品视频参数弹层复测: 在 `视频节点 4` 点击摘要 `16:9 · 720P · 5s · 1个 ·` 后,弹层左边与摘要按钮对齐、底边紧贴摘要顶部;本次外框 `x=824.5 y=179 w=341 h=445`,背景约 `#2d2d2d/95`、边框 `0.5px solid rgb(54,54,54)`、圆角 `16px`、内边距 `12px`,内容宽 `316px`。五组依次为 `比例 / 清晰度 / 视频时长 / 生成音频 / 生成数量`,标签均为 `14px/20px rgb(145,145,145)`;比例按 5 列换行成 `Auto/16:9/4:3/1:1/3:4 + 9:16/21:9`,单项 `56.8 × 62`、间距 `8px`,图标尺寸依次为 `12×9/16×9/12×9/12×12/9×12/9×16/16×7`;清晰度 `480P/720P/1080P/4K` 单项 `73 × 32`,音频 `开启/关闭` 单项 `154 × 32`,数量 `1个/2个/4个` 单项 `100 × 32`。选中态统一为 `rgba(255,255,255,0.1)`、白色 `0.5px` 边框和 `rgb(247,247,247)`,未选中为透明背景、`rgb(82,82,82)` 边框和 `rgb(145,145,145)`;时长滑杆根宽约 `244px`、轨道 `236 × 4`、白色滑块 `12 × 12`,数值框 `48 × 20`、`min=4 max=15 step=1`。实测依次切换 `1:1 -> 1080P -> 关闭音频 -> 2个`,弹层均保持打开,摘要同步为 `1:1 · 1080P · 5s · 2个 ·`;数字框填 `8` 时摘要仍为 `5s`,按 `Enter` 后才变 `8s`,随后已逐项恢复默认值并关闭弹层
- 本地视频参数弹层最终对齐: 原先本地弹层为 `472 × 408`、比例 7 项纯文字横排、标签 `12px`、选中态白底黑字、无时长滑杆,数值框为 `80 × 36 min=1` 且输入时立即提交;现重做为 `341 × 445`、`12px` 内边距、五组 `8px` 纵向间距,并让弹层随模型按钮宽度动态左移/右移,始终与参数摘要左边对齐。Chrome 复测本地外框 `x=959 y=189 w=341 h=445`、摘要 `x=959 y=634 w=204 h=32`;比例、清晰度、音频、数量的每项尺寸、网格换行、图标尺寸、标签字号、边框、颜色和选中态均与竞品测量值一致。补齐 `4-15s` 滑杆和 `48 × 20` 数字框;实测同序点击 `1:1 -> 1080P -> 关闭 -> 2个` 后菜单保持打开、摘要同步,数字框填 `8` 前摘要保持 `5s`、`Enter` 后变 `8s`,滑杆按 `Home` 实时变 `4s`、`ArrowRight` 变 `5s`;最后逐项恢复 `16:9 / 720P / 开启 / 1个 / 5s`
- 竞品视频生成模式菜单复测: 无参考素材时点击参数栏 `文生视频`,打开外框 `x=709.5 y=395 w=197 h=229` 的 Mantine popover,左边与 `文生视频 x=709.5 y=624 w=106 h=32` 对齐、底边紧贴按钮顶部;外框背景约 `#2d2d2d/95`、边框 `0.5px solid rgb(54,54,54)`、圆角 `16px`、模糊 `16px`,内层 `196 × 228`、`padding=8px`、`gap=4px`。标题 `视频生成模式` 高 `32px`,顺序固定为 `文生视频 / 全能参考 / 图生视频 / 首尾帧 / 图片参考`,每项 `180 × 32`、图标 `16 × 16`;当前项背景 `rgba(255,255,255,0.15)`。无参考时仅 `文生视频` 可用,其余四项 disabled;点当前项关闭菜单,点禁用项菜单保持打开且模式不变
- 竞品视频模式素材联动复测: 点击节点尝试项 `首帧生成视频` 后并非立即静态切换,稍后新增 `预设 - 首帧生成视频` 分组和一条 `首帧参考 -> 视频节点 4` 连线,节点总数从侧栏 `98 -> 100`,提示词自动填入 `以当前图为首帧生成视频。`,模式自动变 `全能参考`,无 toast。已有 1 张参考时,`文生视频` disabled,而 `全能参考 / 图生视频 / 首尾帧 / 图片参考` 全部可用;切 `图生视频` 后菜单关闭,快捷动作变 `参考/标记/特效/运镜`,隐藏 `角色库`;切 `首尾帧` 后只保留 `参考/标记/角色库`,缩略图左上标 `首帧`;切 `图片参考` 或 `全能参考` 后恢复 `参考/标记/特效/角色库/运镜` 五项。各模式保留原提示词与 `16:9 · 720P · 5s · 1个`,费用均回显 `135`
- 本地视频生成模式菜单最终对齐: 原先本地点击模式按钮不会打开菜单,而是直接在 `文生视频/全能参考` 间跳转;带参考素材时仍能切回竞品禁止的 `文生视频`,也没有 `图生视频/首尾帧/图片参考` 入口。现新增竞品式 `197 × 229` 模式弹层;Chrome 有参考复测外框 `x=849 y=332 w=197 h=229`、模式按钮 `x=849 y=561 w=106 h=32`,5 项均为 `180 × 32`、图标 `16 × 16`、选中背景 `white/15`,尺寸和相对锚定与竞品一致。无参考复测为 `文生视频` enabled、其余四项 disabled;点击禁用项菜单不关闭。创建首帧参考后自动进入 `全能参考`,`文生视频` disabled、其余四项 enabled;同序切换 `图生视频 -> 首尾帧 -> 图片参考 -> 全能参考`,菜单关闭、按钮文本/图标和快捷动作均按竞品规则变化,`首尾帧` 缩略图补齐 `首帧/尾帧` 角标,各模式费用均显示 `135`
- 本地首帧快捷创建反馈最终对齐: 本地原有 `首帧生成视频` 已能创建首帧图片资源、连线、填入 `以当前图为首帧生成视频。` 并切到 `全能参考`,但会额外弹 `已按「首帧生成视频」创建关联节点`;现只对 `首帧生成视频/首尾帧生成视频` 移除该本地独有 toast。Chrome 清空重建 `视频节点 1` 后复测 `首帧生成视频`,参考节点和连线正常创建、模式自动变 `全能参考`、提示词正确、toast 为空
- 竞品视频 `参考` 画布选择复测: 点击快捷动作 `参考` 不打开上传/历史弹层,而是进入全画布选材态;顶部出现 `314 × 58` 工具栏,背景 `rgb(38,38,38)`、边框 `rgb(54,54,54)`、圆角 `16px`、padding `12px`、gap `16px`,含 `32 × 32` 图标、`从画布选择参考`、`返回节点 76 × 32`、`退出 50 × 32`。`返回节点` 只将 viewport 定位到目标节点且选择态继续;点击可用图片节点会新增 `图片源 -> 视频目标` 连线、工具栏不关闭,图片中央悬浮显示 `取消选择 72 × 28`;再次点击会只断开该参考。`退出` 关闭选择态但保留当前 viewport 和已选参考,全程无 toast
- 竞品视频参考缩略图与移除复测: 连接 2 张图片后,参数浮层由无参考时 `660 × 191` 扩为约 `660 × 254`;快捷动作保持 `54/54/54/66/54 × 26`,参考独占下一行,缩略图外框 `48 × 48`、图片 `47 × 47`、圆角 `12px`、间距 `8px`,左上以 `1/2` 编号,右上 hover 出现 `15 × 15` 删除按钮。删除任意一张只移除对应连线和缩略图,不删除源节点;删除最后一张后模式自动回到 `文生视频`,`首尾帧生成视频/首帧生成视频` 建议项恢复,提示词 `以当前图为首帧生成视频。` 保留
- 本地视频 `参考` 画布选择最终对齐: 原先点击 `参考` 完全无响应;现补齐同款全画布选材状态、`314 × 58` 顶部工具栏、`返回节点/退出` 行为、图片节点点击增删 `source -> target` 连线和选中图片中央 `取消选择 72 × 28`。Chrome 以真实坐标重建 `视频节点 1 -> 首帧` 后复测,画布根滚动保持 `0`,工具栏为 `x=599 y=80 w=314 h=58`;hover 首帧时取消按钮 `x=410 y=564.875 w=72 h=26.25`、opacity `1`,点击后工具栏仍在、参考连线消失、模式回到 `文生视频`、源节点和提示词均保留,退出后工具栏关闭,控制台 error 为 0
- 本地视频参考缩略图最终对齐: 原先参考图混在快捷动作行且只有 `32 × 26`,参数浮层高度固定,也没有移除入口;现移到独立换行区,缩略图改为 `48 × 48`、图片 `47 × 47`、圆角 `12px`、间距 `8px`,右上 hover 删除按钮为 `15 × 15`,并按参考行数扩展浮层高度。Chrome 复测单参考浮层为 `660 × 254`,五枚快捷动作宽度为 `54/54/54/66/54`;缩略图删除和画布中央取消两条路径均只删除边,最后一条移除后按竞品规则回退模式并恢复建议项
- 视频参考状态机推断: 本步骤没有触发文件上传或生成请求,竞品行为可由画布图状态直接解释:选材等价于创建/删除 `image source -> video target` 有向边,缩略图列表来自目标节点的入边;可用生成模式由图片入边数量派生,数量归零时强制回退 `text-to-video`,但 prompt 独立保存。因此本地按同一图状态机实现,没有新增硬编码素材或伪造后端提示词
- 竞品视频 `标记` 选择模式复测: 点击快捷动作 `标记` 不直接改写提示词,而是进入全画布 `元素选择模式`;顶部工具栏为 `336 × 58`、背景 `#262626`、边框 `#363636`、圆角 `16px`,包含 `点击图片选择局部元素 / 返回节点 76 × 32 / 退出 50 × 32`。非图片节点在此模式下不可点,点击图片局部后原位显示 `分析中...`,首个真实结果约 `7-8s` 返回 `人物`,第二个约 `20s` 返回 `人物面部`;工具栏和选择模式在连续标记后均保持,无 toast
- 竞品视频标记结果与层级复测: 每次结果同时落到源图片和目标提示词;源图显示 `34px` 高徽章、指向点击点的竖线及 hover `24 × 24` 删除按钮,目标提示词插入不可编辑 `.magic-chip`,id 规则为 `源节点ID_序号`。`人物` 芯片带层级箭头,点击后出现 `120px` 宽菜单,可在 `人物 / 眼睛` 间切换并同步 `data-magic-index`;`人物面部` 无层级箭头。源徽章删除会联动删除对应芯片且不影响其他标记;将光标放到芯片后按 `Backspace` 也只删除该结构化标记。`退出` 保留标记,`返回节点` 保持选择态;本轮结束前已将竞品临时标记全部清理,提示词恢复为 `以当前图为首帧生成视频。`
- 本地视频 `标记` 最终对齐: 原先本地点击 `标记` 只会在 textarea 后拼接 `,添加人物与构图标记`,产生 `。` 后多余逗号且没有选区语义;现改为同款全画布元素选择工具栏、图片局部点击、分析中状态、源图徽章/指示线/hover 删除、源徽章当前层级菜单、提示词不可编辑芯片、层级菜单、源端联动删除和 Backspace 删除。Chrome 使用真实图片实测返回 `汽车`,层级为 `汽车 / 车头盖 / 前大灯 / 奔驰车标`;切到 `车头盖` 后芯片和索引同步,删除后提示词精确恢复,退出无 toast。最终优化后的真实分析耗时约 `13.3s`
- 本地视频标记后端与配置: 复用现有 `/api/pick-object` 和 Qwen3-VL 能力,新增 `video-canvas-mark` 模式及后台可编辑的 `element-mark` 功能配置,模型默认 `qwen3-vl-plus`,提示词和启停状态均落在 `video_canvas_function_config`,未写死在前端。视频标记模式由服务端拉取源图、用 Sharp 缩放到最长边 `500px` 并叠加点击十字,直接以内存 data URL 调用视觉模型,避免临时上传;原有通用物体选择路径保持不变
- 视频标记算法推断: 竞品返回的不是单一自由文本,而是由点击坐标约束的局部视觉识别结果,数据至少包含 `sourceNodeId / sequence / clickX / clickY / labelOptions / selectedIndex`;层级标签按“主体到局部”排序,提示词芯片只是结构化状态的编辑视图,生成时再把已选标签和对应参考图传给视频任务。本地据此保存结构化 `magicMarks`,生成请求附带标记图片并在后端提示中加入当前选中标签,用户可见提示词保持干净
- 竞品添加视频合成 Beta 节点复测: 点击 `添加节点 -> 视频合成 Beta` 后,节点数 `18 -> 19`、边数保持 `10`、viewport 保持 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)`、无 toast;新增并选中 `react-flow__node-video-clip`,标题 `视频合成 11`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,反推基础尺寸 `350 × 350`;节点正文为 `空空如也,请连接视频节点后操作`
- 本地添加视频合成 Beta 节点最终对齐: 原先底部菜单创建 `视频合成` 会生成 `436 × 470` 节点,落点为 `x=618 y=421`;现改为竞品同款 `350 × 350`,按当前 viewport 中心左上角落点,无新增边、无 toast,正文为 `空空如也,请连接视频节点后操作`。Chrome 复测空画布 100% 下新增 `视频合成 1`,节点框 `x=756 y=403 w=350 h=350`
- 竞品添加导演台 NEW 节点复测: 点击 `添加节点 -> 导演台 NEW` 后,节点数 `19 -> 20`、边数保持 `10`、viewport 保持 `matrix(0.743079,0,0,0.743079,-3842.91,487.096)`、无 toast;新增并选中 `react-flow__node-director-console-3d`,标题 `导演台 12`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,反推基础尺寸 `350 × 350`;节点正文为 `在3D空间中搭建场景并进行多视角截图 / 打开导演台`,按钮为 24px 高半透明小按钮
- 竞品导演台全屏复测: 点击 `打开导演台` 后出现 fixed 全屏编辑器,外壳 `x=0 y=0 w=1512 h=805`、z-index `600`、背景 `rgba(8,8,16,0.98)`;顶栏 `h=64 bg=rgb(33,33,33)`,`导演视角/机位视角` 位于顶栏中部,`帮助/关闭` 为右上 24px 按钮;左栏 `x=0 y=64 w=230 h=741`,含 `场景 / 搜索场景对象 / 机位1 / 角色A`;右栏 `x=1231 y=64 w=281 h=741`,标题 `3D场景`,含 `场景缩放 / 场景平移 XYZ / 场景旋转 XYZ / 全景背景 / 已连接全景图 / 请将图片节点连接到导演台左侧输入口 / 天空颜色 # / 全景球 / 水平旋转 / 球形半径 / 角色标签 / 网格吸附 / 地面 / 透明度 / 高度`;主区右上有 `72 × 72` 小 canvas 和 `重置视角 x=1139 y=158 w=72 h=21`,底部工具条含 `移动 (V) / 添加角色 / 全景图 / 添加机位 / 选择画幅比例 / 截图 / AI 识图导入 / 全屏`
- 本地添加导演台 NEW 最终对齐: 原先本地创建导演台为 `436 × 470` 大节点、按居中偏移落点,且打开后是旧版 `属性/摄像机截图` 侧栏;现菜单创建 `导演台 1` 为 `350 × 350`,空画布 100% 复测节点框 `x=756 y=403 w=350 h=350`,按钮 `x=893 y=560 w=76 h=24`、半透明背景、无新增边/无 toast。打开后全屏外壳 `1512 × 805`、顶栏 `64`、左栏 `230 × 741`、右栏 `281 × 741`、右上小 canvas `72 × 72`、`重置视角 x=1139 y=158 w=72 h=21`,右侧文案和竞品 `3D场景` 参数结构一致
- 竞品导演台重置视角按钮复测: 在右侧停留 `摄像机` 面板、选中 `机位1` 时点击主区右上 `重置视角 x=1139 y=158 w=72 h=21`;按钮类为 `mt-1.5 w-full rounded-md bg-white/10 py-0.5 text-[11px] text-white/60 ... hover:bg-white/15 hover:text-white/85`,点击后仅保留 hover 样式,不弹 toast、不改 URL、不切换右侧面板、不改变选中对象
- 本地导演台重置视角按钮最终对齐: 原先本地按钮为 `top-24 text-[12px] text-white/72` 且点击会弹 `已重置导演视角。`;现改为 `top-[94px] text-[11px] text-white/60 hover:text-white/85` 并移除 toast。Chrome 复测普通态按钮 `x=1139 y=158 w=72 h=21`,点击后无 toast、URL 不变、右侧仍为 `摄像机`;沉浸态复测按钮为 `x=1420 y=158 w=72 h=21`
- 竞品导演台底栏全屏按钮复测: 在导演台编辑器内点击底部 `全屏 x=892 y=740.5 w=36 h=36` 后,不进入浏览器原生 fullscreen,`document.fullscreenElement=false`、viewport 仍为 `1512 × 805`;应用内进入沉浸态,左侧 `场景` 栏和右侧 `3D场景` 面板隐藏,顶栏仍为 `x=0 y=0 w=1512 h=64 bg=rgb(33,33,33)`,主区变为 `x=0 y=64 w=1512 h=741`,`重置视角` 移到 `x=1420 y=158 w=72 h=21`;底部工具条仍为 `x=575.5 y=732 w=361 h=53`,按钮文案/tooltip 变为 `退出全屏 x=892 y=740.5 w=36 h=36`,全程无 toast
- 本地导演台底栏全屏按钮最终对齐: 原先本地点击该按钮只弹 `全屏待接入。` toast,布局不变;现补齐应用内沉浸态切换。Chrome 复测点击 `全屏` 后 `document.fullscreenElement=false`、无 toast,左/右侧栏隐藏,主区变为 `x=0 y=64 w=1512 h=741`,`重置视角 x=1420 y=158 w=72 h=21`,底部按钮变为 `退出全屏`;再次点击后恢复左栏 `x=0 y=64 w=230 h=741`、主区 `x=230 y=64 w=1001 h=741`、右栏 `x=1231 y=64 w=281 h=741`,按钮恢复 `全屏`
- 竞品导演台移动工具菜单复测: 点击底部 `移动 (V) x=584 y=740.5 w=36 h=36` 后无 toast,打开变换模式菜单,主按钮变 `bg-white/[0.14] + ring-white/30` active;菜单外壳 `x=575.5 y=614 w=132 h=118`、背景 `rgba(31,31,31,0.72)`、边框 `white/12`,三项均为 `x=582.5 w=118 h=32`,顺序和 y 坐标为 `移动 V y=621 / 旋转 R y=657 / 缩放 S y=693`。点击 `旋转 R` 或 `缩放 S` 后菜单关闭,底栏第一个按钮同步变为 `旋转 (R)` 或 `缩放 (S)`;重开后对应项保留 active。菜单打开时按 `R/S/V` 会直接切换 active 且菜单保持打开,菜单关闭时按 `V` 只切内部模式、不主动打开菜单
- 本地导演台移动工具菜单最终对齐: 原先本地点击 `移动 (V)` 会弹 `移动 (V)待接入。`,没有可记忆模式;现补齐竞品式三项变换菜单、动态主按钮和快捷键。Chrome 复测本地菜单外壳 `x=575.5 y=614 w=132 h=118`、三项坐标 `x=582.5 y=621/657/693 w=118 h=32`,点击子项关闭且无 toast,重开后保留 `旋转/缩放/移动` active;按 `S` 后底栏主按钮变为 `缩放 (S)`,点击可打开菜单并显示 `缩放 S` active;菜单关闭后按 `V` 不打开菜单,重开显示 `移动 V` active
- 竞品导演台顶栏视角切换复测: 顶栏中部为 `导演视角/机位视角` 分段控件,外壳 `x=675.7 y=11.5 w=174 h=40`、背景 `rgb(20,20,20)`、边框 `rgb(54,54,54)`、圆角 `16px`;两个按钮均为 `80 × 32`,坐标 `导演视角 x=680.7 y=15.5`、`机位视角 x=764.7 y=15.5`,active 为 `bg-white/10`。在底部变换菜单打开时点击 `导演视角` 会关闭菜单、无 toast,并将主区角标切为 `导演视角`,底栏 transform 主按钮仍保留之前选中的 `缩放 (S)`
- 本地导演台顶栏视角切换最终对齐: 原先本地顶栏只是透明 `160 × 32` 分段,inactive 文案偏灰,且切换时不统一收起底部菜单;现改为竞品同款 `174 × 40` 黑色分段控件。Chrome 复测本地外壳 `x=675.7 y=11.5 w=174 h=40`,按钮坐标 `x=680.7/764.7 y=15.5 w=80 h=32`,切换 `机位视角` 后底部菜单关闭、无 toast,主区角标变为 `导演台 1 · 机位视角 · 16:9`,transform 主按钮保持 `缩放 (S)`
- 竞品导演台帮助菜单复测: 右上 `帮助 x=1432 y=19.5 w=24 h=24`、`关闭 x=1464 y=19.5 w=24 h=24`;点击帮助不新开标签、不弹 toast,帮助按钮 active 为 `bg-white/10`,下方菜单外壳 `x=1336 y=43.5 w=120 h=84`,菜单体 `x=1336 y=49.5 w=120 h=78`、背景 `rgba(31,31,31,0.94)`、边框 `white/10`、圆角 `12px`,两项为 `快捷键 x=1341 y=54.5 w=110 h=32`、`使用教程 x=1341 y=90.5 w=110 h=32`
- 竞品导演台快捷键与教程复测: 点击/hover `快捷键` 后菜单不关闭,条目 active,左侧快捷键面板外层 `x=925 y=54.5 w=416 h=234`、面板 `x=925 y=54.5 w=408 h=234`、背景 `#1f1f1f/95`、圆角 `16px`,内容为 `俯视视角 T / 重置视角 Q / 打组 G / 撤销 Z / 正面视角 Y / 网格吸附 X / 解组 ⇧ D / 删除`。点击 `使用教程` 后不新开标签、不弹 toast,进入跟随教程层,顶部黄条 `x=669.1 y=0 w=173.7 h=34`,按钮 `取消ESC x=766.1 y=9 w=63.7 h=16`,并出现 `构图参考线` 高亮按钮;在关闭导演台后黄条仍残留在主画布,且本轮实测 `Escape` 和点击 `取消ESC` 都未将该残留条清掉
- 竞品导演台关闭按钮复测: 在教程跟随态点击右上 `关闭` 后,导演台 fixed 编辑器关闭,右上 `帮助/关闭` 消失,回到主画布并显示节点按钮 `打开导演台 x=854.5 y=568.1 w=67.6 h=23.8`;无 toast、不改 URL、不新增/删除主画布节点;教程黄条作为全局跟随态保留
- 本地导演台帮助/快捷键/教程/关闭最终对齐: 原先本地帮助只弹 `导演台帮助文档待接入。` toast;现补齐竞品式帮助菜单、快捷键二级面板、教程跟随层和关闭保留黄条行为。Chrome 复测帮助菜单为 `x=1336 y=49.5 w=120 h=78`,两项 `x=1341 y=54.5/90.5 w=110 h=32`;快捷键面板修正为 `x=925 y=54.5 w=408 h=234`,内容与竞品一致;教程黄条为 `x=669 y=0 w=174 h=34`、取消按钮 `x=766 y=9 w=64 h=16`;教程态点击 `关闭` 后本地同样回主画布、隐藏 `帮助/关闭`、保留黄条,`Escape` 可清掉本地全局黄条
- 竞品导演台左侧场景树搜索复测: 搜索框 `x=12 y=126 w=205 h=32`、placeholder `请输入搜索内容`,上方 `场景 x=24 y=88`;列表从 `x=12 y=178` 开始,每行 `205 × 32`,对象顺序为 `道具1-7 / 机位1-4 / 角色A-C`。输入 `角色` 后即时过滤为 `角色A/B/C`,首行回到 `y=178`,右侧仍停留在原面板;输入 `机位2` 后只剩 `机位2`;输入 `不存在` 后列表无对象行,显示空态 `没有匹配的场景对象 x=12 y=178 w=205 h≈58.6`,右侧仍停留在上一次选中对象
- 竞品导演台场景树点击联动复测: 在搜索词 `角色` 下点击 `角色C x=12 y=242 w=205 h=32` 后,搜索词保留,右侧切到 `角色` 面板,`属性` tab active、`姿势` inactive,名称输入 `value=角色C x=1248 y=217 w=248 h=28`;在搜索词 `机位2` 下点击唯一行后,右侧切到 `摄像机` 面板,`属性` active、`摄像机截图` inactive,名称输入 `value=机位2 x=1248 y=385 w=248 h=28`,切换机位为 native select `x=1248 y=457 w=248 h=28`,位置为 `0 / 2.2 / 10`,注视坐标为 `0 / 1.2 / 0`,FOV 为 `50.0`
- 本地导演台左侧场景树搜索/点击最终对齐: 原先本地搜索框可输入但不会过滤,行文本包含 `▣/♙` 字符,选中行直接用元素背景;现改为受控即时过滤、视觉图标不进入文本、无匹配空态和竞品式伪元素高亮。Chrome 复测本地搜索框 `x=12 y=126 w=205 h=32`,行从 `y=178` 起,输入 `角色` 只剩 `角色A`,输入 `不存在` 显示 `没有匹配的场景对象 x=12 y=178 w=205 h=58`;过滤态点击 `角色A` 后搜索词保留且右侧切 `角色`,名称输入 `value=角色A x=1248 y=217 w=248 h=28`;过滤态点击 `机位1` 后右侧切 `摄像机`,名称输入 `value=机位1 x=1248 y=384 w=248 h=28`,位置/注视坐标/FOV 与竞品默认规则一致
- 竞品导演台选择画幅比例复测: 在导演台全屏内点击底部 `选择画幅比例` 图标按钮,按钮本体 `x=760 y=740.5 w=36 h=36`,上方出现比例菜单且不关闭工具条;菜单外壳 `x=638 y=550.5 w=280 h=182`、背景 `rgba(31,31,31,0.72)`、边框 `white/12`、z-index `1600`,标题 `比例`,网格 `x=651 y=583.5 w=254 h=136`,4 列布局,选项顺序和坐标为 `Auto x=651`、`21:9 x=716.5`、`16:9 x=782`、`4:3 x=847.5`、第二行 `1:1 x=651`、`3:4 x=716.5`、`9:16 x=782`,单项 `57.5 × 64`;点击 `9:16` 后菜单保持打开,`9:16` 变白字和 `white/8` 背景,其余项保持 `white/55`
- 本地导演台选择画幅比例最终对齐: 原先本地底部导演台工具条为文字按钮,整体 `752.6 × 58`,点击 `选择画幅比例` 会直接在角标中切换 `16:9/9:16` 且无菜单;现改为竞品式 8 个 `36 × 36` 图标按钮,工具条外壳 `x=575.5 y=732 w=361 h=53`,比例按钮 `x=760 y=740.5 w=36 h=36`。Chrome 复测点击比例按钮后菜单 `x=638 y=550.5 w=280 h=182`、网格 `x=651 y=583.5 w=254 h=136`、选项坐标/尺寸与竞品一致;点击 `9:16` 后菜单保持打开,`9:16` 白字 `white/8`,主区角标更新为 `导演台 1 · 导演视角 · 9:16`
- 竞品导演台截图复测: 在画幅菜单打开态点击底部 `截图 x=804 y=740.5 w=36 h=36` 后,比例菜单立即关闭,画布节点/连线数量保持不变;约 1.2s 内按钮和 toast 显示 `正在上传截图...`,toast 位于顶部 `x=663.5 y=32 w=185.1 h=40.4`,蓝色半透明背景。等待约 8s 后上传态消失,工具条按钮恢复 `截图`,仍不向主画布新增节点/连线;导演台左侧场景树新增 `机位2`,右侧从 `3D场景` 切为 `摄像机`,显示 `属性 / 摄像机截图 / FOV 50° / 名称 / 切换机位 / 机位1 / 机位2 / 位置 / 注视目标 / 手动坐标 / 角色A / 注视坐标 / 视野角度 (FOV) / 相机截图 / 机位2-shot-01`。右侧面板仍为 `x=1231 y=64 w=281 h=741`,`相机截图` 标题约 `x=1248 y=823`,截图缩略图约 `80 × 80`、起点约 `x=1248 y=858`
- 本地导演台截图最终对齐: 原先本地 `截图` 只弹 `截图待接入`,不会改变导演台内部结构;现点击后关闭比例菜单,按钮进入 `正在上传截图...` spinner/禁用态,节点/连线数量保持不变。Chrome 干净路径复测 `添加节点 -> 导演台 NEW -> 打开导演台 -> 打开画幅菜单 -> 截图`: 上传中阶段 `ratioMenuOpen=false`、`nodes=0`、`edges=0`、按钮 `aria/title=正在上传截图...`;完成后左栏新增 `机位2`,右栏 `x=1231 y=64 w=281 h=741` 切为 `摄像机`,文案顺序与竞品一致,`相机截图` 标题 `x=1248 y=823`,截图卡片 `机位2-shot-01 x=1248 y=849 w=248 h=92`,卡片内缩略图起点约 `y=857`,工具条恢复 `截图 x=804 y=740.5 w=36 h=36`
- 竞品导演台 AI 识图导入复测: 点击底部 `AI 识图导入 x=848 y=740.5 w=36 h=36` 后,打开遮罩模态,外层 `bg-black/35 + backdrop blur`,主体 `x=436 y=117 w=640 h=571`、背景 `rgb(31,31,31)`、圆角 `12px`、边框 `white/10`;标题 `AI 识图导入`,tab 为 `本地上传 x=453 y=182`、`历史记录 x=529 y=182`。默认上传区 `x=453 y=216 w=606 h=298`,文案 `点击上传图片或拖拽本地图片至此上传 / 上传后画布将新建一个图片节点并自动替换当前图源`;覆盖选项 `插入当前导演台 x=453 y=556 w=299 h=53` 默认选中,`覆盖当前导演台 x=760 y=556 w=299 h=53`;底部提示 `退出不会中断生成过程,全景图生成成功后,会自动加载到背景`,按钮 `识图导入场景 x=957 y=642 w=102 h=32` 初始禁用
- 竞品导演台 AI 识图历史与完成态复测: 切到 `历史记录` 后显示日期 `2026-07-09` 和 8 张图片历史,4 列两行,缩略图 `138 × 138`,首行 `x=462/612/762/912 y=257`,`使用` 按钮 `x=512/662/812/962 y=312 w=38 h=28`;点击第一张 `使用` 后不关闭弹层、不新增主画布节点/连线,上传区变为预览图 `x=454 y=217 w=604 h=296`,文案 `更换图片`,底部出现消耗 `6`,按钮启用。点击 `识图导入场景` 后顶部额度 `20 -> 14`,tab 禁用,按钮变 `识图中…`,约 25s 后弹层自动关闭,主画布节点/连线仍为 `20/10`,导演台内部左栏变为 `道具1-7 / 机位1 / 机位2 / 机位3 / 角色A / 角色B`,右栏回到 `3D场景` 参数面板
- 本地导演台 AI 识图导入最终对齐: 原先本地 `AI 识图导入` 只弹 `AI 识图导入待接入` toast;现补齐竞品式 `640 × 571` 模态、上传/历史 tab、8 张历史图、使用后大预览、插入/覆盖选项、消耗 `6`、`识图中…` pending 和完成导入。Chrome 干净路径复测 `添加节点 -> 导演台 NEW -> 打开导演台 -> AI 识图导入 -> 历史记录 -> 使用第一张 -> 识图导入场景`: 预览 `x=454 y=217 w=604 h=296`,按钮启用 `x=957 y=642 w=102 h=32`;运行中按钮禁用并显示 `识图中…`,tab 禁用,节点/边保持 `0/0`;约 24s 后弹层关闭,左栏 `x=0 y=64 w=230 h=741` 显示 `道具1-7 / 机位1 / 机位2 / 机位3 / 角色A / 角色B`,右栏 `x=1231 y=64 w=281 h=741` 回到 `3D场景`,无主画布新增节点/连线
- 竞品导演台添加机位复测: 在 AI 识图导入后的导演台状态点击底部 `添加机位 x=716 y=740.5 w=36 h=36`,不会立即新增机位,而是在按钮上方打开 `选择机位视角` 预设菜单;菜单外壳 `x=614 y=415 w=240 h=318`,背景 `rgba(31,31,31,0.72)`、边框 `white/12`、圆角 `12px`,标题 `x=621 y=422 w=226 h=24`,滚动区 `x=621 y=446 h=280`。预设为 2 列 `110 × 72`,顺序为 `当前视角 / 正面中景 / 正面特写 / 正面全景 / 侧面跟拍 / 侧面近景 / 背面中景 / 俯拍全景 / 45° 俯拍 / 低角度仰拍 / 低角度广角 / 过肩镜头 / 过肩镜头(右) / 鸟瞰 / 荷兰角`
- 竞品导演台添加机位完成态复测: 点击 `当前视角 x=621 y=446 w=110 h=72` 后菜单关闭,不新增主画布节点/连线;导演台左栏在 `机位3` 和 `角色A` 之间新增并选中 `机位4`,行坐标 `x=12 y=498 w=205 h=32`,角色行下移到 `角色A y=530 / 角色B y=562`;右栏切到 `摄像机`,名称输入和 `切换机位` 下拉均为 `机位4`,下拉选项含 `机位1-4`,位置为 `X 0 / Y 2.2 / Z 10`,注视目标为 `手动坐标`,注视坐标为 `X 0 / Y 1.2 / Z 0`,FOV 为 `50.0`
- 本地导演台添加机位最终对齐: 原先本地 `添加机位` 只弹 `添加机位待接入。` toast;现改为竞品式预设菜单,Chrome 复测菜单标题 `x=621 y=422 w=226 h=24`、首屏按钮 `当前视角 x=621 y=446 w=110 h=72`、`正面中景 x=737 y=446 w=110 h=72`,并补齐 15 个预设。选择 `当前视角` 后菜单关闭、主画布节点/边保持 `2/0`,左栏从 `道具1-7 / 机位1-3 / 角色A/B` 变为 `道具1-7 / 机位1-4 / 角色A/B`,`机位4` 行 `x=12 y=498 w=197 h=32`、文本 `x=52 y=508`,右栏 `摄像机` 打开,名称输入 `value=机位4 x=1248 y=384 w=248 h=28`,切换机位下拉 `value=机位4 x=1248 y=456 w=248 h=28`,位置和注视坐标与竞品一致
- 竞品导演台切换机位下拉复测: 在右侧 `摄像机` 面板中,下拉共有 2 个 select,首个为 `切换机位 x=1248 y=457 w=248 h=28`;从 `机位2` 选择 `机位4` 后不弹专属 toast、不改 URL,名称输入同步为 `机位4 x=1248 y=385 w=248 h=28`,下拉选中 `机位4`,位置仍为 `0 / 2.2 / 10`、注视目标仍为 `手动坐标`、注视坐标 `0 / 1.2 / 0`、FOV `50.0`;左侧 `机位4 x=12 y=498 w=205 h=32` 变选中,选中行 class 为 `after:top-1/2 after:h-6 after:-translate-y-1/2 after:rounded after:bg-white/8`
- 本地导演台切换机位下拉最终对齐: 原先本地右侧下拉联动已能改名称/选中值,但左侧场景树选中态是整行 `after:inset-0 bg-white/10 rounded-lg`;现改为竞品式 24px 中间高亮条。Chrome 复测本地先清空场景搜索、添加 `机位2` 后,右侧 `切换机位 x=1248 y=456 w=248 h=28` 从 `机位2` 切回 `机位1` 再切到 `机位2`,名称输入和下拉值同步、位置/注视/FOV 保持默认、无新增主画布节点/边、无切换 toast;左侧高亮在 `机位1/机位2` 间移动,active class 为 `after:top-1/2 after:h-6 after:-translate-y-1/2 after:rounded after:bg-white/8 hover:after:bg-white/8`
- 竞品导演台 FOV 控件复测: 在右侧 `摄像机` 面板中,预览框 `x=1248 y=185 w=240 h=135`、左上标签 `FOV 50° x=1261 y=198 h=13`;`视野角度 (FOV)` 标题 `x=1248 y=735.5`,隐藏 range 点击层 `x=1248 y=766 w=170 h=20 min=15 max=90 step=1 value=50`,数值输入 `x=1426 y=762 w=70 h=28 value=50.0`。输入 `64.0` 后 range value 同步 `64`、数值框保持 `64.0`、预览标签同步 `FOV 64°`,不改 URL
- 本地导演台 FOV 控件最终对齐: 原先本地摄像机预览框为 `248 × 134`、FOV 标签行高偏高,range 为原生 `x=1248 y=774 w=180 h=6 min=20 max=100`,数值框 `x=1440 y=761 w=56 h=32`;现改为竞品式 `240 × 135` 预览、`leading-none text-white/55` 标签、自绘轨道 + 透明 range 点击层,FOV 范围限制为 `15-90`。Chrome 复测本地 `range x=1248 y=766 w=170 h=20 min=15 max=90 step=1`、数值框 `x=1426 y=762 w=70 h=28`;输入 `64.0` 后 range value 为 `64`,数值框为 `64.0`,预览同步 `FOV 64°`
- 竞品导演台摄像机预览按钮复测: 右侧摄像机预览框右下按钮 `x=1451 y=283 w=24 h=24`,title 为 `切换到机位视角`,class 为 `absolute bottom-3 right-3 flex size-6 ... rounded-lg bg-white/10 text-white/85 hover:bg-white/16`。点击后不改 URL、不切右侧面板、不弹专属 toast,顶部 `机位视角 x=764.7 y=15.5 w=80 h=32` 变 active,`导演视角` 变 inactive
- 本地导演台摄像机预览按钮最终对齐: 原先本地按钮位于 `x=1455 y=287`、无 title、点击只保留 hover 样式且不切视角;现改为 `bottom-3 right-3`、title `切换到机位视角` 并接入 `setViewMode("camera")`。Chrome 复测本地按钮 `x=1451 y=283 w=24 h=24`,点击后顶部 `机位视角` active、右侧仍为 `摄像机`、URL 不变、无新增 toast,主区角标同步为 `导演台 1 · 机位视角 · 16:9`
- 竞品导演台摄像机截图 tab 复测: 在右侧 `摄像机` 面板点击 `摄像机截图 x=1306 y=128 w=89 h=28` 后,URL 不变,`属性` tab 变 inactive `hover:bg-white/6 text-white/45 hover:text-white/75`,`摄像机截图` 变 active `bg-white/10 text-neutral-50`,属性表单消失且 `visibleInputs=0`。内容区从 `x=1232 y=169 w=280 h=636` 开始,画廊外壳 `x=1232 y=169 w=280 h=563`,分组 `机位2截图 x=1248 y=181 w=248 h=28`,三列网格 `x=1248 y=213 w=248 h=102 grid-cols=80px 80px 80px gap=4`,卡片 `机位2-截图01 x=1248 y=213 w=80 h=102`,标题 `x=1248 y=297 w=80 h=18`;底部 footer `x=1232 y=732 w=280 h=73`,按钮 `全部清空 x=1248 y=749 w=120 h=40`、`发送到画布 x=1376 y=749 w=120 h=40`
- 本地导演台摄像机截图 tab 最终对齐: 原先本地 `摄像机截图` 是静态按钮,点击后仍停留在属性表单、输入框仍为 10、无底部 footer;现新增摄像机检查器 tab 状态,点击后切到独立画廊页,属性表单消失且 `visibleInputs=0`,tab 激活态、画廊外壳、分组标题、三列网格和 footer 坐标与竞品一致。Chrome 复测无截图时显示 `机位1截图` + footer;再用底部 `截图` 中心点 `x=822 y=759` 触发上传,按钮进入 `正在上传截图...` 禁用态,完成后生成 `机位2-shot-01`,切入截图 tab 后显示 `机位2截图`、卡片 `x=1248 y=213 w=80 h=102`、标题 `机位2-截图01 x=1248 y=297 w=80 h=18`;`全部清空` 已接入清空本地截图列表,`发送到画布` 的落节点/连线行为需下一轮单独实测竞品后再接入
- 竞品导演台截图发送到画布复测: 在右侧 `摄像机截图` tab 中点击 `发送到画布 x=1376 y=749 w=120 h=40` 后,导演台弹层不关闭、右侧仍停留截图页、URL 不变、按钮不进入 pending、无可见 toast;主画布底层节点数 `21 -> 22`、边数 `10 -> 11`。新增节点为 `react-flow__node-image`,文本 `导演台 12 机位2-shot-01`,屏幕框 `x=1107.5 y=406.8 w=260.1 h=462.2`,反推世界尺寸 `350 × 622`、世界坐标 `x=6662 y=-108`;原导演台节点世界坐标为 `x=6192 y=-108`,所以发送算法是以导演台为 anchor 右移 `470`,并新增一条 `导演台 -> 截图图片节点` 的边,导演台节点保持 selected
- 本地导演台截图发送到画布最终对齐: 原先本地 `发送到画布` 是空按钮;现父画布向导演台弹层传入发送回调,按竞品规则为每个当前机位截图创建 `350 × 622` 图片资源节点,标题为 `${导演台标题} ${原始截图名}`,位置为 `director.x + 470 * index`、`director.y`,并新增 `director -> snapshot image` 连线,同时保持导演台弹层打开且无 toast。Chrome 空画布复测 `添加节点 -> 导演台 NEW -> 打开导演台 -> 截图 -> 摄像机截图 -> 发送到画布`: 发送后弹层仍开、右侧仍为截图页、边数 `0 -> 1`、页面底层新增 `导演台 1 机位2-shot-01`;节点 DOM 为 `x=1226 y=403 w=350 h=622`,原导演台为 `x=756 y=403 w=350 h=350`,刚好右移 `470`
- 竞品导演台截图全部清空复测: 在右侧 `摄像机截图` tab 中首次点击 `全部清空 x=1248 y=749 w=120 h=40` 不会立刻删除,而是在按钮上方弹出确认浮层,浮层 `x=1248 y=640.7 w=120 h=100.3`,文案 `确认清空所有截图?`,按钮 `取消 x=1260 y=701 w=44 h=28`、红色 `确认 x=1312 y=701 w=44 h=28`;浮层打开时截图卡仍存在,`发送到画布` 仍可用。点击 `确认` 后浮层关闭,截图卡消失,内容区显示居中空态 `暂无摄像机截图 x=1240 y=181 w=264 h=535`;底部 `全部清空` 和 `发送到画布` 保留但 disabled,URL 不变,主画布已发送的 `导演台 12 机位2-shot-01` 节点和 `11` 条边不被删除
- 本地导演台截图全部清空最终对齐: 原先本地点击 `全部清空` 会直接清空,清空后仍显示空分组标题 `机位2截图` 且两个 footer 按钮仍启用;现补齐竞品式确认浮层、空态和 disabled 规则。Chrome 复测本地 `摄像机截图` 空态显示 `暂无摄像机截图`,两个按钮 disabled;重新用底部 `截图` 生成 `机位2-截图01` 后,点击 `全部清空` 出现同款确认浮层 `x=1248 y=642 w=120 h=99`,`取消/确认` 按钮均为 `44 × 28`,确认前卡片保留;点击 `确认` 后显示 `暂无摄像机截图`,两个按钮 disabled,页面底层已发送到画布的 `导演台 1 机位2-shot-01` 和 `1` 条边保留
- 竞品导演台添加角色复测: 在 `道具1-7 / 机位1-4 / 角色A/B` 且右侧为 `摄像机` 的状态点击底部 `添加角色 x=628 y=740.5 w=36 h=36`,不会立即新增角色,而是在按钮上方打开角色菜单;菜单外壳 `x=619.5 y=312 w=148 h=420`、背景 `rgba(31,31,31,0.72)`、边框 `white/12`、圆角 `12px`,按钮 `x=624.5 w=138 h=32`,顺序和 y 坐标为 `本地上传 317 / 男性素体 362 / 女性素体 398 / 宽厚素体 434 / 健壮素体 470 / 纤细素体 506 / 少年素体 542 / 儿童素体 578 / 二头身 614 / 群众 (3x3) 650 / 几何模型 695`,`本地上传` 与 `群众 (3x3)` 后有分隔线,`群众 (3x3)` 和 `几何模型` 带右箭头
- 竞品导演台添加角色完成态复测: 点击 `男性素体` 后菜单关闭、不弹 toast、不新增主画布节点/连线;导演台左栏在 `角色B` 后新增并选中 `角色C`,行坐标 `x=12 y=594 w=205 h=32`、文本 `x=52 y=604`;主场景中模型变为绿色,标签变 `角色C` 并显示变换轴;右栏标题切为 `角色`,tab 为 `属性 / 姿势`,属性面板字段为 `名称=角色C`、位置 `0/0/0`、旋转 `0/0/0`、缩放 `1.03/1.03/1.03`、统一缩放 `1.0`、颜色 `#34C759`。反推命名规则为按现有 `角色A/B/...` 取下一个大写字母
- 本地导演台添加角色最终对齐: 原先本地 `添加角色` 只弹 `添加角色待接入。` toast;现补齐竞品式角色菜单和新增角色流程。Chrome 复测完整路径 `导演台 NEW -> AI 识图导入 -> 添加机位 当前视角 -> 添加角色 -> 男性素体`: 本地菜单外壳 `x=619.5 y=312 w=148 h=420`、按钮 `x=624.5 w=138`,各项 y 坐标 `317/362/398/434/470/506/542/578/614/650/695` 与竞品一致;选择 `男性素体` 后菜单关闭、无 toast、主画布节点/边保持 `1/0`,左栏新增选中 `角色C x=12 y=594 w=205 h=32`、文本 `x=52 y=604`,右栏切为 `角色`,名称输入 `value=角色C x=1248 y=217 w=248 h=28`,位置/旋转均为 `0`,缩放为 `1.03`,统一缩放 `1.0`,颜色 `#34C759`
- 竞品导演台全景图菜单复测: 在导演台底部点击 `全景图 x=672 y=740.5 w=36 h=36` 后不弹 toast、不新增主画布节点/连线,而是在按钮上方打开 3 项菜单;菜单外壳 `x=616 y=618.5 w=148 h=114`、背景 `rgba(31,31,31,0.72)`、圆角 `12px`、z-index `300`,按钮均为 `x=621 w=138 h=32`,顺序和 y 坐标为 `本地上传 623.5 / 历史记录 659.5 / AI生成 695.5`
- 竞品导演台全景图历史记录与 AI生成复测: 点击全景图菜单 `历史记录` 后菜单关闭,打开既有 `历史资产` 大弹窗而非 `选择图片`,外壳 `x=75.6 y=80 w=1360.8 h=645`、背景 `rgb(38,38,38)`、圆角 `16px`,包含 `历史资产 / 100% / 图片历史(8) / 视频历史(10) / 音频历史(6) / 时间降序 / 批量操作 / 2026-07-09 / 查看 / 使用 / 下载`;点击 `AI生成` 后打开 `640 × 448` 小弹窗,外壳 `x=436 y=178.5 w=640 h=448`、遮罩 `black/35`,标题 `AI生成`,上传区 `x=453 y=241.5 w=606 h=320`,底部文案 `退出不会中断生成,可在历史记录中查看结果`,消耗 `0`,生成按钮 `x=1031 y=579.5 w=28 h=28`
- 本地导演台全景图最终对齐: 原先本地 `全景图` 只弹 `全景图待接入。` toast;现改为竞品式三项菜单,Chrome 复测菜单外壳 `x=616 y=618.5 w=148 h=114`,按钮 `本地上传/历史记录/AI生成` 坐标 `x=621 y=623.5/659.5/695.5 w=138 h=32`,无旧 toast。点击 `历史记录` 后本地打开 `历史资产` 面板 `x=75.6 y=80 w=1360.8 h=645`,含三类历史 tab、排序、批量操作和 `查看/使用/下载`;点击 `AI生成` 后本地弹窗 `x=436 y=178.5 w=640 h=448`,上传区 `x=453 y=241.5 w=606 h=320`,footer `x=437 y=577.5 w=638 h=48`,生成按钮 `x=1031 y=579.5 w=28 h=28`
- 竞品添加音频节点复测: 先点空白清掉视频节点选中态,再点击 `添加节点 -> 音频`;节点数 `12 -> 13`、边数保持 `10`、viewport 保持 `translate(-3842.91px,487.096px) scale(0.743079)`、无 toast;新增并选中 `react-flow__node-audio`,世界坐标仍是 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,说明基础音频节点也是 `350 × 350`;节点正文为 `音频节点 5 / 尝试: 音频生视频`
- 竞品音频节点语音面板复测: 选中音频节点后,节点下方出现 `660 × 189` 浮层,外壳 `x=558.3 y=678.8`;可见内容依次为 `<#> 停顿`、`() 语气词`、输入区 placeholder `输入要合成的文本`、底部参数 `Minimax-speech-2.8-hd / 少女音色 / 0/50000 / 1`;面板内还包含折叠/裁切的高级项文本 `基础调节 / 一键重置 / 语速 / 声调 / 音量 / 音色效果调节 / 音高 / 强度 / 音色调节 / 音效 / 无 / 空旷回音 / 礼堂广播 / 电话失真 / 电音`
- 本地添加音频节点最终对齐: 原先底部菜单创建音频会生成 `436 × 470` 节点,并按普通模块偏移落到屏幕右上 `x=1243 y=-89`;现基础 `音频` 节点改为 `350 × 350`,和文本/图片/视频一样按当前 viewport 中心左上角落点,无 toast、无新增边,音频卡片高度压到 350 内。Chrome 复测空画布 100% 下新增 `音频节点 1` 屏幕框 `x=756 y=403 w=350 h=350`,正文为 `尝试: 音频生视频`,选中后出现 `660 × 189` 语音合成浮层,文本为 `<#> 停顿 () 语气词 输入要合成的文本 Minimax-speech-2.8-hd 少女音色 0/50000 1 基础调节 一键重置 语速 声调 音量 音色效果调节 音高 强度 音色调节 音效 无 空旷回音 礼堂广播 电话失真 电音`
- 竞品上传入口权限复测: 在当前竞品画布打开 `添加节点 -> 上传` 后 file chooser `multiple=true`;使用无敏感测试图 `/tmp/codex-video-canvas-upload.png` 调用 `setFiles` 仍返回 `Not allowed / fileChooser.setFiles failed`,节点数保持 `93`、边数保持 `106`、图片节点保持 `33`、选中 `文本节点 2` 不变、添加菜单不关闭且无 toast。按 Chrome 插件文档,继续真实上传落节点前需在 `chrome://extensions -> Codex -> Details` 开启 `Allow access to file URLs`
- 竞品音频语音面板深测: 在添加菜单中再次点击 `音频`,节点数 `93 -> 94`、音频节点数 `6 -> 7`、边数保持 `106`,新增并选中 `音频节点 13`;因竞品当前 viewport 为 `10%`,节点屏幕框为 `x=756.3 y=402.8 w=35 h=35`,语音面板外壳 `x=443.8 y=439.4 w=660 h=189`。面板右上有 `28 × 28` 展开按钮,底栏为 `Minimax-speech-2.8-hd x=452.3 w=200.7`、`少女音色 x=756.9 w=110`、翻译图标 `x=874.9 w=32`、设置图标 `x=918.9 w=32`、`0/50000`、闪电费用 `1`、白色圆形生成按钮 `x=1063.3 w=32`。点击设置图标后面板高度变 `565.2`,设置按钮 active,展开 `基础调节 / 一键重置 / 语速 / 声调 / 音量` 三条滑杆和 `音色效果调节 / 音高 / 强度 / 音色调节 / 音效`;点击右上展开后打开 `black/50` 遮罩和居中 `800 × 600` modal `x=356 y=102.5`,modal 保留同一套参数控件
- 竞品音频模型菜单深测: 在音频展开 modal 的设置态点击 `Minimax-speech-2.8-hd`,打开局部模型下拉 `x=364.5 y=317.8 w=369 h=341`,背景约 `#333/95`、z-index `700`;选项顺序为 `Seed Audio 1.0`(多模态音频生成,支持人声、音效、音乐一体化创作)、`Minimax-speech-2.8-hd`(当前选中,文字转语音,多元的情绪渲染)、`Minimax-speech-2.8-turbo`(文字转语音,快速生成自然的音频效果)、`Eleven V3`(文字转语音,提供高质量、可定制的语音)、`Eleven Music V3`(智能音乐生成,高质量音乐生成)、`Mureka V8`(智能音乐生成,兼具多元风格)
- 竞品音色选择弹窗深测: 点击底栏 `少女音色` 后打开高层 `音色选择` modal,外壳 `x=356 y=94.7 w=800 h=615.6`,覆盖在音频 modal 上;顶部为标题和关闭按钮,tab 为 `音色库 / 我的音色 / 收藏音色`,右侧包含 `克隆新音色`、搜索框 placeholder `搜索音色库` 和 `筛选`。列表行高约 `64px`,前几项为 `青涩青年音色 / 精英青年音色 / 霸道青年音色 / 青年大学生音色 / 少女音色 / 御姐音色 / 成熟女性音色 / 甜美女性音色`,副信息为 `中文(普通话)` 与性别,`少女音色` 显示 `已选`,其它显示 `选择`,行尾有收藏星标和 `...`;分页 footer 为 `1 2 3 4 5 ... 17 › 20条/页 跳至 页 共327条`
- 本地音频语音面板二次对齐: 原先本地只在 `660 × 189` 浮层中显示模型/音色/计数,右上没有展开按钮,底栏缺少翻译/设置/闪电/生成图标,高级参数只是 `h-0 overflow-hidden` 隐藏文本;现补齐竞品式右上展开、底栏图标和设置展开。Chrome 复测本地空画布 `添加节点 -> 音频` 后节点 `350 × 350` 落于 `x=756 y=403`,100% 视口下浮层按节点底部出现在 `x=601 y=765 w=660 h=189`;点击右上展开打开居中 `800 × 600` modal `x=356 y=102.5`,点击设置图标后 modal 顶部编辑区压为 `222px`,底部显示 `基础调节/一键重置/语速/声调/音量/音色效果调节/音高/强度/音色调节/音效/无/空旷回音/礼堂广播/电话失真/电音`,坐标和层级与竞品展开态一致
- 本地音频模型/音色菜单最终对齐: 原先本地没有模型下拉和音色库弹窗;现模型按钮补齐同款 6 项菜单、说明文案、当前选中态和 `369 × 342` 外壳,音色按钮补齐 `800 × 616` 的 `音色选择` modal、三 tab、克隆/搜索/筛选、音色列表、已选态和分页 footer。Chrome 复测本地展开态中点击模型按钮可见 `Seed Audio 1.0 / Minimax-speech-2.8-hd / Minimax-speech-2.8-turbo / Eleven V3 / Eleven Music V3 / Mureka V8`,点击 `少女音色` 后弹窗外壳 `x=356 y=94.5 w=800 h=616`,内容包含 `音色库 / 我的音色 / 收藏音色 / 克隆新音色 / 搜索音色库 / 筛选 / 青涩青年音色 / 少女音色 / 已选 / 20条/页 / 共 327 条`
- 竞品添加脚本菜单复测: 点击 `添加节点 -> 脚本` 主行不会落节点,节点数保持 `13`、边数保持 `10`、无 toast;主行保持高亮并展开右侧二级菜单,子菜单外壳 `x=720.5 y=551.5 w=204 h=85`,内部 `x=728.5 y=551.5 w=196 h=85`,两行分别是 `脚本 NEW x=737 y=560 w=179 h=32`、`脚本(旧版) Beta x=737 y=596 w=179 h=32`
- 竞品添加脚本 NEW 节点复测: 点击二级菜单 `脚本 NEW` 后,节点数 `13 -> 14`、边数保持 `10`、viewport 保持 `translate(-3842.91px,487.096px) scale(0.743079)`、无 toast;新增并选中 `react-flow__node-script-v2`,世界坐标仍是 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,说明现代脚本节点也是 `350 × 350`;节点标题为 `脚本生成器`,正文为 `尝试: 剧本生成分镜脚本 角色生成分镜脚本 自己编写分镜脚本`
- 本地添加脚本 NEW 节点最终对齐: 原先点击脚本主行会直接创建旧版 `脚本 1`,尺寸 `438 × 470`,落点 `x=1093 y=421`,按钮为 `剧本生成分镜脚本 / 视频参考生成分镜脚本 / 角色生成分镜脚本`;现脚本主行只展开同款二级菜单,不新增节点、不弹 toast;点击 `脚本 NEW` 后新增 `350 × 350` 的 `脚本生成器`,按 viewport 中心左上角落点,无新增边、无 toast,正文改为 `尝试: 剧本生成分镜脚本 角色生成分镜脚本 自己编写分镜脚本`。Chrome 复测空画布 100% 下节点框 `x=756 y=403 w=350 h=350`
- 竞品添加脚本(旧版)节点复测: 在已新增 `脚本 NEW` 后继续点击二级菜单 `脚本(旧版) Beta`,节点数 `14 -> 15`、边数保持 `10`、viewport 保持 `translate(-3842.91px,487.096px) scale(0.743079)`、无 toast;新增并选中 `react-flow__node-script`,世界坐标同样是 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`,说明旧版脚本节点也是 `350 × 350`;标题同为 `脚本生成器`,正文为 `尝试: 剧本生成分镜脚本 视频参考生成分镜脚本 角色生成分镜脚本`
- 本地添加脚本(旧版)节点最终对齐: 原先点击旧版入口会生成 `438 × 470` 的 `脚本 1`,落点 `x=1093 y=421`,且按钮组被 NEW 节点逻辑误渲染为 `剧本生成分镜脚本 / 角色生成分镜脚本 / 自己编写分镜脚本`;现旧版入口生成 `350 × 350` 的 `脚本生成器`,按 viewport 中心左上角落点,无新增边、无 toast,正文为 `尝试: 剧本生成分镜脚本 视频参考生成分镜脚本 角色生成分镜脚本`。Chrome 复测空画布 100% 下节点框 `x=756 y=403 w=350 h=350`
- 竞品添加资源-从生成历史选择复测: 点击 `添加节点 -> 从生成历史选择` 后不落节点、不弹 toast,节点数保持 `15`、边数保持 `10`,打开 `选择图片` 模态框,外壳 `x=75.6 y=80 w=1360.8 h=645`;一级 tab 为 `LibTV / Lib生成器 / WebUI / ComfyUI / AI应用`,右侧计数 `已选 0/10 张`;二级 tab 为 `图片 / 视频 / 音频`;图片卡为 6 列,首卡选中后左上出现蓝色勾,右下出现删除/预览图标,计数变 `1/10`,点击 `确定` 后模态框关闭、节点数 `15 -> 16`、边数保持 `10`、viewport 不变、无 toast;新增 `react-flow__node-image`,标题 `图片节点 8`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=260.1`
- 本地添加资源-从生成历史选择最终对齐: 原先该入口打开 `历史资产` 管理面板,显示 `图片历史/视频历史/音频历史` 和 `查看/使用/下载`,单项 `使用` 会弹 `已应用到画布` toast;现添加入口改为竞品同款 `选择图片` 多选模态框,外壳 `x=75.6 y=80 w=1360.8 h=645`,一级/二级 tab、`已选 0/10 张`、6 列卡片、选中蓝勾、右下图标、底部分页和 `确定` 行为对齐。Chrome 复测空画布选第一张后计数 `1/10`,点击 `确定` 静默生成 `350 × 350` 的 `图片节点 1`,节点框 `x=756 y=403 w=350 h=350`,无新增边、无 toast
- 竞品添加资源-历史选择视频 tab 复测: 在同一模态点击 `视频` 后标题变 `选择视频`,计数仍为 `已选 0/10 张`;视频卡共 10 张,6 列布局,首行 `x=100.6/320.7/540.9/761/981.1/1201.3 y=242 w=211.1 h=276`,次行前 4 张 `y=527`;首卡选中后计数 `已选 1/10 张`,点击 `确定` 后模态关闭,节点数 `16 -> 17`、边数保持 `10`、viewport 不变、无 toast、无选中态;新增 `react-flow__node-video`,标题 `视频节点 9`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=462.2 h=260.1`,反推资源视频节点 `622 × 350`
- 本地添加资源-历史选择视频 tab 最终对齐: 原先视频 tab 只有 1 张默认视频且计数写作 `已选 0/10 个`;现默认历史视频补齐为 10 张,计数统一为 `张`,布局为首行 6 张、次行 4 张。Chrome 复测本地空画布为 `选择视频 / 已选 0/10 张`,卡片坐标 `x=99.6/319.7/539.9/760/980.1/1200.3 y=240`、次行 `y=524`;首卡确认后静默生成 `622 × 350` 的 `视频节点 1`,节点框 `x=756 y=403 w=622 h=350`,无新增边、无 toast
- 竞品添加资源-历史选择音频 tab 复测: 在同一模态点击 `音频` 后标题变 `选择音频`,计数仍为 `已选 0/10 张`;音频卡共 6 张,均为灰色底居中音符图标,无标题文字,首行坐标 `x=100.6/320.7/540.9/761/981.1/1201.3 y=242 w=211.1 h=276`;首卡选中后计数 `已选 1/10 张`,点击 `确定` 后模态关闭,节点数 `17 -> 18`、边数保持 `10`、viewport 不变、无 toast、无选中态;新增 `react-flow__node-audio`,标题 `音频节点 10`,世界坐标 `translate(6192px,-108px)`,屏幕框约 `x=758.2 y=406.8 w=260.1 h=110`,反推历史音频资源节点约 `350 × 148`
- 本地添加资源-历史选择音频 tab 最终对齐: 原先本地无默认音频历史素材,无法按竞品路径落音频资源节点;现默认音频历史补齐为 6 张灰色音符卡,计数统一为 `张`。Chrome 复测本地空画布为 `选择音频 / 已选 0/10 张`,卡片坐标 `x=99.6/319.7/539.9/760/980.1/1200.3 y=240 w=212.1 h=276`;首卡确认后静默生成 `350 × 148` 的 `音频节点 1`,节点框 `x=756 y=403 w=350 h=148`,无新增边、无 toast、无选中态
## 当前差距
- 工具箱小面板、模板 hover 和 `使用` 落点主流程已像素级对齐;剩余差距是模板封面仍复用本地演示素材,缺少竞品真实模板市场封面、收藏/最近使用和后端模板库统计
- 角色库主流程、筛选菜单、最近使用空态和应用 4 节点落点已对齐;剩余差距是本地仍使用 demo 角色素材,缺少竞品同量级真实角色资产库、真实最近使用记录和后端资产元数据
- 素材库入口、风格库/特效库大浮层、搜索回车刷新、tab 空态、风格卡落节点和特效卡直接关闭行为已按本轮竞品实测对齐;剩余差距是本地仍使用演示缩略图和静态 mock 市场数据,缺少竞品真实素材市场封面、收藏状态、最近使用记录、搜索后端和风格/特效素材元数据
- 历史记录主交互、单项查看全屏预览、列表单项下载水印规则、批量选择态、批量下载 pending 态和批量使用已具备,但数据源仍是当前画布图片/视频/音频节点聚合;竞品历史记录来自账号生成资产,后续需接入真实生成资产表、跨画布历史、下载文件命名、批量下载打包完成态和批量删除后端接口
- 生成失败态: 本轮竞品当前项目和历史资产中没有真实失败任务样本,不能确认竞品 failed 节点是否使用 `移除`、`重试` 或其他操作;本地已验证接口失败时的 failed 节点、toast 和移除行为,后续拿到竞品真实失败样本后再做像素级对齐
- 竞品已覆盖但本地仍需继续细测: 资源上传入口和 file chooser 行为已确认,但真实上传落节点被 Chrome 扩展 `Allow access to file URLs` 权限阻塞,需开启后继续;`人声分离 -> 仅保留人声/背景音` 已对齐菜单层级、处理中状态、toast、双节点落点、节点顺序和连线;`智能擦除` 已对齐成本、落节点、连线、pending 文案、取消确认和本地完成态;`框选擦除` 已完成浏览器端到端前端复测并对齐选区、历史、费用、生成 pending/完成态和参数写入逻辑;真实 `volcano-subtitle-eraser` 后端任务仍需接入;`裁剪` 已对齐前端裁剪态、进度态和落节点结果,但真实 FFmpeg/云存储上传服务仍需接入;`高清` 已对齐节点式创建和本地生成态,但真实后端高清服务仍需接入;本轮未发现竞品选中线重连手柄,暂不将重连作为已确认竞品能力
- 故事脚本数量、字段、节点内表格、选中工具栏、全屏三步工作流、资产准备页、单镜 `查看提示词` 弹窗、批量分镜和批量生视频前端任务编排已按竞品当前项目对齐;剩余差距是真实模型生成质量、真实资产生成/覆盖逻辑、资产图片后端落库、真实 Seedance 视频任务提交/轮询/扣费和视频结果持久化

View File

@ -12,12 +12,19 @@ export type DetailKimiGenerateOptions = {
}; };
const KIMI_K26_PATTERN = /^kimi[-_.]?k2\.6(?:\b|[-_.])/i; const KIMI_K26_PATTERN = /^kimi[-_.]?k2\.6(?:\b|[-_.])/i;
const MOONSHOT_BASE_URL_PATTERN = /moonshot\.(?:cn|ai)|kimi\.com/i; const KIMI_DETAIL_MODEL_PATTERN =
/^(?:kimi[-_.]?k2\.(?:6|7)(?:\b|[-_.])|kimi-for-coding$)/i;
const MOONSHOT_BASE_URL_PATTERN =
/moonshot\.(?:cn|ai)|api\.kimi\.com\/coding|kimi\.com/i;
export function isKimiK26ModelId(value: unknown) { export function isKimiK26ModelId(value: unknown) {
return KIMI_K26_PATTERN.test(String(value || "").trim()); return KIMI_K26_PATTERN.test(String(value || "").trim());
} }
export function isKimiDetailModelId(value: unknown) {
return KIMI_DETAIL_MODEL_PATTERN.test(String(value || "").trim());
}
export function isMoonshotOfficialBaseUrl(value: unknown) { export function isMoonshotOfficialBaseUrl(value: unknown) {
return MOONSHOT_BASE_URL_PATTERN.test(String(value || "").trim()); return MOONSHOT_BASE_URL_PATTERN.test(String(value || "").trim());
} }
@ -55,7 +62,7 @@ export function shouldUseKimiDetailProfile(params: {
providerBaseUrl?: string | null; providerBaseUrl?: string | null;
isLocalMoonshot?: boolean; isLocalMoonshot?: boolean;
}) { }) {
if (!isKimiK26ModelId(params.modelId)) return false; if (!isKimiDetailModelId(params.modelId)) return false;
return Boolean(params.isLocalMoonshot) || isMoonshotOfficialBaseUrl(params.providerBaseUrl); return Boolean(params.isLocalMoonshot) || isMoonshotOfficialBaseUrl(params.providerBaseUrl);
} }

View File

@ -4,9 +4,17 @@ export const DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY =
export const DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID = export const DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID =
"__moonshot_kimi_k25_local__"; "__moonshot_kimi_k25_local__";
export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL = export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL =
"https://api.moonshot.cn/v1"; "https://api.kimi.com/coding/v1";
export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-k2.6"; export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-for-coding";
export const DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME = "官方 Kimi K2.6"; export const DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME =
"Kimi Code 编程套餐";
const LEGACY_MOONSHOT_BASE_URLS = new Set([
"https://api.moonshot.cn/v1",
"https://api.moonshot.ai/v1",
"https://api.moonshot.cn/v1/chat/completions",
"https://api.moonshot.ai/v1/chat/completions",
]);
export type DetailAgentLocalMoonshotConfig = { export type DetailAgentLocalMoonshotConfig = {
enabled: boolean; enabled: boolean;
@ -23,15 +31,47 @@ export function isDetailAgentLocalMoonshotOption(value: unknown) {
function normalizeMoonshotModelId(value: unknown) { function normalizeMoonshotModelId(value: unknown) {
const modelId = String(value || "").trim(); const modelId = String(value || "").trim();
if (!modelId || modelId === "kimi-k2.5") { if (
!modelId ||
modelId === "kimi-k2.5" ||
modelId === "kimi-k2.6" ||
modelId === "kimi-k2.7-code" ||
modelId === "kimi-k2.7-code-highspeed"
) {
return DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID; return DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
} }
return modelId; return modelId;
} }
export function normalizeDetailAgentLocalMoonshotBaseUrl(value: unknown) {
const clean = String(value || "")
.trim()
.replace(/\/+$/, "");
if (!clean || LEGACY_MOONSHOT_BASE_URLS.has(clean)) {
return DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
}
if (clean === `${DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}/chat/completions`) {
return DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
}
return clean;
}
export function isDetailAgentLocalMoonshotBaseUrl(value: unknown) {
return (
normalizeDetailAgentLocalMoonshotBaseUrl(value) ===
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL
);
}
function normalizeMoonshotDisplayName(value: unknown) { function normalizeMoonshotDisplayName(value: unknown) {
const displayName = String(value || "").trim(); const displayName = String(value || "").trim();
if (!displayName || displayName === "官方 Kimi K2.5") { if (
!displayName ||
displayName === "官方 Kimi K2.5" ||
displayName === "官方 Kimi K2.6" ||
displayName === "官方 Kimi K2.7 Code Highspeed" ||
displayName === "Kimi Code 编程套餐 K2.7 Highspeed"
) {
return DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME; return DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
} }
return displayName; return displayName;
@ -48,9 +88,8 @@ export function normalizeDetailAgentLocalMoonshotConfig(
return { return {
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
providerName: providerName:
String(raw.providerName || "").trim() || "Moonshot AI (Official)", String(raw.providerName || "").trim() || "Kimi Code 编程套餐",
baseUrl: baseUrl: normalizeDetailAgentLocalMoonshotBaseUrl(raw.baseUrl),
String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
apiKey: String(raw.apiKey || "").trim(), apiKey: String(raw.apiKey || "").trim(),
modelId: normalizeMoonshotModelId(raw.modelId), modelId: normalizeMoonshotModelId(raw.modelId),
displayName: normalizeMoonshotDisplayName(raw.displayName), displayName: normalizeMoonshotDisplayName(raw.displayName),

View File

@ -600,7 +600,16 @@ function summarizeFalError(error: any) {
error?.message || error?.message ||
(typeof body === "string" ? body : "") || (typeof body === "string" ? body : "") ||
"FAL request failed"; "FAL request failed";
const cause = error?.cause;
return { return {
cause:
cause && typeof cause === "object"
? {
code: (cause as { code?: unknown }).code,
message: (cause as { message?: unknown }).message,
name: (cause as { name?: unknown }).name,
}
: cause,
name: error?.name, name: error?.name,
status, status,
message, message,

View File

@ -43,6 +43,7 @@ async function getQwenImageProviderApiKey() {
export async function callQwen3Vl(params: { export async function callQwen3Vl(params: {
imageUrl?: string; imageUrl?: string;
imageUrls?: string[]; imageUrls?: string[];
model?: string;
prompt: string; prompt: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
@ -64,9 +65,17 @@ export async function callQwen3Vl(params: {
throw new Error("Qwen3-VL 调用缺少图片输入"); throw new Error("Qwen3-VL 调用缺少图片输入");
} }
const preferredModel = String(params.model || "").trim();
const models = Array.from(
new Set([
...(/^qwen.*(?:vl|vision)/i.test(preferredModel) ? [preferredModel] : []),
...QWEN3_VL_MODELS,
])
);
let lastError: Error | null = null; let lastError: Error | null = null;
for (const model of QWEN3_VL_MODELS) { for (const model of models) {
for (let attempt = 0; attempt <= retries; attempt += 1) { for (let attempt = 0; attempt <= retries; attempt += 1) {
try { try {
const controller = new AbortController(); const controller = new AbortController();

View File

@ -90,6 +90,29 @@ function withConfiguredVideoCapabilities(
}; };
} }
function isAgnesVideoModel(item: any) {
const capabilities = item?.model?.capabilities;
const provider = capabilities && typeof capabilities === "object"
? String((capabilities as Record<string, unknown>).provider || "")
: "";
const kind = capabilities && typeof capabilities === "object"
? String((capabilities as Record<string, unknown>).kind || "")
: "";
const modelId = String(item?.model?.modelId || "").toLowerCase();
return (
item?.provider?.type === "custom" &&
(provider === "agnes-ai" || (kind === "video" && modelId.startsWith("agnes-video")))
);
}
function isVolcanoVideoModel(item: any) {
return item?.provider?.type === "volcano-video" || item?.provider?.type === "doubao-video";
}
function isDirectExecutableVideoModel(item: any) {
return isVolcanoVideoModel(item) || isAgnesVideoModel(item);
}
/** /**
* 🆕 智能选择最佳子模型 * 🆕 智能选择最佳子模型
* 根据用户选择的模式和参数,从子模型列表中选择最合适的模型 * 根据用户选择的模式和参数,从子模型列表中选择最合适的模型
@ -107,7 +130,7 @@ async function selectBestSubModel(
const directModel = allModels.find( const directModel = allModels.find(
(item) => (item) =>
item.model.id === parentModelId && item.model.id === parentModelId &&
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video"), isDirectExecutableVideoModel(item),
); );
if (!directModel) { if (!directModel) {
@ -199,13 +222,13 @@ async function getCompatibleModel(
const volcanoVideoModels = models.filter( const volcanoVideoModels = models.filter(
(m) => (m) =>
(m.provider.type === "volcano-video" || m.provider.type === "doubao-video") && isDirectExecutableVideoModel(m) &&
m.model.isEnabled && m.model.isEnabled &&
m.model.capabilities m.model.capabilities
); );
if (volcanoVideoModels.length === 0) { if (volcanoVideoModels.length === 0) {
throw new Error("没有启用的火山引擎视频模型"); throw new Error("没有启用的可执行视频模型");
} }
const compatibleModels = volcanoVideoModels.filter((m) => { const compatibleModels = volcanoVideoModels.filter((m) => {
@ -339,7 +362,7 @@ async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
const models = await getEnabledAiModelsWithProvider(); const models = await getEnabledAiModelsWithProvider();
const matched = models.find( const matched = models.find(
(item) => (item) =>
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") && isDirectExecutableVideoModel(item) &&
(item.model.id === normalizedRef || item.model.modelId === normalizedRef), (item.model.id === normalizedRef || item.model.modelId === normalizedRef),
); );
@ -350,6 +373,153 @@ async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
return matched.model.id; return matched.model.id;
} }
function resolveAgnesVideoSize(
resolution: VideoGenerationOptions["resolution"],
ratio: VideoGenerationOptions["ratio"],
) {
const longSideByResolution: Record<string, number> = {
"480p": 854,
"720p": 1280,
"1080p": 1920,
};
const longSide = longSideByResolution[resolution || "720p"] || 1280;
const ratios: Record<string, [number, number]> = {
"16:9": [16, 9],
"4:3": [4, 3],
"1:1": [1, 1],
"3:4": [3, 4],
"9:16": [9, 16],
"21:9": [21, 9],
adaptive: [16, 9],
};
const [rw, rh] = ratios[ratio || "16:9"] || ratios["16:9"];
const landscape = rw >= rh;
const width = landscape ? longSide : Math.round((longSide * rw) / rh);
const height = landscape ? Math.round((longSide * rh) / rw) : longSide;
return {
width: Math.max(256, Math.round(width / 8) * 8),
height: Math.max(256, Math.round(height / 8) * 8),
};
}
function resolveAgnesFrameCount(duration?: number) {
const seconds = Math.max(1, Math.min(Number(duration) || 5, 18));
const rawFrames = Math.min(441, Math.max(9, Math.round(seconds * 24)));
return Math.min(441, Math.max(9, Math.floor((rawFrames - 1) / 8) * 8 + 1));
}
function buildAgnesResultUrl(baseUrl: string, videoId: string, modelId: string) {
const normalizedBase = String(baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
const root = normalizedBase.replace(/\/v1$/i, "");
const url = new URL(`${root}/agnesapi`);
url.searchParams.set("video_id", videoId);
url.searchParams.set("model_name", modelId);
return url.toString();
}
async function executeAgnesVideoGeneration(params: {
apiKey: string;
baseUrl: string;
options: VideoGenerationOptions;
}) {
const baseUrl = String(params.baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
const createUrl = `${baseUrl}/videos`;
const { width, height } = resolveAgnesVideoSize(params.options.resolution, params.options.ratio);
const body: Record<string, unknown> = {
model: params.options.model,
prompt: params.options.prompt,
width,
height,
num_frames: resolveAgnesFrameCount(params.options.duration),
frame_rate: 24,
};
if (params.options.seed !== undefined) body.seed = params.options.seed;
if (params.options.firstFrameImage && params.options.lastFrameImage) {
body.extra_body = {
image: [params.options.firstFrameImage, params.options.lastFrameImage],
mode: "keyframes",
};
} else if (params.options.referenceImages?.length) {
body.extra_body = {
image: params.options.referenceImages.slice(0, 4),
mode: "keyframes",
};
} else if (params.options.firstFrameImage) {
body.image = params.options.firstFrameImage;
body.mode = "ti2vid";
}
const createResponse = await fetch(createUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const createText = await createResponse.text();
let createData: Record<string, any> = {};
try {
createData = createText ? JSON.parse(createText) : {};
} catch {
createData = { raw: createText };
}
if (!createResponse.ok) {
throw new Error(
`Agnes 视频任务创建失败: ${createData?.error?.message || createData?.message || createResponse.statusText}`,
);
}
const videoId = String(createData.video_id || createData.videoId || createData.id || createData.task_id || "").trim();
const taskId = String(createData.task_id || createData.id || "").trim();
if (!videoId && !taskId) {
throw new Error("Agnes 视频任务创建成功但没有返回 video_id/task_id");
}
const startedAt = Date.now();
const timeoutMs = 285_000;
let lastData = createData;
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const resultUrl = videoId
? buildAgnesResultUrl(baseUrl, videoId, params.options.model)
: `${baseUrl}/videos/${encodeURIComponent(taskId)}`;
const resultResponse = await fetch(resultUrl, {
headers: { Authorization: `Bearer ${params.apiKey}` },
});
const resultText = await resultResponse.text();
let resultData: Record<string, any> = {};
try {
resultData = resultText ? JSON.parse(resultText) : {};
} catch {
resultData = { raw: resultText };
}
if (!resultResponse.ok) {
throw new Error(
`Agnes 视频结果查询失败: ${resultData?.error?.message || resultData?.message || resultResponse.statusText}`,
);
}
lastData = resultData;
const status = String(resultData.status || "").toLowerCase();
if (status === "completed" && resultData.url) {
return {
url: String(resultData.url),
metadata: {
...resultData,
createTask: createData,
},
};
}
if (status === "failed") {
throw new Error(`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`);
}
}
throw new Error(
`Agnes 视频生成超时: ${String(lastData.status || "unknown")}`,
);
}
export async function executeDirectVideoGeneration( export async function executeDirectVideoGeneration(
params: ExecuteDirectVideoGenerationParams, params: ExecuteDirectVideoGenerationParams,
): Promise<ExecuteDirectVideoGenerationResult> { ): Promise<ExecuteDirectVideoGenerationResult> {
@ -365,13 +535,13 @@ export async function executeDirectVideoGeneration(
const fallbackParentModel = const fallbackParentModel =
allModels.find( allModels.find(
(item) => (item) =>
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") && isVolcanoVideoModel(item) &&
!item.model.isSubModel && !item.model.isSubModel &&
item.model.modelId === "volcano-video-master", item.model.modelId === "volcano-video-master",
) || ) ||
allModels.find( allModels.find(
(item) => (item) =>
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") && isDirectExecutableVideoModel(item) &&
!item.model.isSubModel, !item.model.isSubModel,
); );
@ -380,7 +550,7 @@ export async function executeDirectVideoGeneration(
fallbackParentModel?.model.id || fallbackParentModel?.model.id ||
allModels.find( allModels.find(
(item) => (item) =>
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") && isDirectExecutableVideoModel(item) &&
item.model.isEnabled && item.model.isEnabled &&
item.model.isSubModel && item.model.isSubModel &&
item.model.parentModelId, item.model.parentModelId,
@ -408,6 +578,12 @@ export async function executeDirectVideoGeneration(
); );
} catch (preferredModelError) { } catch (preferredModelError) {
if (parentModelId) { if (parentModelId) {
if (
preferredModelError instanceof Error &&
/不支持|没有模型支持/.test(preferredModelError.message)
) {
throw preferredModelError;
}
console.warn( console.warn(
"[Generate Video Tool] Preferred parent model selection failed, fallback to global compatible model:", "[Generate Video Tool] Preferred parent model selection failed, fallback to global compatible model:",
preferredModelError, preferredModelError,
@ -435,14 +611,26 @@ export async function executeDirectVideoGeneration(
); );
let videoMetadata: Record<string, any> | null = null; let videoMetadata: Record<string, any> | null = null;
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) { if (isAgnesVideoModel(model)) {
await params.onProgress?.(chunk); const agnesResult = await executeAgnesVideoGeneration({
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/); apiKey: model.provider.apiKey,
if (metadataMatch) { baseUrl: model.provider.baseUrl,
try { options,
videoMetadata = JSON.parse(metadataMatch[1]); });
} catch (error) { videoMetadata = {
console.error("[Generate Video Tool] 解析元数据失败:", error); ...(agnesResult.metadata || {}),
temporaryVideoUrl: agnesResult.url,
};
} else {
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
await params.onProgress?.(chunk);
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/);
if (metadataMatch) {
try {
videoMetadata = JSON.parse(metadataMatch[1]);
} catch (error) {
console.error("[Generate Video Tool] 解析元数据失败:", error);
}
} }
} }
} }

View File

@ -1,7 +1,7 @@
const DEFAULT_MAX_RETRIES = 3; const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000; const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3; const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024; const DIRECT_UPLOAD_MIN_BYTES = 1;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4; const DIRECT_UPLOAD_PART_CONCURRENCY = 4;

View File

@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS "AccessIpEvent" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"eventType" varchar(32) DEFAULT 'request' NOT NULL,
"ip" varchar(64) NOT NULL,
"userId" uuid REFERENCES "User"("id") ON DELETE SET NULL,
"userEmail" varchar(128),
"userRole" varchar(32),
"method" varchar(12),
"path" varchar(256),
"routeGroup" varchar(64),
"statusCode" integer,
"outcome" varchar(32) DEFAULT 'allowed' NOT NULL,
"severity" varchar(16) DEFAULT 'info' NOT NULL,
"reasons" jsonb,
"userAgent" text,
"referer" text,
"metadata" jsonb,
"occurredAt" timestamp DEFAULT now() NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "AccessIpEvent_createdAt_idx"
ON "AccessIpEvent" ("createdAt" DESC);
CREATE INDEX IF NOT EXISTS "AccessIpEvent_ip_createdAt_idx"
ON "AccessIpEvent" ("ip", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS "AccessIpEvent_userId_createdAt_idx"
ON "AccessIpEvent" ("userId", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS "AccessIpEvent_severity_createdAt_idx"
ON "AccessIpEvent" ("severity", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS "AccessIpEvent_path_createdAt_idx"
ON "AccessIpEvent" ("path", "createdAt" DESC);

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "AccessIpBlocklist" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ip" varchar(128) NOT NULL,
"reason" text,
"isActive" boolean DEFAULT true NOT NULL,
"expiresAt" timestamp,
"createdBy" uuid REFERENCES "User"("id") ON DELETE SET NULL,
"updatedBy" uuid REFERENCES "User"("id") ON DELETE SET NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "AccessIpBlocklist_ip_unique_idx"
ON "AccessIpBlocklist" (lower("ip"));
CREATE INDEX IF NOT EXISTS "AccessIpBlocklist_active_idx"
ON "AccessIpBlocklist" ("isActive", "updatedAt" DESC);
CREATE INDEX IF NOT EXISTS "AccessIpBlocklist_expiresAt_idx"
ON "AccessIpBlocklist" ("expiresAt");

View File

@ -512,6 +512,20 @@
"when": 1781174400000, "when": 1781174400000,
"tag": "0086_external_image_log_model_delete_set_null", "tag": "0086_external_image_log_model_delete_set_null",
"breakpoints": true "breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1783417200000,
"tag": "0087_add_access_ip_events",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1783419000000,
"tag": "0088_add_access_ip_blocklist",
"breakpoints": true
} }
] ]
} }

View File

@ -4760,9 +4760,19 @@ export async function getFrontendDisplayAiModelsWithProvider(
export async function getAgentAiModelWithProvider() { export async function getAgentAiModelWithProvider() {
const models = await getEnabledAiModelsWithProvider(false); const models = await getEnabledAiModelsWithProvider(false);
const healthPriority = (status?: string | null) => {
if (status === "healthy") return 0;
if (status === "degraded") return 1;
if (status === "auto_disabled") return 2;
return 3;
};
const candidates = models const candidates = models
.filter(({ model }) => isAiModelAgentAuto(model)) .filter(({ model }) => isAiModelAgentAuto(model) && model.healthStatus !== "auto_disabled")
.sort((a, b) => { .sort((a, b) => {
const byHealth =
healthPriority(a.model.healthStatus) -
healthPriority(b.model.healthStatus);
if (byHealth !== 0) return byHealth;
if (a.model.isDefault !== b.model.isDefault) { if (a.model.isDefault !== b.model.isDefault) {
return Number(b.model.isDefault) - Number(a.model.isDefault); return Number(b.model.isDefault) - Number(a.model.isDefault);
} }

View File

@ -655,6 +655,43 @@ export type ClientPerformanceEvent = InferSelectModel<
typeof clientPerformanceEvent typeof clientPerformanceEvent
>; >;
export const accessIpEvent = pgTable("AccessIpEvent", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
eventType: varchar("eventType", { length: 32 }).notNull().default("request"),
ip: varchar("ip", { length: 64 }).notNull(),
userId: uuid("userId").references(() => user.id, { onDelete: "set null" }),
userEmail: varchar("userEmail", { length: 128 }),
userRole: varchar("userRole", { length: 32 }),
method: varchar("method", { length: 12 }),
path: varchar("path", { length: 256 }),
routeGroup: varchar("routeGroup", { length: 64 }),
statusCode: integer("statusCode"),
outcome: varchar("outcome", { length: 32 }).notNull().default("allowed"),
severity: varchar("severity", { length: 16 }).notNull().default("info"),
reasons: jsonb("reasons").$type<string[]>(),
userAgent: text("userAgent"),
referer: text("referer"),
metadata: jsonb("metadata"),
occurredAt: timestamp("occurredAt").notNull().defaultNow(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
});
export type AccessIpEvent = InferSelectModel<typeof accessIpEvent>;
export const accessIpBlocklist = pgTable("AccessIpBlocklist", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
ip: varchar("ip", { length: 128 }).notNull(),
reason: text("reason"),
isActive: boolean("isActive").notNull().default(true),
expiresAt: timestamp("expiresAt"),
createdBy: uuid("createdBy").references(() => user.id, { onDelete: "set null" }),
updatedBy: uuid("updatedBy").references(() => user.id, { onDelete: "set null" }),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AccessIpBlocklist = InferSelectModel<typeof accessIpBlocklist>;
// 风格化分类表 // 风格化分类表
export const agentPresetCategory = pgTable("AgentPresetCategory", { export const agentPresetCategory = pgTable("AgentPresetCategory", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),

200
lib/security/access-log.ts Normal file
View File

@ -0,0 +1,200 @@
import "server-only";
import { sql } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { accessIpEvent } from "@/lib/db/schema";
export type AccessLogSeverity = "info" | "watch" | "high" | "critical";
export type RecordAccessIpEventInput = {
eventType?: string | null;
ip?: string | null;
userId?: string | null;
userEmail?: string | null;
userRole?: string | null;
method?: string | null;
path?: string | null;
routeGroup?: string | null;
statusCode?: number | null;
outcome?: string | null;
severity?: AccessLogSeverity | string | null;
reasons?: string[] | null;
userAgent?: string | null;
referer?: string | null;
metadata?: Record<string, unknown> | null;
occurredAt?: Date | string | number | null;
};
const SEVERITIES = new Set(["info", "watch", "high", "critical"]);
const ACCESS_EVENT_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
let accessEventUnavailableUntil = 0;
let accessEventMissingTableWarned = false;
function getErrorCode(error: unknown) {
const directCode =
error && typeof error === "object"
? (error as { code?: unknown }).code
: null;
if (typeof directCode === "string") return directCode;
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
const causeCode =
cause && typeof cause === "object"
? (cause as { code?: unknown }).code
: null;
return typeof causeCode === "string" ? causeCode : null;
}
function getErrorMessage(error: unknown) {
const messages: string[] = [];
if (error instanceof Error) messages.push(error.message);
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
if (cause instanceof Error) messages.push(cause.message);
if (!messages.length) messages.push(String(error));
return messages.join("\n");
}
function isMissingAccessIpEventTableError(error: unknown) {
if (getErrorCode(error) === "42P01") return true;
const message = getErrorMessage(error);
return (
message.includes('relation "AccessIpEvent" does not exist') ||
(message.includes("AccessIpEvent") && message.includes("does not exist"))
);
}
function markAccessEventsUnavailable(error: unknown) {
accessEventUnavailableUntil = Date.now() + ACCESS_EVENT_UNAVAILABLE_CACHE_MS;
if (accessEventMissingTableWarned) return;
accessEventMissingTableWarned = true;
console.warn(
"[AccessIpEvent] table is unavailable; access logging will be skipped temporarily",
getErrorMessage(error)
);
}
function shouldSkipAccessEventWrite() {
return (
process.env.ACCESS_LOG_ENABLED === "false" ||
Date.now() < accessEventUnavailableUntil
);
}
function clampText(value: unknown, maxLength: number) {
const normalized = String(value || "").trim();
if (!normalized) return null;
return normalized.slice(0, maxLength);
}
function normalizeIp(value: unknown) {
const normalized = clampText(value, 128);
if (!normalized) return "unknown";
return normalized.split(",")[0]?.trim().slice(0, 64) || "unknown";
}
function normalizeSeverity(value: unknown): AccessLogSeverity {
const normalized = String(value || "").trim().toLowerCase();
return SEVERITIES.has(normalized)
? (normalized as AccessLogSeverity)
: "info";
}
function normalizeReasons(value: unknown) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => clampText(item, 80))
.filter((item): item is string => Boolean(item))
)
).slice(0, 8);
}
function normalizeOccurredAt(value: unknown) {
if (!value) return new Date();
const date =
value instanceof Date
? value
: typeof value === "number"
? new Date(value)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? new Date() : date;
}
async function maybePruneOldAccessEvents() {
if (Math.random() > 0.005) return;
await db.execute(sql`
DELETE FROM "AccessIpEvent"
WHERE "createdAt" < NOW() - INTERVAL '30 days'
`);
}
export async function recordAccessIpEvent(input: RecordAccessIpEventInput) {
if (shouldSkipAccessEventWrite()) return;
const ip = normalizeIp(input.ip);
await maybePruneOldAccessEvents().catch((error) => {
if (isMissingAccessIpEventTableError(error)) {
markAccessEventsUnavailable(error);
return;
}
console.warn("[AccessIpEvent] prune failed", error);
});
try {
await db.insert(accessIpEvent).values({
eventType: clampText(input.eventType, 32) || "request",
ip,
userId: clampText(input.userId, 64),
userEmail: clampText(input.userEmail, 128),
userRole: clampText(input.userRole, 32),
method: clampText(input.method, 12),
path: clampText(input.path, 256),
routeGroup: clampText(input.routeGroup, 64),
statusCode:
typeof input.statusCode === "number" && Number.isFinite(input.statusCode)
? Math.round(input.statusCode)
: null,
outcome: clampText(input.outcome, 32) || "allowed",
severity: normalizeSeverity(input.severity),
reasons: normalizeReasons(input.reasons),
userAgent: clampText(input.userAgent, 512),
referer: clampText(input.referer, 512),
metadata: input.metadata || null,
occurredAt: normalizeOccurredAt(input.occurredAt),
});
} catch (error) {
if (isMissingAccessIpEventTableError(error)) {
markAccessEventsUnavailable(error);
return;
}
throw error;
}
}
export async function recordAccessIpEventFromHeaders(
input: Omit<RecordAccessIpEventInput, "ip" | "userAgent" | "referer">
) {
const requestHeaders = await headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
const realIp = requestHeaders.get("x-real-ip");
await recordAccessIpEvent({
...input,
ip: forwardedFor || realIp,
userAgent: requestHeaders.get("user-agent"),
referer: requestHeaders.get("referer"),
});
}

View File

@ -0,0 +1,88 @@
import "server-only";
import { isIP } from "node:net";
export type ManualIpBlockEntry = {
id: string;
ip: string;
reason?: string | null;
expiresAt?: Date | string | null;
};
function ipv4ToNumber(value: string) {
const parts = value.split(".").map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
return null;
}
if (parts.some((part) => part < 0 || part > 255)) {
return null;
}
return (
((parts[0] << 24) >>> 0) +
((parts[1] << 16) >>> 0) +
((parts[2] << 8) >>> 0) +
(parts[3] >>> 0)
) >>> 0;
}
function normalizePlainIp(value: string) {
const normalized = value.trim().replace(/^\[/, "").replace(/\]$/, "");
return isIP(normalized) ? normalized : null;
}
function normalizeIpv4Cidr(value: string) {
const [rawIp, rawPrefix] = value.split("/");
const ip = normalizePlainIp(rawIp || "");
const prefix = Number.parseInt(rawPrefix || "", 10);
if (!ip || isIP(ip) !== 4) return null;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null;
return `${ip}/${prefix}`;
}
export function normalizeManualBlockIp(input: unknown) {
const normalized = String(input || "").trim();
if (!normalized) return null;
if (normalized.includes("/")) {
return normalizeIpv4Cidr(normalized);
}
return normalizePlainIp(normalized);
}
function ipv4MatchesCidr(ip: string, cidr: string) {
const [rangeIp, rawPrefix] = cidr.split("/");
const ipNumber = ipv4ToNumber(ip);
const rangeNumber = ipv4ToNumber(rangeIp || "");
const prefix = Number.parseInt(rawPrefix || "", 10);
if (ipNumber == null || rangeNumber == null) return false;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (ipNumber & mask) === (rangeNumber & mask);
}
export function matchManualIpBlock(
ip: string | null | undefined,
entries: ManualIpBlockEntry[]
) {
const normalizedIp = normalizePlainIp(String(ip || ""));
if (!normalizedIp) return null;
const ipVersion = isIP(normalizedIp);
return (
entries.find((entry) => {
const rule = normalizeManualBlockIp(entry.ip);
if (!rule) return false;
if (rule.includes("/")) {
return ipVersion === 4 && ipv4MatchesCidr(normalizedIp, rule);
}
return rule.toLowerCase() === normalizedIp.toLowerCase();
}) || null
);
}

View File

@ -220,8 +220,7 @@ async function uploadToS3(
Body: Buffer.from(buffer), Body: Buffer.from(buffer),
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
// 设置为公开可读(如果您的 MinIO 桶支持) ...(config.provider === "qiniu-kodo" ? {} : { ACL: "public-read" as const }),
ACL: "public-read",
}); });
await s3Client.send(command); await s3Client.send(command);

View File

@ -17,6 +17,7 @@ export type VideoCanvasBackendRoute =
| "enhance" | "enhance"
| "enhance-pro" | "enhance-pro"
| "extend" | "extend"
| "pick-object"
| "remove-bg" | "remove-bg"
| "layer-split" | "layer-split"
| "local"; | "local";
@ -38,11 +39,23 @@ export type VideoCanvasFunctionBillingCopy = {
description: string; description: string;
}; };
export type VideoCanvasEffectPreset = {
id: string;
title: string;
author: string;
usageCount: number;
model: string;
thumbnailUrl: string;
prompt: string;
commercial: boolean;
};
export type VideoCanvasFunctionConfig = { export type VideoCanvasFunctionConfig = {
enabled: boolean; enabled: boolean;
defaultImageModel: string; defaultImageModel: string;
defaultVideoModel: string; defaultVideoModel: string;
defaultMultimodalModel: string; defaultMultimodalModel: string;
effectPresets: VideoCanvasEffectPreset[];
functions: Record<string, VideoCanvasFunctionDefinition>; functions: Record<string, VideoCanvasFunctionDefinition>;
}; };
@ -237,6 +250,18 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
prompt: "分析参考图片,输出结构化中文提示词,包括主体、环境、构图、光影、镜头语言和风格关键词。", prompt: "分析参考图片,输出结构化中文提示词,包括主体、环境、构图、光影、镜头语言和风格关键词。",
description: "多模态识图生成提示词。", description: "多模态识图生成提示词。",
}, },
{
key: "element-mark",
label: "视频元素标记",
kind: "multimodal",
route: "pick-object",
enabled: true,
model: "qwen3-vl-plus",
prompt:
"图片中有一个红色圆形标记和十字线。请识别红色标记正中心所指向的视觉元素,并给出从宽泛主体到精确局部的可切换标签层级。若点中完整人物,首项用“人物”;若点中人物面部,首项用“人物面部”;若点中眼睛、手部等明确局部,可返回如 [\"人物\",\"眼睛\"] 的层级;商品或物体同理,先给完整主体,再给确实可见的局部。标签必须简短、客观、互不重复,每项不超过 8 个汉字,最多 4 项。严格返回纯 JSON:{\"name\":\"默认标签\",\"labels\":[\"默认标签\",\"更精确标签\"],\"position\":\"位置描述\",\"confidence\":0.95}。position 必须从以下选项中选择:{{positionOptions}}。不要输出 Markdown 或额外解释。",
description: "视频节点“标记”使用的点选元素识别规则;结果以可切换的结构化标签写入提示词。",
requiresBackend: true,
},
{ {
key: "text-to-video", key: "text-to-video",
label: "文生视频", label: "文生视频",
@ -276,11 +301,164 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
}, },
]; ];
export const defaultVideoCanvasEffectPresets: VideoCanvasEffectPreset[] = [
{
id: "bee-camera",
title: "小蜜蜂运镜",
author: "赛博蒲公英",
usageCount: 1100,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/bee-camera.webp",
prompt: "使用微型飞行器第一人称视角快速穿行场景,贴近主体掠过并保持连续、灵活的空间运动,形成小蜜蜂般轻盈迅捷的运镜。",
commercial: true,
},
{
id: "cloud-dive",
title: "穿云而入",
author: "数据星云",
usageCount: 528,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/cloud-dive.webp",
prompt: "镜头从高空云层快速向下俯冲,穿透云雾后自然揭示参考图主体和场景,保持方向连续并强化速度与纵深感。",
commercial: true,
},
{
id: "horizon-flight",
title: "飞跃地平线",
author: "赛博蒲公英",
usageCount: 1000,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/horizon-flight.webp",
prompt: "镜头低空高速前进并在接近地平线时抬升飞越,空间尺度逐步展开,主体和环境运动保持稳定连贯。",
commercial: true,
},
{
id: "reverse-gravity",
title: "逆转引力",
author: "量子呢喃",
usageCount: 239,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/reverse-gravity.webp",
prompt: "让场景和主体在保持结构一致的前提下出现引力反转,镜头随空间翻转并平滑过渡,避免突然跳切和形体崩坏。",
commercial: true,
},
{
id: "earth-zoom",
title: "地球缩放",
author: "数字游隼",
usageCount: 180,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/earth-zoom.webp",
prompt: "从参考场景连续快速拉远到城市、云层和地球尺度,保持地理方向和中心主体一致,形成无缝地球缩放转场。",
commercial: true,
},
{
id: "global-zoom",
title: "环球缩放",
author: "比特浮尘",
usageCount: 184,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/global-zoom.webp",
prompt: "围绕地球尺度完成连续环绕和缩放,镜头从局部场景扩展至全球视野后平滑落回目标区域,运动轨迹清晰稳定。",
commercial: true,
},
{
id: "iris-push",
title: "瞳孔推镜",
author: "参数迷宫",
usageCount: 387,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/iris-push.webp",
prompt: "镜头平滑推近人物眼睛并穿入瞳孔,以瞳孔纹理作为转场中心,保持面部身份稳定并避免眼部畸变。",
commercial: true,
},
{
id: "earth-dive",
title: "俯冲地球",
author: "神经元旋涡",
usageCount: 275,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/earth-dive.webp",
prompt: "从太空视角向地球目标区域高速俯冲,依次穿过大气、云层和城市,最终自然抵达参考画面。",
commercial: true,
},
{
id: "product-light-sweep",
title: "产品扫光",
author: "模型幻景",
usageCount: 943,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "在不改变产品造型、材质和品牌细节的前提下,让精致轮廓光从一侧平滑扫过产品表面,突出质感与高光细节。",
commercial: true,
},
{
id: "prada-change",
title: "普拉达换装",
author: "量子呢喃",
usageCount: 71,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/prada-change.webp",
prompt: "保持人物身份、姿态和镜头不变,以高级时装广告节奏完成连续换装,服装材质真实且每次变化衔接自然。",
commercial: true,
},
{
id: "multi-angle",
title: "多角度定点",
author: "代码极光",
usageCount: 546,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "围绕主体在多个固定机位间平滑切换,主体位置和比例保持稳定,每个角度清晰展示不同侧面并避免身份漂移。",
commercial: true,
},
{
id: "underwater-slow",
title: "水下慢镜头",
author: "代码极光",
usageCount: 253,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/underwater-slow.webp",
prompt: "将动作转为水下慢镜头质感,加入自然漂浮、细微气泡和柔和水体折射,保持主体外观清晰并让运动流畅连贯。",
commercial: true,
},
...[
["eye-makeup", "试妆特写", "像素漫游者", 98, "iris-push.webp"],
["float-in", "悬浮缓入", "像素漫游者", 171, "cloud-dive.webp"],
["macro-push", "微距推镜", "智能碎屑", 273, "product-light-sweep.webp"],
["helicopter-reveal", "直升机揭幕", "认知碎片", 100, "horizon-flight.webp"],
["mountain-chase", "山路追击", "代码极光", 185, "multi-angle.webp"],
["snow-race", "雪地赛车", "智能碎屑", 102, "earth-dive.webp"],
["city-drive", "City Drive", "像素漫游者", 70, "bee-camera.webp"],
["deconstruct-3d", "3D解构", "算法织梦者", 296, "reverse-gravity.webp"],
["face-orbit", "面部环拍", "比特浮尘", 183, "multi-angle.webp"],
["showroom", "Showroom", "智能碎屑", 65, "product-light-sweep.webp"],
["jewelry-closeup", "饰品特写", "逻辑褶皱", 62, "product-light-sweep.webp"],
["giant-lookdown", "巨人俯瞰", "算法织梦者", 116, "reverse-gravity.webp"],
["runway", "Runway", "算力潮汐", 81, "horizon-flight.webp"],
["ai-dance", "AI 编舞", "比特浮尘", 501, "multi-angle.webp"],
["android", "机械姬", "认知碎片", 137, "product-light-sweep.webp"],
["star-scene", "巨星名场面", "数据星云", 80, "horizon-flight.webp"],
["mirror-double", "镜面分身", "赛博蒲公英", 56, "reverse-gravity.webp"],
["iris-mutation", "瞳孔异变", "数字游隼", 50, "iris-push.webp"],
].map(([id, title, author, usageCount, thumbnail]) => ({
id: String(id),
title: String(title),
author: String(author),
usageCount: Number(usageCount),
model: "Seedance 2.0",
thumbnailUrl: `/video-canvas/effects/${String(thumbnail)}`,
prompt: `保持参考主体和画面结构稳定,应用“${String(title)}”的专业视频特效与镜头运动,过渡自然、动作连续、避免形体畸变和闪烁。`,
commercial: true,
})),
];
export const defaultVideoCanvasFunctionConfig: VideoCanvasFunctionConfig = { export const defaultVideoCanvasFunctionConfig: VideoCanvasFunctionConfig = {
enabled: true, enabled: true,
defaultImageModel: "auto", defaultImageModel: "auto",
defaultVideoModel: "auto", defaultVideoModel: "auto",
defaultMultimodalModel: "auto", defaultMultimodalModel: "auto",
effectPresets: defaultVideoCanvasEffectPresets,
functions: Object.fromEntries(defaultFunctions.map((item) => [item.key, item])), functions: Object.fromEntries(defaultFunctions.map((item) => [item.key, item])),
}; };
@ -404,6 +582,35 @@ export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasF
typeof value.defaultMultimodalModel === "string" && value.defaultMultimodalModel.trim() typeof value.defaultMultimodalModel === "string" && value.defaultMultimodalModel.trim()
? value.defaultMultimodalModel.trim() ? value.defaultMultimodalModel.trim()
: defaultVideoCanvasFunctionConfig.defaultMultimodalModel, : defaultVideoCanvasFunctionConfig.defaultMultimodalModel,
effectPresets: Array.isArray(value.effectPresets) && value.effectPresets.length > 0
? value.effectPresets
.slice(0, 100)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
const fallback =
defaultVideoCanvasEffectPresets[index] ||
defaultVideoCanvasEffectPresets[0];
return {
id: String(incoming.id || fallback.id || `effect-${index + 1}`),
title: String(incoming.title || fallback.title || `视频特效 ${index + 1}`),
author: String(incoming.author || fallback.author || "官方预设"),
usageCount: Math.max(
0,
Math.round(Number(incoming.usageCount ?? fallback.usageCount) || 0),
),
model: String(incoming.model || fallback.model || "Seedance 2.0"),
thumbnailUrl: String(
incoming.thumbnailUrl || fallback.thumbnailUrl || "",
),
prompt: String(incoming.prompt || fallback.prompt || ""),
commercial:
typeof incoming.commercial === "boolean"
? incoming.commercial
: fallback.commercial,
} satisfies VideoCanvasEffectPreset;
})
.filter((item) => item.title && item.thumbnailUrl)
: defaultVideoCanvasEffectPresets,
functions, functions,
}; };
} }

501
proxy.ts
View File

@ -1,4 +1,4 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist"; import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist";
import { import {
@ -8,6 +8,40 @@ import {
} from "@/lib/security/rate-limiter"; } from "@/lib/security/rate-limiter";
import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor"; import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor";
type AccessLogPayload = {
eventType?: string;
ip?: string | null;
userId?: string | null;
userEmail?: string | null;
userRole?: string | null;
method?: string | null;
path?: string | null;
routeGroup?: string | null;
statusCode?: number | null;
outcome?: string | null;
severity?: "info" | "watch" | "high" | "critical";
reasons?: string[];
userAgent?: string | null;
referer?: string | null;
metadata?: Record<string, unknown>;
occurredAt?: string;
};
type ManualIpBlockCheckResult = {
blocked: boolean;
entry?: {
id: string;
ip: string;
reason?: string | null;
expiresAt?: string | null;
} | null;
};
const manualIpBlockCache = new Map<
string,
{ expiresAt: number; result: ManualIpBlockCheckResult }
>();
function isHighFrequencyReadApi(pathname: string, method: string) { function isHighFrequencyReadApi(pathname: string, method: string) {
if (method !== "GET") return false; if (method !== "GET") return false;
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
@ -30,7 +64,197 @@ function isHighFrequencyReadApi(pathname: string, method: string) {
].includes(pathname); ].includes(pathname);
} }
export async function proxy(request: NextRequest) { function getAccessRouteGroup(pathname: string) {
if (pathname.startsWith("/api/auth")) return "auth";
if (pathname.startsWith("/api/admin")) return "admin-api";
if (pathname.startsWith("/api/")) return "api";
if (pathname.startsWith("/settings")) return "admin-page";
if (pathname === "/login" || pathname === "/register") return "auth-page";
if (pathname.startsWith("/canvas")) return "canvas-page";
return "page";
}
function shouldRecordAccessLog(
pathname: string,
method: string,
payload: AccessLogPayload
) {
if (process.env.ACCESS_LOG_ENABLED === "false") return false;
if (pathname === "/api/internal/access-log") return false;
if (payload.severity && payload.severity !== "info") return true;
if (payload.outcome && payload.outcome !== "allowed") return true;
if (isRoutineAuthRead(pathname, method)) return false;
if (isHighFrequencyReadApi(pathname, method)) return false;
if (pathname.startsWith("/api/auth")) return true;
if (pathname === "/login" || pathname === "/register") return true;
if (pathname.startsWith("/settings")) return true;
if (pathname.startsWith("/api/admin")) return true;
if (pathname.startsWith("/api/")) return method !== "GET";
return false;
}
function isInternalAccessPath(pathname: string) {
return (
pathname === "/api/internal/access-log" ||
pathname === "/api/internal/ip-blocklist/check"
);
}
function isLoopbackIp(ip: string | null) {
return (
ip === "::1" ||
ip === "127.0.0.1" ||
ip === "::ffff:127.0.0.1" ||
ip === "localhost"
);
}
function isRoutineAuthRead(pathname: string, method: string) {
if (method !== "GET") return false;
return (
pathname === "/api/auth/session" ||
pathname === "/api/auth/validate-session" ||
pathname === "/api/auth/providers" ||
pathname === "/api/auth/csrf"
);
}
function shouldBypassLocalAuthSessionRateLimit(pathname: string, ip: string | null) {
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
isLoopbackIp(ip) &&
(pathname === "/api/auth/session" ||
pathname === "/api/auth/validate-session")
);
}
function shouldBypassLocalCanvasPageAuth(pathname: string, ip: string | null) {
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
isLoopbackIp(ip) &&
pathname.startsWith("/canvas")
);
}
function shouldBypassLocalCanvasE2E(pathname: string, ip: string | null) {
if (process.env.CANVAS_E2E_LOCAL !== "true" || !isLoopbackIp(ip)) {
return false;
}
return (
pathname.startsWith("/canvas") ||
/^\/api\/canvas(?:\/|$)/.test(pathname) ||
pathname === "/api/models" ||
pathname === "/api/site-settings" ||
pathname === "/api/user/membership-summary" ||
pathname === "/api/video-canvas/config" ||
pathname === "/api/favicon" ||
pathname === "/api/auth/session" ||
pathname === "/api/auth/validate-session"
);
}
async function checkManualIpBlock(
request: NextRequest,
ip: string | null
): Promise<ManualIpBlockCheckResult> {
if (!ip || ip === "unknown") return { blocked: false };
const now = Date.now();
const cached = manualIpBlockCache.get(ip);
if (cached && cached.expiresAt > now) {
return cached.result;
}
const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
if (!secret) return { blocked: false };
try {
const response = await fetch(
new URL("/api/internal/ip-blocklist/check", request.url),
{
method: "POST",
headers: {
"content-type": "application/json",
"x-access-log-internal-secret": secret,
},
body: JSON.stringify({ ip }),
}
);
if (!response.ok) {
return { blocked: false };
}
const result = (await response.json().catch(() => null)) as
| ManualIpBlockCheckResult
| null;
const normalizedResult = result?.blocked
? result
: { blocked: false };
manualIpBlockCache.set(ip, {
expiresAt: now + (normalizedResult.blocked ? 10_000 : 30_000),
result: normalizedResult,
});
return normalizedResult;
} catch (error) {
console.warn("[AccessIpBlocklist] manual block check failed", error);
manualIpBlockCache.set(ip, {
expiresAt: now + 5_000,
result: { blocked: false },
});
return { blocked: false };
}
}
function queueAccessLog(
request: NextRequest,
event: NextFetchEvent | undefined,
payload: AccessLogPayload
) {
const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
if (!secret) return;
const { pathname } = request.nextUrl;
if (shouldBypassLocalCanvasE2E(pathname, getClientIP(request))) return;
if (!shouldRecordAccessLog(pathname, request.method, payload)) return;
const body = JSON.stringify({
eventType: "request",
ip: getClientIP(request),
method: request.method,
path: pathname,
routeGroup: getAccessRouteGroup(pathname),
statusCode: payload.statusCode ?? null,
outcome: payload.outcome || "allowed",
severity: payload.severity || "info",
reasons: payload.reasons || [],
userAgent: request.headers.get("user-agent"),
referer: request.headers.get("referer"),
occurredAt: new Date().toISOString(),
...payload,
});
const writePromise = fetch(new URL("/api/internal/access-log", request.url), {
method: "POST",
headers: {
"content-type": "application/json",
"x-access-log-internal-secret": secret,
},
body,
}).catch((error) => {
console.warn("[AccessIpEvent] async proxy write failed", error);
});
if (event) {
event.waitUntil(writePromise);
return;
}
void writePromise;
}
export async function proxy(request: NextRequest, event: NextFetchEvent) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const isSecure = const isSecure =
process.env.AUTH_URL?.startsWith("https://") || process.env.AUTH_URL?.startsWith("https://") ||
@ -48,25 +272,99 @@ export async function proxy(request: NextRequest) {
return cachedToken; return cachedToken;
}; };
// 🛡️ 安全检查1:阻止黑名单IP if (isInternalAccessPath(pathname)) {
return NextResponse.next();
}
// 🛡️ 安全检查1:标记历史黑名单IP,但不封禁
const clientIP = getClientIP(request); const clientIP = getClientIP(request);
if (isBlockedIP(clientIP)) { const bypassLocalCanvasE2E = shouldBypassLocalCanvasE2E(pathname, clientIP);
console.warn(`[Security] Blocked request from blacklisted IP: ${clientIP}`, { const staticBlocklistHit = isBlockedIP(clientIP);
let suspiciousCheck: ReturnType<typeof isSuspiciousRequest> = {
suspicious: false,
};
const accessLogIdentity = () => {
const token =
cachedToken && typeof cachedToken === "object" ? cachedToken : null;
return {
userId: typeof token?.sub === "string" ? token.sub : null,
userEmail: typeof token?.email === "string" ? token.email : null,
userRole: typeof token?.type === "string" ? token.type : null,
};
};
const withAccessLog = (
response: NextResponse,
details: AccessLogPayload = {}
) => {
const reasons = [...(details.reasons || [])];
let severity = details.severity || "info";
if (staticBlocklistHit) {
reasons.push("静态黑名单命中但未封禁");
severity = severity === "critical" ? severity : "high";
}
if (suspiciousCheck.suspicious && suspiciousCheck.reason) {
reasons.push(suspiciousCheck.reason);
if (severity === "info") severity = "watch";
}
queueAccessLog(request, event, {
...accessLogIdentity(),
statusCode: response.status || details.statusCode || null,
outcome: details.outcome || "allowed",
...details,
severity,
reasons,
});
return response;
};
if (staticBlocklistHit) {
console.warn(`[Security] Static blocklist hit but request was not blocked: ${clientIP}`, {
pathname, pathname,
method: request.method, method: request.method,
userAgent: request.headers.get("user-agent"), userAgent: request.headers.get("user-agent"),
}); });
return new NextResponse("Forbidden", { status: 403 }); }
const manualBlock = bypassLocalCanvasE2E
? { blocked: false }
: await checkManualIpBlock(request, clientIP);
if (manualBlock.blocked) {
return withAccessLog(
new NextResponse("Forbidden", { status: 403 }),
{
outcome: "manual_blocked",
severity: "critical",
reasons: [
"管理员手动拉黑",
manualBlock.entry?.reason
? `拉黑原因:${manualBlock.entry.reason}`
: "",
].filter(Boolean),
metadata: {
blocklistId: manualBlock.entry?.id || null,
matchedRule: manualBlock.entry?.ip || null,
},
}
);
} }
// 🛡️ 安全检查2:检测可疑请求模式 // 🛡️ 安全检查2:检测可疑请求模式
const suspiciousCheck = isSuspiciousRequest(request); suspiciousCheck = bypassLocalCanvasE2E
? { suspicious: false }
: isSuspiciousRequest(request);
if (suspiciousCheck.suspicious) { if (suspiciousCheck.suspicious) {
logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern"); logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern");
// 对于可疑请求,记录但不立即阻止(避免误报) // 对于可疑请求,记录但不立即阻止(避免误报)
// 可以在这里添加更严格的检查 // 可以在这里添加更严格的检查
} }
if (bypassLocalCanvasE2E) {
return NextResponse.next();
}
// 浏览器会自动拉取 favicon,不应参与通用 API 限流。 // 浏览器会自动拉取 favicon,不应参与通用 API 限流。
if (pathname === "/api/favicon") { if (pathname === "/api/favicon") {
return NextResponse.next(); return NextResponse.next();
@ -94,9 +392,16 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(chatRateLimit.resetTime).toISOString(), resetTime: new Date(chatRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) }, NextResponse.json(
{ status: 429 } { error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["聊天接口限流"],
}
); );
} }
} else if (pathname === "/api/image-proxy" && request.method === "GET") { } else if (pathname === "/api/image-proxy" && request.method === "GET") {
@ -108,9 +413,16 @@ export async function proxy(request: NextRequest) {
method: request.method, method: request.method,
resetTime: new Date(imageProxyRateLimit.resetTime).toISOString(), resetTime: new Date(imageProxyRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many image proxy requests", retryAfter }, NextResponse.json(
{ status: 429, headers: { "Retry-After": String(retryAfter) } } { error: "Too many image proxy requests", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["图片代理限流"],
}
); );
} }
} else if ( } else if (
@ -135,9 +447,16 @@ export async function proxy(request: NextRequest) {
userId, userId,
resetTime: new Date(uploadRateLimit.resetTime).toISOString(), resetTime: new Date(uploadRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many upload requests", retryAfter }, NextResponse.json(
{ status: 429, headers: { "Retry-After": String(retryAfter) } } { error: "Too many upload requests", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["上传接口限流"],
}
); );
} }
} else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") {
@ -147,9 +466,16 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(canvasRateLimit.resetTime).toISOString(), resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) }, NextResponse.json(
{ status: 429 } { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["画布接口限流"],
}
); );
} }
} else if (pathname === "/api/canvas/auto-title" && request.method === "POST") { } else if (pathname === "/api/canvas/auto-title" && request.method === "POST") {
@ -159,12 +485,19 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(canvasRateLimit.resetTime).toISOString(), resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
NextResponse.json(
{
error: "Too many canvas requests",
retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000),
},
{ status: 429 }
),
{ {
error: "Too many canvas requests", outcome: "rate_limited",
retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000), severity: "high",
}, reasons: ["画布自动标题限流"],
{ status: 429 } }
); );
} }
} else if ( } else if (
@ -177,9 +510,16 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(realtimeRateLimit.resetTime).toISOString(), resetTime: new Date(realtimeRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) }, NextResponse.json(
{ status: 429 } { error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["实时接口限流"],
}
); );
} }
} else if (isHighFrequencyReadApi(pathname, request.method)) { } else if (isHighFrequencyReadApi(pathname, request.method)) {
@ -189,14 +529,23 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(pollingRateLimit.resetTime).toISOString(), resetTime: new Date(pollingRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
NextResponse.json(
{
error: "Too many polling requests",
retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000),
},
{ status: 429 }
),
{ {
error: "Too many polling requests", outcome: "rate_limited",
retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000), severity: "high",
}, reasons: ["高频轮询限流"],
{ status: 429 } }
); );
} }
} else if (isRoutineAuthRead(pathname, request.method)) {
// Session reads are used for UI hydration and should not count as login attempts.
} else if (pathname.startsWith("/api/admin/")) { } else if (pathname.startsWith("/api/admin/")) {
const token = await resolveToken(); const token = await resolveToken();
const adminIdentifier = const adminIdentifier =
@ -216,9 +565,16 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(adminRateLimit.resetTime).toISOString(), resetTime: new Date(adminRateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many admin requests", retryAfter }, NextResponse.json(
{ status: 429, headers: { "Retry-After": String(retryAfter) } } { error: "Too many admin requests", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["管理后台限流"],
}
); );
} }
} else if (pathname.startsWith("/api/")) { } else if (pathname.startsWith("/api/")) {
@ -228,21 +584,39 @@ export async function proxy(request: NextRequest) {
pathname, pathname,
resetTime: new Date(rateLimit.resetTime).toISOString(), resetTime: new Date(rateLimit.resetTime).toISOString(),
}); });
return NextResponse.json( return withAccessLog(
{ error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, NextResponse.json(
{ status: 429 } { error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["通用 API 限流"],
}
); );
} }
} }
// 🛡️ 安全检查4:认证相关API的严格速率限制 // 🛡️ 安全检查4:认证相关API的严格速率限制
if (pathname.startsWith("/api/auth/")) { if (
pathname.startsWith("/api/auth/") &&
!isRoutineAuthRead(pathname, request.method) &&
!shouldBypassLocalAuthSessionRateLimit(pathname, clientIP)
) {
const rateLimit = checkRateLimit(request, rateLimiters.auth); const rateLimit = checkRateLimit(request, rateLimiters.auth);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`); console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`);
return NextResponse.json( return withAccessLog(
{ error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, NextResponse.json(
{ status: 429 } { error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["认证接口限流"],
}
); );
} }
} }
@ -253,12 +627,21 @@ export async function proxy(request: NextRequest) {
} }
if (pathname === "/classic" || pathname.startsWith("/chat/")) { if (pathname === "/classic" || pathname.startsWith("/chat/")) {
return NextResponse.redirect(new URL("/", request.url)); return withAccessLog(
NextResponse.redirect(new URL("/", request.url)),
{
outcome: "redirect",
reasons: ["旧入口重定向"],
}
);
} }
// 允许访问认证相关 API // 允许访问认证相关 API
if (pathname.startsWith("/api/auth")) { if (pathname.startsWith("/api/auth")) {
return NextResponse.next(); return withAccessLog(NextResponse.next(), {
outcome: "allowed",
reasons: ["认证接口访问"],
});
} }
// 允许访问公开的网站设置 API(登录页面需要) // 允许访问公开的网站设置 API(登录页面需要)
@ -323,15 +706,23 @@ export async function proxy(request: NextRequest) {
pathname === "/particle-preview" || pathname === "/particle-preview" ||
pathname === "/riji" || pathname === "/riji" ||
pathname.startsWith("/riji/") || pathname.startsWith("/riji/") ||
pathname.startsWith("/worlds/share/"); pathname.startsWith("/worlds/share/") ||
shouldBypassLocalCanvasPageAuth(pathname, clientIP);
// 未登录用户允许访问公开页面,其余需要登录 // 未登录用户允许访问公开页面,其余需要登录
if (!token && !isPublicPage) { if (!token && !isPublicPage) {
// 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页 // 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
return NextResponse.json( return withAccessLog(
{ error: "未授权,请先登录" }, NextResponse.json(
{ status: 401 } { error: "未授权,请先登录" },
{ status: 401 }
),
{
outcome: "unauthorized",
severity: "watch",
reasons: ["未登录访问 API"],
}
); );
} }
@ -342,22 +733,28 @@ export async function proxy(request: NextRequest) {
const loginUrl = new URL("/login", externalBase); const loginUrl = new URL("/login", externalBase);
// 保存当前路径作为回调 URL // 保存当前路径作为回调 URL
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl); return withAccessLog(
NextResponse.redirect(loginUrl),
{
outcome: "redirect",
severity: "watch",
reasons: ["未登录访问受保护页面"],
}
);
} }
return NextResponse.next(); return withAccessLog(NextResponse.next());
} }
export const config = { export const config = {
matcher: [ matcher: [
/* /*
* 匹配所有请求路径,除了: * 匹配所有请求路径,除了:
* - api/auth (NextAuth.js)
* - _next/static (静态文件) * - _next/static (静态文件)
* - _next/image (图片优化文件) * - _next/image (图片优化文件)
* - favicon.ico (favicon文件) * - favicon.ico (favicon文件)
* - public 文件夹中的文件 * - public 文件夹中的文件
*/ */
"/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|js|wasm|woff2?|ttf|otf|eot)$).*)", "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|js|wasm|woff2?|ttf|otf|eot)$).*)",
], ],
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -6,23 +6,26 @@ import {
S3Client, S3Client,
type CORSRule, type CORSRule,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" }); loadEnv({ path: ".env.local" });
loadEnv(); loadEnv();
type S3UploadConfig = { type S3UploadConfig = {
name?: string;
endpoint: string; endpoint: string;
region: string; region: string;
bucket: string; bucket: string;
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
provider?: "s3" | "qiniu-kodo";
}; };
const DEFAULT_ORIGINS = [ const DEFAULT_ORIGINS = [
"https://ai.yydd.ai", "https://ai.yydd.ai",
"https://yydd.ai", "https://yydd.ai",
"https://bbyy.ai", "https://bbyy.ai",
"https://yybb.top",
"https://goyingup.chat", "https://goyingup.chat",
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3001", "http://localhost:3001",
@ -54,7 +57,7 @@ function createS3Client(config: S3UploadConfig) {
accessKeyId: config.accessKeyId, accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey, secretAccessKey: config.secretAccessKey,
}, },
forcePathStyle: !isCloudS3, forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
}); });
} }
@ -69,7 +72,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
if ( if (
!raw || !raw ||
raw.type !== "s3" || (raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
raw.isEnabled !== true || raw.isEnabled !== true ||
!endpoint || !endpoint ||
!bucket || !bucket ||
@ -80,11 +83,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
} }
return { return {
name: raw.name ? String(raw.name) : undefined,
endpoint, endpoint,
region: String(raw.region || "auto").trim() || "auto", region: String(raw.region || "auto").trim() || "auto",
bucket, bucket,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
}; };
} }
@ -101,6 +106,7 @@ function readEnvS3Config(): S3UploadConfig | null {
} }
return { return {
name: "Environment S3",
endpoint, endpoint,
region: process.env.S3_REGION?.trim() || "auto", region: process.env.S3_REGION?.trim() || "auto",
bucket, bucket,
@ -109,24 +115,31 @@ function readEnvS3Config(): S3UploadConfig | null {
}; };
} }
async function resolveS3Config() { async function resolveS3Configs() {
const [{ db }, { storageConfig }] = await Promise.all([ const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"), import("@/lib/db"),
import("@/lib/db/schema"), import("@/lib/db/schema"),
]); ]);
const [defaultConfig] = await db const storageConfigs = await db
.select() .select()
.from(storageConfig) .from(storageConfig)
.where( .where(
and( and(
eq(storageConfig.type, "s3"), inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
eq(storageConfig.isDefault, true),
eq(storageConfig.isEnabled, true) eq(storageConfig.isEnabled, true)
) )
) );
.limit(1);
return toS3Config(defaultConfig) || readEnvS3Config(); const configs = storageConfigs
.map(toS3Config)
.filter((config): config is S3UploadConfig => Boolean(config));
const envConfig = readEnvS3Config();
if (configs.length === 0 && envConfig) {
return [envConfig];
}
return configs;
} }
function readOrigins() { function readOrigins() {
@ -159,15 +172,21 @@ function isDirectUploadRule(rule: CORSRule, origins: string[]) {
); );
} }
function isCorsApiUnsupported(error: unknown) {
const value = error as { Code?: string; name?: string; message?: string } | null;
const code = String(value?.Code || value?.name || "");
const message = String(value?.message || "");
return code === "NotImplemented" || /not implemented/i.test(message);
}
async function main() { async function main() {
const config = await resolveS3Config(); const configs = await resolveS3Configs();
if (!config) { if (configs.length === 0) {
console.log("[direct-upload-cors] skip: no S3-compatible upload target"); console.log("[direct-upload-cors] skip: no S3-compatible upload targets");
return; return;
} }
const origins = readOrigins(); const origins = readOrigins();
const client = createS3Client(config);
const directRule: CORSRule = { const directRule: CORSRule = {
AllowedOrigins: origins, AllowedOrigins: origins,
AllowedMethods: ["GET", "PUT", "POST", "HEAD"], AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
@ -175,41 +194,77 @@ async function main() {
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"], ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
MaxAgeSeconds: 3600, MaxAgeSeconds: 3600,
}; };
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
let existingRules: CORSRule[] = []; for (const config of configs) {
try { const client = createS3Client(config);
const current = await client.send( let existingRules: CORSRule[] = [];
new GetBucketCorsCommand({ Bucket: config.bucket }) try {
); const current = await client.send(
existingRules = current.CORSRules || []; new GetBucketCorsCommand({ Bucket: config.bucket })
} catch (error) { );
console.warn( existingRules = current.CORSRules || [];
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:", } catch (error) {
error instanceof Error ? error.message : String(error) console.warn(
); "[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
{
name: config.name,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
}
);
}
const mergedRules = [
directRule,
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
]
.slice(0, 100)
.map(normalizeRule);
try {
await client.send(
new PutBucketCorsCommand({
Bucket: config.bucket,
CORSConfiguration: {
CORSRules: mergedRules,
},
})
);
console.log("[direct-upload-cors] configured", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
origins,
ruleCount: mergedRules.length,
});
} catch (error) {
if (isCorsApiUnsupported(error)) {
console.warn("[direct-upload-cors] bucket CORS API unsupported; leaving provider/global CORS in place", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
failures.push({ config, error });
console.error("[direct-upload-cors] configure bucket failed", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
}
} }
const mergedRules = [ if (failures.length > 0) {
directRule, throw new Error(
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)), `Failed to configure direct upload CORS for ${failures.length} bucket(s)`
] );
.slice(0, 100) }
.map(normalizeRule);
await client.send(
new PutBucketCorsCommand({
Bucket: config.bucket,
CORSConfiguration: {
CORSRules: mergedRules,
},
})
);
console.log("[direct-upload-cors] configured", {
bucket: config.bucket,
origins,
ruleCount: mergedRules.length,
});
} }
main() main()

View File

@ -6,12 +6,13 @@ import {
S3Client, S3Client,
type LifecycleRule, type LifecycleRule,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" }); loadEnv({ path: ".env.local" });
loadEnv(); loadEnv();
type S3UploadConfig = { type S3UploadConfig = {
name?: string;
endpoint: string; endpoint: string;
region: string; region: string;
bucket: string; bucket: string;
@ -65,7 +66,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
if ( if (
!raw || !raw ||
raw.type !== "s3" || (raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
raw.isEnabled !== true || raw.isEnabled !== true ||
!endpoint || !endpoint ||
!bucket || !bucket ||
@ -76,12 +77,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
} }
return { return {
name: raw.name ? String(raw.name) : undefined,
endpoint, endpoint,
region: String(raw.region || "auto").trim() || "auto", region: String(raw.region || "auto").trim() || "auto",
bucket, bucket,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
provider: raw.provider === "qiniu-kodo" ? "qiniu-kodo" : "s3", provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
}; };
} }
@ -98,6 +100,7 @@ function readEnvS3Config(): S3UploadConfig | null {
} }
return { return {
name: "Environment S3",
endpoint, endpoint,
region: process.env.S3_REGION?.trim() || "auto", region: process.env.S3_REGION?.trim() || "auto",
bucket, bucket,
@ -106,24 +109,31 @@ function readEnvS3Config(): S3UploadConfig | null {
}; };
} }
async function resolveS3Config() { async function resolveS3Configs() {
const [{ db }, { storageConfig }] = await Promise.all([ const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"), import("@/lib/db"),
import("@/lib/db/schema"), import("@/lib/db/schema"),
]); ]);
const [defaultConfig] = await db const storageConfigs = await db
.select() .select()
.from(storageConfig) .from(storageConfig)
.where( .where(
and( and(
eq(storageConfig.type, "s3"), inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
eq(storageConfig.isDefault, true),
eq(storageConfig.isEnabled, true) eq(storageConfig.isEnabled, true)
) )
) );
.limit(1);
return toS3Config(defaultConfig) || readEnvS3Config(); const configs = storageConfigs
.map(toS3Config)
.filter((config): config is S3UploadConfig => Boolean(config));
const envConfig = readEnvS3Config();
if (configs.length === 0 && envConfig) {
return [envConfig];
}
return configs;
} }
function readAbortDays() { function readAbortDays() {
@ -148,62 +158,123 @@ function normalizeRule(rule: LifecycleRule): LifecycleRule {
}; };
} }
function isLifecycleApiUnsupported(error: unknown) {
const value = error as { Code?: string; name?: string; message?: string } | null;
const code = String(value?.Code || value?.name || "");
const message = String(value?.message || "");
return (
code === "NotImplemented" ||
/not implemented/i.test(message) ||
/not well-formed/i.test(message) ||
/validate against/i.test(message)
);
}
async function main() { async function main() {
const config = await resolveS3Config(); const configs = await resolveS3Configs();
if (!config) { if (configs.length === 0) {
console.log("[multipart-lifecycle] skip: no S3-compatible upload target"); console.log("[multipart-lifecycle] skip: no S3-compatible upload targets");
return; return;
} }
const client = createS3Client(config);
const prefix = readRulePrefix(); const prefix = readRulePrefix();
const abortDays = readAbortDays(); const abortDays = readAbortDays();
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID; const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
let configuredCount = 0;
let existingRules: LifecycleRule[] = []; for (const config of configs) {
try { if (config.provider === "qiniu-kodo") {
const current = await client.send( console.log("[multipart-lifecycle] skip bucket: qiniu-kodo lifecycle is not configured through S3 compatibility", {
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket }) name: config.name,
); bucket: config.bucket,
existingRules = current.Rules || []; });
} catch (error) { continue;
console.warn( }
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
error instanceof Error ? error.message : String(error) const client = createS3Client(config);
); let existingRules: LifecycleRule[] = [];
try {
const current = await client.send(
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
);
existingRules = current.Rules || [];
} catch (error) {
console.warn(
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
{
name: config.name,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
}
);
}
const cleanupRule: LifecycleRule = {
ID: ruleId,
Status: "Enabled",
Filter: { Prefix: prefix },
AbortIncompleteMultipartUpload: {
DaysAfterInitiation: abortDays,
},
};
const mergedRules = [
cleanupRule,
...existingRules.filter((rule) => rule.ID !== ruleId),
]
.slice(0, 1000)
.map(normalizeRule);
try {
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: config.bucket,
LifecycleConfiguration: {
Rules: mergedRules,
},
})
);
configuredCount += 1;
console.log("[multipart-lifecycle] configured", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
prefix,
abortDays,
ruleCount: mergedRules.length,
});
} catch (error) {
if (isLifecycleApiUnsupported(error)) {
console.warn("[multipart-lifecycle] bucket lifecycle API unsupported; skipping cleanup rule", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
failures.push({ config, error });
console.error("[multipart-lifecycle] configure bucket failed", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
}
} }
const cleanupRule: LifecycleRule = { if (configuredCount === 0) {
ID: ruleId, console.log("[multipart-lifecycle] no compatible buckets required lifecycle configuration");
Status: "Enabled", }
Filter: { Prefix: prefix },
AbortIncompleteMultipartUpload: {
DaysAfterInitiation: abortDays,
},
};
const mergedRules = [ if (failures.length > 0) {
cleanupRule, throw new Error(
...existingRules.filter((rule) => rule.ID !== ruleId), `Failed to configure multipart lifecycle for ${failures.length} bucket(s)`
] );
.slice(0, 1000) }
.map(normalizeRule);
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: config.bucket,
LifecycleConfiguration: {
Rules: mergedRules,
},
})
);
console.log("[multipart-lifecycle] configured", {
bucket: config.bucket,
prefix,
abortDays,
ruleCount: mergedRules.length,
});
} }
main() main()

View File

@ -102,6 +102,7 @@ export interface CanvasTextSuggestionItem {
export type WorkflowAnalysisMode = export type WorkflowAnalysisMode =
| "video-analysis" | "video-analysis"
| "character-scene-extraction" | "character-scene-extraction"
| "story-script"
| "agent-plan"; | "agent-plan";
export interface EnhanceProImageResult { export interface EnhanceProImageResult {
@ -1002,7 +1003,18 @@ export const workflowAnalysisService = {
}): Promise< }): Promise<
ApiResponse<{ ApiResponse<{
analysisRows?: Array<{ analysisRows?: Array<{
shot?: string | number;
startTime?: string | number;
endTime?: string | number;
duration?: string | number;
sceneDescription?: string; sceneDescription?: string;
narrative?: string;
story?: string;
shotSize?: string;
cameraAngle?: string;
cameraMovement?: string;
focusDepth?: string;
lighting?: string;
backgroundMusic?: string; backgroundMusic?: string;
voiceAndSfx?: string; voiceAndSfx?: string;
imagePrompt?: string; imagePrompt?: string;