feat: refine canvas workflows and unify application UI
698
app/(admin)/settings/_components/access-security-dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
207
app/api/admin/access-blocklist/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
271
app/api/admin/access-logs/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
147
app/api/internal/access-log/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
140
app/api/internal/ip-blocklist/check/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
136
app/globals.css
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
221
docs/Claw模式技能使用宣导.md
Normal 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 或原因?
|
||||||
|
- 供应商维度下,哪些商品销售好但库存或到货存在风险?
|
||||||
197
docs/Claw模式技能使用宣导.txt
Normal 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 或原因?
|
||||||
|
- 供应商维度下,哪些商品销售好但库存或到货存在风险?
|
||||||
BIN
docs/Claw模式技能使用宣导2.pdf
Normal file
364
docs/video-canvas-competitor-audit.md
Normal 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 视频任务提交/轮询/扣费和视频结果持久化
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
35
lib/db/migrations/0087_add_access_ip_events.sql
Normal 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);
|
||||||
20
lib/db/migrations/0088_add_access_ip_blocklist.sql
Normal 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");
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
88
lib/security/manual-ip-blocklist.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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
@ -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)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
public/video-canvas/effects/bee-camera.webp
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/video-canvas/effects/cloud-dive.webp
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/video-canvas/effects/earth-dive.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/video-canvas/effects/earth-zoom.webp
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/video-canvas/effects/global-zoom.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/video-canvas/effects/horizon-flight.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/video-canvas/effects/iris-push.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/video-canvas/effects/multi-angle.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/video-canvas/effects/prada-change.webp
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/video-canvas/effects/product-light-sweep.webp
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
public/video-canvas/effects/reverse-gravity.webp
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/video-canvas/effects/underwater-slow.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||