merge: integrate missing canvas and UI updates
This commit is contained in:
commit
2ce64dd49b
698
app/(admin)/settings/_components/access-security-dashboard.tsx
Normal file
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-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
|
||||
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
|
||||
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
|
||||
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
|
||||
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
@ -45,7 +45,7 @@ function SettingsQuickAction({
|
||||
variant="ghost"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
</Button>
|
||||
@ -59,7 +59,7 @@ function SettingsQuickAction({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
@ -84,11 +84,14 @@ export function AdminSettingsNavigation({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
<nav
|
||||
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">
|
||||
<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 || "选择设置项"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -102,7 +105,7 @@ export function AdminSettingsNavigation({
|
||||
|
||||
<Button
|
||||
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"
|
||||
variant="outline"
|
||||
>
|
||||
@ -113,35 +116,22 @@ export function AdminSettingsNavigation({
|
||||
</Button>
|
||||
</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 className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<div className="min-w-[118px] flex-1" key={item.value}>
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
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"
|
||||
variant="outline"
|
||||
>
|
||||
@ -151,6 +141,6 @@ export function AdminSettingsNavigation({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
|
||||
onSetAllExpanded: (expanded: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-background">
|
||||
<div className="border-b bg-muted/20 px-5 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="border-b bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground">
|
||||
SYSTEM INTEGRATIONS
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-normal">
|
||||
<h2 className="font-semibold text-lg">
|
||||
高级能力与外部服务
|
||||
</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
|
||||
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<SettingStatusBadge tone="neutral">
|
||||
已展开 {openSectionCount} 个模块
|
||||
</SettingStatusBadge>
|
||||
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
|
||||
模型 {modelCount}
|
||||
</SettingStatusBadge>
|
||||
<Button
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onSetAllExpanded(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
|
||||
全部展开
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onSetAllExpanded(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<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}
|
||||
onClick={() => onFocusSection(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">
|
||||
<div className="truncate font-semibold text-xs">
|
||||
{item.title}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t bg-muted/10 px-5 py-4">
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="border-t bg-muted/10 px-4 py-3">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
||||
<div className="space-y-2" key={group}>
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
<div className="space-y-1.5" key={group}>
|
||||
<div className="font-semibold text-[11px] text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickLinks
|
||||
.filter((item) => item.group === group)
|
||||
.map((item) => (
|
||||
<button
|
||||
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)
|
||||
? "border-foreground/20 bg-background text-foreground"
|
||||
: "border-border bg-transparent text-muted-foreground"
|
||||
|
||||
@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<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>
|
||||
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
|
||||
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{items.map((item) => (
|
||||
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
||||
{item.label}
|
||||
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
|
||||
return (
|
||||
<Button
|
||||
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}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{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 ? "收起配置" : "展开配置"}
|
||||
</Button>
|
||||
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
|
||||
}) {
|
||||
return (
|
||||
<Card id={id}>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{summary}
|
||||
</div>
|
||||
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"local-ai-config-card-content space-y-6 pt-6",
|
||||
"local-ai-config-card-content space-y-4 pt-4",
|
||||
!expanded && "hidden"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
|
||||
为 AI详情生成 单独配置 Kimi Code 编程套餐
|
||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||
</>
|
||||
}
|
||||
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={<>Moonshot 官方 Kimi K2.6 配置</>}
|
||||
title={<>Kimi Code 编程套餐配置</>}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
||||
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
|
||||
providerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Moonshot AI (Official)"
|
||||
placeholder="Kimi Code 编程套餐"
|
||||
value={moonshotDetailAgentConfig.providerName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
|
||||
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
|
||||
<Input
|
||||
id="moonshotBaseUrl"
|
||||
onChange={(e) =>
|
||||
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.baseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
|
||||
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
|
||||
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
</div>
|
||||
</div>
|
||||
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
|
||||
placeholder={
|
||||
moonshotDetailAgentConfig.providerId
|
||||
? "留空则不修改当前 Key"
|
||||
: "请输入 Moonshot 官方 API Key"
|
||||
: "请输入 Kimi Code 控制台 API Key"
|
||||
}
|
||||
type="password"
|
||||
value={moonshotDetailAgentConfig.apiKey}
|
||||
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
|
||||
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
|
||||
<Input
|
||||
id="moonshotModelId"
|
||||
onChange={(e) =>
|
||||
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.modelId}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
已按你提供的 Moonshot 官方文档默认填写
|
||||
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
|
||||
Kimi Code 编程套餐专用接口固定使用
|
||||
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存 Moonshot Kimi K2.6 配置
|
||||
保存 Kimi Code 编程套餐配置
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
@ -15,7 +15,7 @@ export function SettingStatusBadge({
|
||||
return (
|
||||
<Badge
|
||||
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" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
MessageSquare,
|
||||
Scissors,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@ -37,6 +38,12 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
||||
description: "用量、趋势与运营指标",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
value: "access-security",
|
||||
label: "访问安全",
|
||||
description: "访问 IP、异常标注与登录来源",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Crop,
|
||||
@ -155,6 +157,8 @@ import {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||
isDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotConfig,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
|
||||
@ -303,6 +307,7 @@ import {
|
||||
} from "@/lib/worldlabs/key-pool-config-shared";
|
||||
import type { AgentWorkflowUiConfig } from "@/types/agent";
|
||||
import { AdminSettingsNavigation } from "./_components/admin-settings-navigation";
|
||||
import { AccessSecurityDashboard } from "./_components/access-security-dashboard";
|
||||
import { LocalAiCommandCenter } from "./_components/local-ai-command-center";
|
||||
import { LocalAiGroupHeading } from "./_components/local-ai-section-shell";
|
||||
import { LocalAiImagePipelineSections } from "./_components/local-ai-sections/image-pipeline-sections";
|
||||
@ -1699,10 +1704,7 @@ function isBltcyBflBaseUrl(value?: string | null) {
|
||||
}
|
||||
|
||||
function isMoonshotOfficialBaseUrl(value?: string | null) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.replace(TRAILING_SLASHES_REGEX, "");
|
||||
return normalized === DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
return isDetailAgentLocalMoonshotBaseUrl(value);
|
||||
}
|
||||
|
||||
export function AdminSettingsClient() {
|
||||
@ -2671,7 +2673,7 @@ export function AdminSettingsClient() {
|
||||
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
||||
providerId: "",
|
||||
modelEntryId: "",
|
||||
providerName: "Moonshot AI (Official)",
|
||||
providerName: "Kimi Code 编程套餐",
|
||||
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||
apiKey: "",
|
||||
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
@ -7053,8 +7055,9 @@ export function AdminSettingsClient() {
|
||||
const handleSaveMoonshotDetailAgentConfig = async () => {
|
||||
try {
|
||||
const trimmedBaseUrl =
|
||||
moonshotDetailAgentConfig.baseUrl.trim() ||
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl(
|
||||
moonshotDetailAgentConfig.baseUrl
|
||||
);
|
||||
const trimmedModelId =
|
||||
moonshotDetailAgentConfig.modelId.trim() ||
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
|
||||
@ -7063,17 +7066,17 @@ export function AdminSettingsClient() {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
|
||||
const trimmedProviderName =
|
||||
moonshotDetailAgentConfig.providerName.trim() ||
|
||||
"Moonshot AI (Official)";
|
||||
"Kimi Code 编程套餐";
|
||||
|
||||
if (!isMoonshotOfficialBaseUrl(trimmedBaseUrl)) {
|
||||
toast.error(
|
||||
"请使用 Moonshot 官方 API 地址:https://api.moonshot.cn/v1"
|
||||
`请使用 Kimi Code 编程套餐专用 API 地址:${DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedModelId) {
|
||||
toast.error("请填写官方 Kimi K2.6 的 API 模型 ID");
|
||||
toast.error("请填写 Kimi Code API 模型 ID");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7081,7 +7084,7 @@ export function AdminSettingsClient() {
|
||||
!moonshotDetailAgentConfig.providerId &&
|
||||
!moonshotDetailAgentConfig.apiKey.trim()
|
||||
) {
|
||||
toast.error("请填写 Moonshot 官方 API Key");
|
||||
toast.error("请填写 Kimi Code 控制台 API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7133,7 +7136,7 @@ export function AdminSettingsClient() {
|
||||
|
||||
mutateMoonshotDetailAgentSetting();
|
||||
mutateSiteSettings();
|
||||
toast.success("Moonshot 官方 Kimi K2.6 已绑定为 AI详情 Agent 专用模型");
|
||||
toast.success("Kimi Code 编程套餐已绑定为 AI详情 Agent 专用模型");
|
||||
} catch (error) {
|
||||
console.error("[Moonshot Detail Agent Config] Error:", error);
|
||||
toast.error(
|
||||
@ -8653,7 +8656,7 @@ export function AdminSettingsClient() {
|
||||
}, [expandedLocalAiSections]);
|
||||
|
||||
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}>
|
||||
<AdminSettingsNavigation
|
||||
activeTab={activeTab}
|
||||
@ -8677,18 +8680,20 @@ export function AdminSettingsClient() {
|
||||
type="text"
|
||||
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
|
||||
className="flex items-center gap-2"
|
||||
className="min-w-0 px-2"
|
||||
onClick={() => setShowBatchLimitDialog(true)}
|
||||
size="sm"
|
||||
title="批量设置限额"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
批量设置限额
|
||||
<span className="sm:hidden">批量限额</span>
|
||||
<span className="hidden sm:inline">批量设置限额</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
className="min-w-0 px-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
toast.info("正在从钉钉同步用户信息,可能需要几分钟...");
|
||||
@ -8713,10 +8718,12 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
title="同步钉钉用户"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
同步钉钉用户
|
||||
<span className="sm:hidden">同步用户</span>
|
||||
<span className="hidden sm:inline">同步钉钉用户</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -8725,40 +8732,35 @@ export function AdminSettingsClient() {
|
||||
<div className="mb-4 space-y-4">
|
||||
{usersData?.users?.map((user) => (
|
||||
<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}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
邮箱: {user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
部门: {user.department || "未设置"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态:{" "}
|
||||
{user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
标签: {user.isHighVip ? "高级VIP" : "普通"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
月度出图限额:{" "}
|
||||
{user.monthlyImageLimit
|
||||
? `${user.monthlyImageLimit} 张/月`
|
||||
: "无限制"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
剩余积分:{" "}
|
||||
{Number(user.pointsBalance || 0).toLocaleString()} |
|
||||
累计赠送:{" "}
|
||||
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
|
||||
<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">
|
||||
<div className="truncate">邮箱: {user.email}</div>
|
||||
<div>部门: {user.department || "未设置"}</div>
|
||||
<div>
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} ·{" "}
|
||||
{user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
<div>标签: {user.isHighVip ? "高级VIP" : "普通"}</div>
|
||||
<div>
|
||||
月度限额:{" "}
|
||||
{user.monthlyImageLimit
|
||||
? `${user.monthlyImageLimit} 张/月`
|
||||
: "无限制"}
|
||||
</div>
|
||||
<div>
|
||||
积分: {Number(user.pointsBalance || 0).toLocaleString()}
|
||||
{" · 赠送 "}
|
||||
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
|
||||
</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">
|
||||
<Input
|
||||
@ -8905,15 +8907,16 @@ export function AdminSettingsClient() {
|
||||
|
||||
{/* 分页控件 */}
|
||||
{usersData && usersData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
显示 {(userPage - 1) * userLimit + 1} -{" "}
|
||||
{Math.min(userPage * userLimit, usersData.total)} 条,共{" "}
|
||||
{usersData.total} 条
|
||||
</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
|
||||
className="hidden sm:inline-flex"
|
||||
disabled={userPage === 1}
|
||||
onClick={() => setUserPage(1)}
|
||||
size="sm"
|
||||
@ -8925,19 +8928,24 @@ export function AdminSettingsClient() {
|
||||
|
||||
{/* 上一页按钮 */}
|
||||
<Button
|
||||
aria-label="上一页"
|
||||
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
|
||||
disabled={userPage === 1}
|
||||
onClick={() => setUserPage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
上一页
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
|
||||
{/* 页码显示和跳转 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm">第</span>
|
||||
<div className="flex min-w-0 items-center justify-center gap-1">
|
||||
<span className="hidden whitespace-nowrap text-xs sm:inline">
|
||||
第
|
||||
</span>
|
||||
<Input
|
||||
className="h-8 w-16 text-center"
|
||||
className="h-8 w-10 px-1 text-center sm:w-14"
|
||||
max={usersData.totalPages}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
@ -8962,13 +8970,15 @@ export function AdminSettingsClient() {
|
||||
type="number"
|
||||
value={userPage}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-sm">
|
||||
/ {usersData.totalPages} 页
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
/{usersData.totalPages}页
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<Button
|
||||
aria-label="下一页"
|
||||
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
|
||||
disabled={userPage === usersData.totalPages}
|
||||
onClick={() =>
|
||||
setUserPage((p) =>
|
||||
@ -8978,11 +8988,13 @@ export function AdminSettingsClient() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
下一页
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* 末页按钮 */}
|
||||
<Button
|
||||
className="hidden sm:inline-flex"
|
||||
disabled={userPage === usersData.totalPages}
|
||||
onClick={() => setUserPage(usersData.totalPages)}
|
||||
size="sm"
|
||||
@ -9979,6 +9991,11 @@ export function AdminSettingsClient() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 访问安全 */}
|
||||
<TabsContent className="mt-6" value="access-security">
|
||||
<AccessSecurityDashboard />
|
||||
</TabsContent>
|
||||
|
||||
{/* 网站设置 */}
|
||||
<TabsContent className="mt-6" value="site">
|
||||
<Card>
|
||||
@ -13424,7 +13441,7 @@ export function AdminSettingsClient() {
|
||||
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
|
||||
Agent 对话默认模型;仅影响
|
||||
AI详情生成,不影响普通无限画布对话框的 Agent
|
||||
对话。建议选择官方 Kimi K2.6 多模态模型。
|
||||
对话。建议选择 Kimi Code 编程套餐模型。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
if (isLoading) {
|
||||
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 items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
|
||||
}
|
||||
|
||||
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 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="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<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">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className="text-base font-semibold">风格化工作流配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
|
||||
</p>
|
||||
</div>
|
||||
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{enabledStepsCount}
|
||||
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<main className="container mx-auto p-4 sm:px-5">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
|
||||
<TabsTrigger value="showcase" className="gap-2">
|
||||
<MonitorPlay className="h-4 w-4" />
|
||||
|
||||
@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
|
||||
);
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<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">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className="text-base font-semibold">PSD 模板配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
|
||||
</p>
|
||||
</div>
|
||||
@ -2457,7 +2457,6 @@ export function PsdTemplatesSettingsClient({
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!draftTemplate || isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -2472,8 +2471,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</header>
|
||||
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{templates.length}
|
||||
</Badge>
|
||||
@ -2509,8 +2508,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
<div className="space-y-6">
|
||||
<div className="container mx-auto px-4 py-4 sm:px-5">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">品类标签</CardTitle>
|
||||
|
||||
@ -561,19 +561,19 @@ export function StyleProSettingsClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<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-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<Button asChild variant="outline">
|
||||
<div className="mb-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
返回设置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||||
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
requiresSingleActiveSession,
|
||||
revokeSingleActiveSession,
|
||||
} from "@/lib/auth/session-control";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
|
||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
@ -200,7 +201,10 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
||||
}
|
||||
|
||||
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 =
|
||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||
const googleClientId =
|
||||
@ -283,6 +287,32 @@ const nextAuth = NextAuth(async () => {
|
||||
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;
|
||||
},
|
||||
session({ session, token }) {
|
||||
|
||||
@ -289,6 +289,7 @@ import {
|
||||
} from "@/lib/video-agent/task-store";
|
||||
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments";
|
||||
import type { AppUsage } from "@/lib/usage";
|
||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
|
||||
@ -1306,7 +1307,7 @@ export async function POST(request: Request) {
|
||||
chatId: id,
|
||||
role: "user",
|
||||
parts: message.parts as any,
|
||||
attachments: [],
|
||||
attachments: collectMessageImageAttachments({ parts: message.parts }),
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
@ -2889,7 +2890,7 @@ export async function POST(request: Request) {
|
||||
id: message.id,
|
||||
role: "user",
|
||||
parts: message.parts as any,
|
||||
attachments: [],
|
||||
attachments: collectMessageImageAttachments({ parts: message.parts }),
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
|
||||
@ -243,7 +243,6 @@ export async function POST(request: Request) {
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
|
||||
@ -72,6 +72,73 @@ function isWorkflowImageGenerationModel(model: {
|
||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
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();
|
||||
|
||||
if (!session?.user) {
|
||||
|
||||
207
app/api/admin/access-blocklist/route.ts
Normal file
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
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_MODEL_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||
isDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import {
|
||||
normalizeProductPlanningApiConfig,
|
||||
@ -95,7 +97,7 @@ const moonshotDetailAgentConfigSchema = z.object({
|
||||
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
|
||||
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> {
|
||||
@ -191,19 +193,27 @@ export async function POST(request: Request) {
|
||||
? (existingSetting.value as Record<string, unknown>)
|
||||
: {};
|
||||
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 rawDisplayName = String(config.displayName || "").trim();
|
||||
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
|
||||
: rawModelId;
|
||||
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
|
||||
: rawDisplayName;
|
||||
const normalizedProviderName =
|
||||
String(config.providerName || "").trim() || "Moonshot AI (Official)";
|
||||
String(config.providerName || "").trim() || "Kimi Code 编程套餐";
|
||||
const normalizedApiKey =
|
||||
String(config.apiKey || "").trim() ||
|
||||
String(existingValue.apiKey || "").trim();
|
||||
@ -212,12 +222,12 @@ export async function POST(request: Request) {
|
||||
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
|
||||
return new ChatSDKError(
|
||||
"bad_request:api",
|
||||
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
||||
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@ -17,19 +17,67 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
|
||||
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(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
if (shouldUseLocalCanvasE2E(request)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createLocalCanvasProject(id),
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
@ -57,13 +105,20 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -70,6 +70,14 @@ function normalizeText(value: unknown) {
|
||||
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) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
|
||||
@ -83,6 +83,10 @@ async function readLocalFallbackFavicon() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (process.env.CANVAS_E2E_LOCAL === "true") {
|
||||
return await readLocalFallbackFavicon();
|
||||
}
|
||||
|
||||
const preferDark = requestUrl.searchParams.get("theme") === "dark";
|
||||
const settings = await getSiteSettings();
|
||||
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||
|
||||
147
app/api/internal/access-log/route.ts
Normal file
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
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 { auth } from "@/app/(auth)/auth";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@ -21,10 +27,12 @@ interface PickObjectRequest {
|
||||
imageData: string; // 带红色标记的图片 base64
|
||||
clickX: number; // 点击位置 X (0-1)
|
||||
clickY: number; // 点击位置 Y (0-1)
|
||||
mode?: "object" | "video-canvas-mark";
|
||||
}
|
||||
|
||||
interface PickObjectResult {
|
||||
name: string;
|
||||
labels?: string[];
|
||||
position: string;
|
||||
confidence: number;
|
||||
boundingBox: {
|
||||
@ -119,7 +127,51 @@ async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string)
|
||||
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();
|
||||
if (!raw) throw new Error("缺少图片数据");
|
||||
|
||||
@ -145,7 +197,10 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
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 {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@ -160,7 +215,9 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
||||
const buffer = Buffer.from(base64Body, "base64");
|
||||
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) {
|
||||
@ -174,7 +231,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body: PickObjectRequest = await request.json();
|
||||
const { imageData, clickX, clickY } = body;
|
||||
const { imageData, clickX, clickY, mode = "object" } = body;
|
||||
|
||||
logger.info("request received", {
|
||||
imageDataLength: imageData?.length,
|
||||
@ -204,12 +261,34 @@ export async function POST(request: Request) {
|
||||
}, { 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" });
|
||||
|
||||
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
||||
const prompt = `
|
||||
const videoCanvasConfig =
|
||||
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_OPTIONS.join('、')}
|
||||
`.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");
|
||||
|
||||
// 调用视觉模型,发送带红色标记的图片
|
||||
const { text: responseText, model } = await callQwen3Vl({
|
||||
imageUrl: normalizedImage,
|
||||
model:
|
||||
mode === "video-canvas-mark"
|
||||
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
|
||||
: undefined,
|
||||
prompt,
|
||||
temperature: 0.1,
|
||||
maxTokens: 300,
|
||||
@ -266,7 +357,17 @@ ${POSITION_OPTIONS.join('、')}
|
||||
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) {
|
||||
name = name.substring(0, 6);
|
||||
}
|
||||
@ -292,6 +393,7 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name,
|
||||
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
|
||||
position,
|
||||
confidence,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
||||
@ -312,6 +414,7 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name: '物品',
|
||||
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
|
||||
position,
|
||||
confidence: 0.5,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
||||
|
||||
@ -1010,6 +1010,32 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const scope = request.nextUrl.searchParams.get("scope") || "";
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
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)) {
|
||||
const scopedScope = scope as PublicSiteSettingsScope;
|
||||
const brandName =
|
||||
|
||||
@ -17,12 +17,35 @@ import {
|
||||
normalizeMembershipUserOverrides,
|
||||
resolveMembershipPlanIdForUser,
|
||||
} from "@/lib/membership/config";
|
||||
import { defaultPointsSystemConfig } from "@/lib/points/config";
|
||||
|
||||
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
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();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
|
||||
@ -8,6 +8,10 @@ import {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
|
||||
@ -192,6 +192,11 @@ import {
|
||||
sanitizeAgentWorkflowSelections,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import {
|
||||
loadCanvasChatPanelModule,
|
||||
preloadCanvasChatPanel,
|
||||
} from "@/components/canvas/canvas-chat-panel-loader";
|
||||
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
||||
import {
|
||||
buildVideoWorkflowCanvasNode,
|
||||
getVideoWorkflowNodeDefinition,
|
||||
@ -311,7 +316,7 @@ const CanvasWorkspace = dynamic(
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
loadCanvasChatPanelModule().then(
|
||||
(module) => module.CanvasChatPanel
|
||||
),
|
||||
{
|
||||
@ -1135,7 +1140,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_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
||||
const FLOATING_PANEL_INSET = 16;
|
||||
@ -1976,7 +1981,7 @@ export default function CanvasEditorLegacyPage({
|
||||
);
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode);
|
||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||
useState(FLOATING_PANEL_WIDTH);
|
||||
const [
|
||||
@ -1985,8 +1990,7 @@ export default function CanvasEditorLegacyPage({
|
||||
] = useState(false);
|
||||
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
||||
useState(false);
|
||||
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
|
||||
useState(false);
|
||||
const areSecondaryPanelsReady = true;
|
||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||||
useState(false);
|
||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||||
@ -3280,11 +3284,13 @@ export default function CanvasEditorLegacyPage({
|
||||
}, [checkCanvasAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setAreSecondaryPanelsReady(true);
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
preloadCanvasChatPanel();
|
||||
preloadCanvasChatData(id);
|
||||
|
||||
return scheduleCanvasIdleTask(() => {
|
||||
void import("@/components/canvas/CanvasFileListPanel");
|
||||
}, 700);
|
||||
}, [id]);
|
||||
|
||||
const hydrateCanvasProject = useCallback(
|
||||
(project: CanvasInitialProject) => {
|
||||
@ -10776,7 +10782,7 @@ export default function CanvasEditorLegacyPage({
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-full px-3 font-medium text-sm",
|
||||
"h-8 rounded-full px-2.5 text-[12px] font-medium",
|
||||
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 bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
||||
@ -10784,7 +10790,7 @@ export default function CanvasEditorLegacyPage({
|
||||
onClick={() => setIsPanelOpen(true)}
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageCircle className="mr-1.5 h-4 w-4" />
|
||||
<MessageCircle className="mr-1 h-3.5 w-3.5" />
|
||||
对话
|
||||
</Button>
|
||||
</div>
|
||||
@ -10797,7 +10803,7 @@ export default function CanvasEditorLegacyPage({
|
||||
"absolute z-10 ease-in-out",
|
||||
isFloatingPanelResizing
|
||||
? "transition-none"
|
||||
: "transition-all duration-300",
|
||||
: "transition-all duration-150",
|
||||
isPanelOpen
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none translate-x-[120%] opacity-0"
|
||||
@ -10836,7 +10842,7 @@ export default function CanvasEditorLegacyPage({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||
{!areSecondaryPanelsReady ? (
|
||||
{!isPanelOpen ? null : !areSecondaryPanelsReady ? (
|
||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||
) : panelMode === "chat" ? (
|
||||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||||
@ -11431,60 +11437,60 @@ export default function CanvasEditorLegacyPage({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={shouldDockFloatingToolbarToSide ? "start" : "center"}
|
||||
className="w-[420px] rounded-2xl border bg-background p-0 shadow-2xl"
|
||||
className="w-[360px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border bg-background p-0 shadow-xl"
|
||||
side={shouldDockFloatingToolbarToSide ? "right" : "top"}
|
||||
>
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="font-semibold text-foreground text-sm">
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
视频工作流节点库
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||
这里按参考 APP
|
||||
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="space-y-3 p-2">
|
||||
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
||||
const sectionNodes = getVideoWorkflowSectionNodes(
|
||||
section.id
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2" key={section.id}>
|
||||
<div className="px-2">
|
||||
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
|
||||
<div className="space-y-1.5" key={section.id}>
|
||||
<div className="px-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{section.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{sectionNodes.map((nodeDefinition) => (
|
||||
<DropdownMenuItem
|
||||
className="flex items-start gap-3 rounded-xl px-3 py-3"
|
||||
className="flex items-start gap-2 rounded-[var(--ui-radius-control)] px-2.5 py-2"
|
||||
key={nodeDefinition.type}
|
||||
onClick={() =>
|
||||
handleAddWorkflowNode(nodeDefinition.type)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mt-0.5 h-2.5 w-2.5 rounded-full"
|
||||
className="mt-1 h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
nodeDefinition.accentColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{nodeDefinition.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{nodeDefinition.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs leading-5">
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{nodeDefinition.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,8 +5,9 @@ import { useParams, useSearchParams } from "next/navigation";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||
import type { CanvasInitialProject } from "@/types/canvas";
|
||||
import { loadCanvasEditorModule } from "./canvas-editor-loader";
|
||||
|
||||
const CanvasEditorLegacyPage = dynamic(() => import("./CanvasEditorLegacyPageClient"), {
|
||||
const CanvasEditorLegacyPage = dynamic(loadCanvasEditorModule, {
|
||||
loading: () => <CanvasRouteLoader label="正在加载画布..." />,
|
||||
ssr: false,
|
||||
});
|
||||
@ -66,7 +67,7 @@ export function CanvasPageClientRouter({
|
||||
if (isVideoMode) {
|
||||
return (
|
||||
<CanvasLoadingBrandProvider value={loadingBrand}>
|
||||
<LiblibVideoCanvasReplica canvasId={id} />
|
||||
<LiblibVideoCanvasReplica canvasId={id} initialProject={initialProject} />
|
||||
</CanvasLoadingBrandProvider>
|
||||
);
|
||||
}
|
||||
|
||||
12
app/canvas/[id]/canvas-editor-loader.ts
Normal file
12
app/canvas/[id]/canvas-editor-loader.ts
Normal file
@ -0,0 +1,12 @@
|
||||
type CanvasEditorModule = typeof import("./CanvasEditorLegacyPageClient");
|
||||
|
||||
let canvasEditorModulePromise: Promise<CanvasEditorModule> | null = null;
|
||||
|
||||
export function loadCanvasEditorModule() {
|
||||
canvasEditorModulePromise ??= import("./CanvasEditorLegacyPageClient");
|
||||
return canvasEditorModulePromise;
|
||||
}
|
||||
|
||||
export function preloadCanvasEditor() {
|
||||
void loadCanvasEditorModule();
|
||||
}
|
||||
@ -50,10 +50,13 @@ export default async function CanvasEditorPage({
|
||||
}: CanvasEditorPageProps) {
|
||||
const { id } = await params;
|
||||
const resolvedSearchParams = searchParams ? await searchParams : {};
|
||||
const [siteSettings, initialProject] = await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const bypassLocalDatabase = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
const [siteSettings, initialProject] = bypassLocalDatabase
|
||||
? [null, null]
|
||||
: await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const loadingBrand = {
|
||||
siteLogo: siteSettings?.siteLogo,
|
||||
siteLogoDark: siteSettings?.siteLogoDark,
|
||||
|
||||
136
app/globals.css
136
app/globals.css
@ -92,6 +92,15 @@
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
/* border radius unit */
|
||||
--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%);
|
||||
--chat-user-bubble-bg: hsl(214 100% 50%);
|
||||
--chat-user-bubble-foreground: hsl(0 0% 100%);
|
||||
@ -257,6 +266,29 @@
|
||||
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-tutorial,
|
||||
@ -370,3 +402,107 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,30 +52,30 @@ export function AgentExecutionSnapshotDialog({
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !snapshot ? (
|
||||
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-dashed p-5 text-[13px] text-muted-foreground">
|
||||
暂无快照详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">风格化</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">风格化</div>
|
||||
<div className="mt-1 font-medium">{snapshot.presetName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
类型:{snapshot.presetType === "video" ? "视频" : "生图"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
分类:{snapshot.categoryName || "未分类"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">模型</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">模型</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{snapshot.resolvedModel?.displayName ||
|
||||
snapshot.defaultModel?.displayName ||
|
||||
"未记录"}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
槽位:{snapshot.slotCount} 个
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@ -84,8 +84,8 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">本次使用的 3 张素材图</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">本次使用的 3 张素材图</div>
|
||||
{uploadedImageUrls.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{uploadedImageUrls.map((url, index) => (
|
||||
@ -107,18 +107,18 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
|
||||
{snapshot.userInputText ? (
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-[13px] text-muted-foreground">
|
||||
{snapshot.userInputText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">槽位详情</div>
|
||||
<div className="text-[13px] font-medium">槽位详情</div>
|
||||
{snapshot.slots.map((slot, index) => (
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-xl border p-3">
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{slot.title || slot.uploadLabel || `槽位 ${slot.slotIndex + 1}`}
|
||||
|
||||
@ -189,7 +189,7 @@ export function AngleEditDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="bg-black"
|
||||
className="max-w-4xl p-0 gap-0 overflow-hidden border border-border shadow-2xl !bg-white dark:!bg-zinc-950 sm:rounded-[20px]"
|
||||
className="max-w-4xl gap-0 overflow-hidden border border-border !bg-white p-0 shadow-2xl sm:rounded-[var(--ui-radius-panel)] dark:!bg-zinc-950"
|
||||
onPointerDownCapture={stopDialogEventCapture}
|
||||
onPointerMoveCapture={stopDialogEventCapture}
|
||||
onPointerUpCapture={stopDialogEventCapture}
|
||||
|
||||
@ -63,7 +63,7 @@ function MaterialImageCard({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-xl border bg-background text-left transition-all",
|
||||
"group overflow-hidden rounded-[var(--ui-radius-control)] border bg-background text-left transition-all",
|
||||
selected
|
||||
? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.25)]"
|
||||
: "border-border hover:border-primary/45"
|
||||
@ -91,11 +91,11 @@ function MaterialImageCard({
|
||||
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<div className="truncate text-[13px] font-medium">
|
||||
{image.label || image.fileName || "白底图"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{image.group === "fixed" ? "固定角度" : "补充图"}</span>
|
||||
<span className="truncate">{image.fileName || image.key}</span>
|
||||
</div>
|
||||
@ -114,7 +114,7 @@ function SkuOptionCard({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors",
|
||||
"flex items-center gap-2.5 rounded-[var(--ui-radius-control)] border bg-background p-2.5 text-left transition-colors",
|
||||
option.hasWhiteBackground
|
||||
? "hover:border-primary/55 hover:bg-muted/30"
|
||||
: "cursor-not-allowed opacity-55"
|
||||
@ -123,7 +123,7 @@ function SkuOptionCard({
|
||||
onClick={() => onSelect(option.skuName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-muted/40">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[6px] border bg-muted/40">
|
||||
{option.previewImageUrl ? (
|
||||
<AgentPreviewImage
|
||||
alt={option.previewFileName || option.skuName}
|
||||
@ -139,8 +139,8 @@ function SkuOptionCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{option.skuName}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<div className="truncate text-[13px] font-medium">{option.skuName}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{option.hasWhiteBackground
|
||||
? `${option.whiteBackgroundCount} 张白底图`
|
||||
: "暂无白底图"}
|
||||
@ -266,9 +266,9 @@ export function CanvasAddImageDialog({
|
||||
return (
|
||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 pt-5 pb-4">
|
||||
<DialogHeader className="border-b px-5 pb-3 pt-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@ -276,11 +276,11 @@ export function CanvasAddImageDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto px-6 py-5">
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||
<Button
|
||||
className="h-11 justify-start gap-2 rounded-xl"
|
||||
className="h-9 justify-start gap-2"
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
closeDialog();
|
||||
@ -293,14 +293,14 @@ export function CanvasAddImageDialog({
|
||||
</Button>
|
||||
|
||||
{materialSearchEnabled ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2.5 sm:flex-row">
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setQueryMode(value === "sku" ? "sku" : "spu")
|
||||
}
|
||||
value={queryMode}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-full rounded-xl sm:w-[118px]">
|
||||
<SelectTrigger className="h-9 w-full sm:w-[108px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -310,7 +310,7 @@ export function CanvasAddImageDialog({
|
||||
</Select>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
className="h-11 rounded-xl pr-10"
|
||||
className="h-9 pr-9"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setErrorText(null);
|
||||
@ -338,7 +338,7 @@ export function CanvasAddImageDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
className="h-11 gap-2 rounded-xl"
|
||||
className="h-9 gap-2"
|
||||
disabled={isSearching}
|
||||
onClick={() => void handleSearch()}
|
||||
type="button"
|
||||
@ -354,14 +354,14 @@ export function CanvasAddImageDialog({
|
||||
) : null}
|
||||
</div>
|
||||
{!materialSearchEnabled ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed bg-background/70 px-3 py-2 text-muted-foreground text-xs">
|
||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorText ? (
|
||||
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
@ -370,8 +370,8 @@ export function CanvasAddImageDialog({
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">选择 SKU</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="text-[13px] font-medium">选择 SKU</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。
|
||||
</div>
|
||||
</div>
|
||||
@ -392,11 +392,11 @@ export function CanvasAddImageDialog({
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[13px] font-medium">
|
||||
白底图素材
|
||||
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
共 {images.length} 张,已选 {selectedImages.length} 张。
|
||||
</div>
|
||||
</div>
|
||||
@ -432,13 +432,13 @@ export function CanvasAddImageDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? (
|
||||
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-2xl border border-dashed text-muted-foreground text-sm">
|
||||
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-[var(--ui-radius-panel)] border border-dashed text-[13px] text-muted-foreground">
|
||||
当前没有可添加的白底图。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<DialogFooter className="border-t px-5 py-3">
|
||||
<Button
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
@ -135,7 +135,6 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p
|
||||
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import { Response } from "@/components/elements/response";
|
||||
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
|
||||
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
|
||||
import { DetailSurfaceInspectorPanel } from "@/components/canvas/DetailSurfaceInspectorPanel";
|
||||
@ -261,6 +260,7 @@ import {
|
||||
} from "@/components/canvas/chat-panel/canvas-chat-session";
|
||||
import {
|
||||
buildCanvasHistoryMessagesSignature,
|
||||
collectMessageImageAttachments,
|
||||
extractAssistantImageUrls,
|
||||
extractCanvasModelName,
|
||||
extractImageUrls,
|
||||
@ -282,6 +282,39 @@ import {
|
||||
stripMediaMarkup,
|
||||
} from "@/components/canvas/chat-panel/canvas-chat-media";
|
||||
|
||||
type ResponseModule = typeof import("@/components/elements/response");
|
||||
type ResponseProps = ComponentProps<ResponseModule["Response"]>;
|
||||
|
||||
const MarkdownResponse = lazy(async () => {
|
||||
const module = await import("@/components/elements/response");
|
||||
return { default: module.Response };
|
||||
});
|
||||
|
||||
function MarkdownResponseFallback({
|
||||
children,
|
||||
className,
|
||||
}: Pick<ResponseProps, "children" | "className">) {
|
||||
return (
|
||||
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Response(props: ResponseProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<MarkdownResponseFallback className={props.className}>
|
||||
{props.children}
|
||||
</MarkdownResponseFallback>
|
||||
}
|
||||
>
|
||||
<MarkdownResponse {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// 标签悬停预览信息
|
||||
interface TagPreviewInfo {
|
||||
url: string;
|
||||
@ -1730,20 +1763,20 @@ function CanvasAssistantTypingPlaceholder({
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="flex max-w-[min(78%,680px)] flex-col items-start gap-2">
|
||||
{hint ? (
|
||||
<div className="rounded-[18px] bg-[#f4f5f7] px-4 py-3 text-[15px] leading-6 text-[#2f343d] shadow-sm dark:bg-white/8 dark:text-zinc-200">
|
||||
<div className="rounded-[var(--ui-radius-panel)] bg-[#f4f5f7] px-3 py-2.5 text-[13px] leading-5 text-[#2f343d] shadow-sm dark:bg-white/8 dark:text-zinc-200">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
aria-label={label}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[16px] bg-[#f4f5f7] px-3.5 text-[15px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-[var(--ui-radius-control)] bg-[#f4f5f7] px-2.5 text-[12px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{label}</span>
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 ml-1 rounded-full px-2 py-0.5 text-[12px] font-semibold text-[#5d6675] transition-colors hover:bg-black/5 hover:text-[#242a33] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
className="-mr-1 ml-1 rounded-[5px] px-1.5 py-0.5 text-[11px] font-semibold text-[#5d6675] transition-colors hover:bg-black/5 hover:text-[#242a33] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -1781,13 +1814,13 @@ function CanvasComposerUploadProgressDialog({
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/35 px-4 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-[360px] rounded-[22px] border border-white/12 bg-[#111215]/95 p-5 text-white shadow-2xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-[#e0ff3a]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<div className="w-full max-w-[340px] rounded-[var(--ui-radius-panel)] border border-white/12 bg-[#111215]/95 p-4 text-white shadow-xl">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] bg-white/10 text-[#e0ff3a]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[15px] font-semibold leading-5">
|
||||
<div className="text-[14px] font-semibold leading-5">
|
||||
{progress.title}
|
||||
</div>
|
||||
{progress.fileName ? (
|
||||
@ -5274,7 +5307,7 @@ export function CanvasChatPanel({
|
||||
const getComposerModeMenuItemClass = useCallback(
|
||||
(mode: CanvasComposerMode) =>
|
||||
cn(
|
||||
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal outline-none transition-colors focus:text-[#1f2937] dark:focus:text-white [&_svg]:!size-5",
|
||||
"h-9 cursor-pointer rounded-[var(--ui-radius-control)] px-2.5 text-[13px] font-normal outline-none transition-colors focus:text-[#1f2937] dark:focus:text-white [&_svg]:!size-4",
|
||||
isVideoToolbarDark
|
||||
? "text-zinc-100 focus:bg-white/12 focus:text-white"
|
||||
: "text-[#2e3238] focus:bg-[#eef0f3] dark:text-zinc-100 dark:focus:bg-white/12",
|
||||
@ -11487,6 +11520,7 @@ export function CanvasChatPanel({
|
||||
id: userMessageId,
|
||||
role: "user",
|
||||
parts,
|
||||
attachments: effectiveAttachments,
|
||||
createdAt: optimisticCreatedAt,
|
||||
metadata: optimisticUserMetadata,
|
||||
} as ChatMessage;
|
||||
@ -12964,13 +12998,13 @@ export function CanvasChatPanel({
|
||||
const renderModelSelectContent = () => (
|
||||
<SelectContent
|
||||
className={cn(
|
||||
"w-[360px] max-w-[calc(100vw-32px)] max-h-[420px] overflow-hidden rounded-[22px] border border-[#cfd4dc] bg-white p-0 text-[#252b33] shadow-[0_18px_44px_rgba(15,23,42,0.16)]",
|
||||
"max-h-[360px] w-[292px] max-w-[calc(100vw-24px)] overflow-hidden rounded-[var(--ui-radius-panel)] border border-[#cfd4dc] bg-white p-0 text-[#252b33] shadow-[0_14px_36px_rgba(15,23,42,0.14)]",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background/95 shadow-[0_14px_30px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl"
|
||||
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[392px] space-y-1 overflow-y-auto p-2">
|
||||
<div className="max-h-[344px] space-y-0.5 overflow-y-auto p-1.5">
|
||||
{selectableModels.map((model) => {
|
||||
const discountLabel = getImageModelCreditDiscountLabel(
|
||||
model.imageGenConfig
|
||||
@ -12982,7 +13016,7 @@ export function CanvasChatPanel({
|
||||
value={model.id}
|
||||
disabled={model.selectable === false}
|
||||
className={cn(
|
||||
"h-12 rounded-[14px] px-4 text-[17px] font-medium [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
"h-9 rounded-[var(--ui-radius-control)] px-2.5 text-[13px] font-normal [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
isVideoWorkspace
|
||||
? "text-foreground focus:bg-background focus:text-foreground data-[state=checked]:bg-background data-[state=checked]:text-foreground"
|
||||
: "text-[#2f3640] focus:bg-[#f4f5f6] focus:text-[#1f2937] data-[state=checked]:bg-transparent data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:text-white",
|
||||
@ -12990,10 +13024,10 @@ export function CanvasChatPanel({
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none",
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background"
|
||||
: "border border-transparent bg-white dark:border-white/20 dark:bg-white"
|
||||
@ -13015,7 +13049,7 @@ export function CanvasChatPanel({
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center text-[13px] font-medium leading-none",
|
||||
"flex h-full w-full items-center justify-center text-[10px] font-medium leading-none",
|
||||
isVideoWorkspace ? "text-muted-foreground" : "text-[#6b7280]"
|
||||
)}
|
||||
>
|
||||
@ -13023,7 +13057,7 @@ export function CanvasChatPanel({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 whitespace-nowrap text-[17px] font-medium leading-none tracking-[-0.01em]">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-normal leading-5">
|
||||
{model.displayName}
|
||||
</span>
|
||||
</span>
|
||||
@ -13031,7 +13065,7 @@ export function CanvasChatPanel({
|
||||
{discountLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-[10px] border px-2 py-0.5 text-[11px] font-semibold leading-none",
|
||||
"rounded-[5px] border px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
isVideoWorkspace
|
||||
? "border-border/70 bg-muted/80 text-foreground/80"
|
||||
: "border-[#d0d4dc] bg-[#eef1f5] text-[#5c6574] dark:border-white/15 dark:bg-white/10 dark:text-zinc-200"
|
||||
@ -13043,7 +13077,7 @@ export function CanvasChatPanel({
|
||||
{model.availabilityLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-[10px] border px-2 py-0.5 text-[11px] font-semibold leading-none",
|
||||
"rounded-[5px] border px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
model.availabilityStatus === "maintenance"
|
||||
? isVideoWorkspace
|
||||
? "border-amber-400/30 bg-amber-500/10 text-amber-300"
|
||||
@ -13057,7 +13091,7 @@ export function CanvasChatPanel({
|
||||
</span>
|
||||
) : null}
|
||||
{modelSelectValue === model.id ? (
|
||||
<Check className={cn("h-5 w-5", isVideoWorkspace ? "text-foreground" : "text-[#1f2937]")} />
|
||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-foreground" : "text-[#1f2937]")} />
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
@ -13172,6 +13206,7 @@ export function CanvasChatPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-canvas-chat-panel="ready"
|
||||
className={cn(
|
||||
"relative flex h-full min-h-0 flex-col",
|
||||
isWorkspacePresentation
|
||||
@ -13183,14 +13218,14 @@ export function CanvasChatPanel({
|
||||
>
|
||||
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
|
||||
{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="flex flex-col items-center gap-4 p-6 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">
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<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-3 p-5 text-center">
|
||||
<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-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">正在从画布选择图片</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h3 className="text-base font-semibold text-foreground">正在从画布选择图片</h3>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
请点击画布中的任意图片以填入
|
||||
<span className="font-medium text-primary">
|
||||
“{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}”
|
||||
@ -13209,21 +13244,21 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
)}
|
||||
{!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">
|
||||
<span
|
||||
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
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
</span>
|
||||
<h2
|
||||
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"
|
||||
)}
|
||||
title={displayCanvasName}
|
||||
@ -13277,7 +13312,7 @@ export function CanvasChatPanel({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
"h-7 w-7 rounded-full",
|
||||
isVideoWorkspace
|
||||
? "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"
|
||||
@ -13285,14 +13320,14 @@ export function CanvasChatPanel({
|
||||
onClick={createNewChatSession}
|
||||
title="新建聊天窗口"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isClawdMode ? (
|
||||
<div className="relative" ref={clawdHistoryMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 rounded-full px-2.5 text-xs font-semibold",
|
||||
"h-7 rounded-full px-2 text-[11px] font-semibold",
|
||||
isVideoWorkspace
|
||||
? "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",
|
||||
@ -13310,7 +13345,7 @@ export function CanvasChatPanel({
|
||||
}}
|
||||
title="Claw 对话历史"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>Claw</span>
|
||||
</Button>
|
||||
{isClawdHistoryMenuOpen && (
|
||||
@ -13392,7 +13427,7 @@ export function CanvasChatPanel({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
"h-7 w-7 rounded-full",
|
||||
isVideoWorkspace
|
||||
? "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",
|
||||
@ -13401,7 +13436,7 @@ export function CanvasChatPanel({
|
||||
onClick={() => setIsSessionMenuOpen((open) => !open)}
|
||||
title="聊天窗口记录"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isSessionMenuOpen && (
|
||||
<div
|
||||
@ -13458,7 +13493,7 @@ export function CanvasChatPanel({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
"h-7 w-7 rounded-full",
|
||||
isVideoWorkspace
|
||||
? "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"
|
||||
@ -13473,13 +13508,13 @@ export function CanvasChatPanel({
|
||||
}}
|
||||
title="已生成文件列表"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
"h-7 w-7 rounded-full",
|
||||
isVideoWorkspace
|
||||
? "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"
|
||||
@ -13488,7 +13523,7 @@ export function CanvasChatPanel({
|
||||
disabled={!onClose}
|
||||
title="收起聊天面板"
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
<ArrowRightToLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -13504,26 +13539,26 @@ export function CanvasChatPanel({
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-x-hidden",
|
||||
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
|
||||
isWorkspacePresentation ? "p-6" : "p-4",
|
||||
isWorkspacePresentation ? "p-5" : "p-3",
|
||||
isPlainCanvasEmptyState
|
||||
? "pt-0"
|
||||
: isDetailGenerationMode && renderableVisibleMessages.length === 0
|
||||
? "pt-10"
|
||||
: "pt-4"
|
||||
: "pt-3"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
)}>
|
||||
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? (
|
||||
isWorkspacePresentation ? (
|
||||
<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="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]">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
<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-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-5 w-5" />
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
<div className="text-xl font-semibold text-foreground">
|
||||
{workspaceEmptyTitle}
|
||||
</div>
|
||||
{workspaceEmptyDescription ? (
|
||||
@ -13633,16 +13668,16 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</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">
|
||||
<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,{" "}
|
||||
{greetingUserName ? (
|
||||
<span className="text-[#2563ff]">{greetingUserName}</span>
|
||||
) : null}{" "}
|
||||
{greetingPeriodText}
|
||||
</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]">
|
||||
{Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0}
|
||||
@ -13652,13 +13687,13 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<h3 className="text-[16px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||
<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-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||
{canvasSkillTagsTitle}
|
||||
</h3>
|
||||
<div
|
||||
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
|
||||
? "max-h-none overflow-visible"
|
||||
: "max-h-[76px] overflow-hidden"
|
||||
@ -13686,7 +13721,7 @@ export function CanvasChatPanel({
|
||||
onDragEnd={handleCanvasSkillTagDragEnd}
|
||||
onClick={() => handleSelectCanvasSkillTag(tag)}
|
||||
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
|
||||
? "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",
|
||||
@ -13740,6 +13775,10 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
|
||||
{renderableVisibleMessages.map((message, index) => {
|
||||
const userImageAttachments =
|
||||
message.role === "user"
|
||||
? collectMessageImageAttachments(message as any)
|
||||
: [];
|
||||
const assistantTextParts = message.role === "assistant"
|
||||
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
|
||||
: [];
|
||||
@ -13831,7 +13870,7 @@ export function CanvasChatPanel({
|
||||
return (
|
||||
<div key={message.id} className="space-y-3">
|
||||
{showDateSeparator && messageTimestamp ? (
|
||||
<div className="text-[14px] font-medium text-[#a6adba]">
|
||||
<div className="text-[11px] font-medium text-[#9ca3af]">
|
||||
{formatMessageDateLabel(messageTimestamp)}
|
||||
</div>
|
||||
) : null}
|
||||
@ -13852,7 +13891,7 @@ export function CanvasChatPanel({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border px-3 py-2 shadow-sm",
|
||||
"rounded-[12px] border px-3 py-2 shadow-sm",
|
||||
isVideoWorkspace
|
||||
? "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"
|
||||
@ -13862,89 +13901,54 @@ export function CanvasChatPanel({
|
||||
{(() => {
|
||||
const renderedUserParts: React.ReactNode[] = [];
|
||||
|
||||
message.parts?.forEach((part: any, idx: number) => {
|
||||
const fileUrl =
|
||||
typeof part?.url === "string" && part.url.trim()
|
||||
? part.url
|
||||
: typeof part?.data === "string" && part.data.trim()
|
||||
? part.data
|
||||
: "";
|
||||
|
||||
if (part.type === 'file' && fileUrl) {
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`part-${idx}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background"
|
||||
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={buildPreviewImageUrl(fileUrl, 48)}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== fileUrl) {
|
||||
event.currentTarget.src = fileUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-current">{part.name || 'Image'}</span>
|
||||
userImageAttachments.forEach((attachment, idx) => {
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`image-${attachment.url}-${idx}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-[var(--ui-radius-control)] px-1.5 py-1 text-[11px]",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background"
|
||||
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={buildPreviewImageUrl(attachment.url, 48)}
|
||||
alt=""
|
||||
className="h-4 w-4 rounded-[3px] object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== attachment.url) {
|
||||
event.currentTarget.src = attachment.url;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="max-w-[72px] truncate text-current">
|
||||
{attachment.name || "图片"}
|
||||
</span>
|
||||
);
|
||||
return;
|
||||
}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
message.parts?.forEach((part: any, idx: number) => {
|
||||
if (part.type === 'text' && part.text) {
|
||||
const text = String(part.text)
|
||||
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
|
||||
.trim();
|
||||
if (!text) return;
|
||||
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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!message.parts?.some((p: any) => p.type === 'file') &&
|
||||
(message as any).attachments?.length > 0
|
||||
) {
|
||||
(message as any).attachments.forEach((att: any, idx: number) => {
|
||||
if (!att?.url) return;
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`att-${idx}`}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
|
||||
>
|
||||
<img
|
||||
src={buildPreviewImageUrl(att.url, 48)}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== att.url) {
|
||||
event.currentTarget.src = att.url;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (renderedUserParts.length === 0) {
|
||||
const fallbackText =
|
||||
extractUserTextPrompt(message as any) ||
|
||||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
|
||||
renderedUserParts.push(
|
||||
<span key="fallback-text" className="text-sm text-current">
|
||||
<span key="fallback-text" className="text-[13px] leading-5 text-current">
|
||||
{fallbackText}
|
||||
</span>
|
||||
);
|
||||
@ -13973,7 +13977,7 @@ export function CanvasChatPanel({
|
||||
<>
|
||||
<div
|
||||
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
|
||||
? "bg-transparent px-0 py-0"
|
||||
: "bg-transparent px-0 py-0"
|
||||
@ -14054,7 +14058,7 @@ export function CanvasChatPanel({
|
||||
? renderPlainText(analysisSection.before, `before-${i}`)
|
||||
: null}
|
||||
<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}
|
||||
onOpenChange={(open) =>
|
||||
setAnalysisCollapseState((prev) => ({
|
||||
@ -14090,7 +14094,7 @@ export function CanvasChatPanel({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
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",
|
||||
"outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
@ -14442,14 +14446,14 @@ export function CanvasChatPanel({
|
||||
<div
|
||||
className={cn(
|
||||
isWorkspacePresentation
|
||||
? "border-t bg-white/92 p-5 backdrop-blur-sm dark:bg-zinc-950/92"
|
||||
: "bg-white px-[10px] pb-[10px] pt-0 dark:bg-zinc-950"
|
||||
? "border-t bg-white/92 p-4 backdrop-blur-sm dark:bg-zinc-950/92"
|
||||
: "bg-white px-2 pb-2 pt-0 dark:bg-zinc-950"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
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",
|
||||
"min-h-[184px] rounded-[28px]"
|
||||
"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-[148px] rounded-[18px]"
|
||||
)}
|
||||
data-canvas-chat-input-anchor="true"
|
||||
>
|
||||
@ -15207,12 +15211,12 @@ export function CanvasChatPanel({
|
||||
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-3 pb-0 pt-3 sm:px-4",
|
||||
"relative px-3 pb-0 pt-2.5",
|
||||
isDetailGenerationMode
|
||||
? "min-h-[112px]"
|
||||
? "min-h-[96px]"
|
||||
: activeComposerMode === "image"
|
||||
? "min-h-[118px]"
|
||||
: "min-h-[132px]"
|
||||
? "min-h-[92px]"
|
||||
: "min-h-[104px]"
|
||||
)}
|
||||
>
|
||||
{!isWorkspacePresentation &&
|
||||
@ -15222,7 +15226,7 @@ export function CanvasChatPanel({
|
||||
<div className="mb-2 flex">
|
||||
<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={() => {
|
||||
setSelectedSkillTagId(null);
|
||||
pendingSelectedClawdSkillTagRef.current = null;
|
||||
@ -15246,9 +15250,9 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
) : null}
|
||||
{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
|
||||
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}」快捷标签提示词`}
|
||||
>
|
||||
{activeClawdSkillTag.iconUrl ? (
|
||||
@ -15659,7 +15663,7 @@ export function CanvasChatPanel({
|
||||
className={cn(
|
||||
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
|
||||
? "px-1"
|
||||
: "px-4 pt-3"
|
||||
: "px-2 pt-2"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -15669,10 +15673,10 @@ export function CanvasChatPanel({
|
||||
className={cn(
|
||||
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
|
||||
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"
|
||||
? "min-h-[44px] text-[16px] leading-7 sm:text-[16px] sm:leading-7"
|
||||
: "min-h-[64px] text-[16px] leading-7 sm:text-[17px] sm:leading-7",
|
||||
? "min-h-[38px] text-[16px] leading-6 md:text-[14px]"
|
||||
: "min-h-[50px] text-[16px] leading-6 md:text-[14px]",
|
||||
isVideoWorkspace
|
||||
? "text-foreground empty:before:text-muted-foreground/70"
|
||||
: "text-[#1f2328] empty:before:text-[#a8abb2] dark:text-zinc-100 dark:empty:before:text-zinc-500"
|
||||
@ -15892,7 +15896,7 @@ export function CanvasChatPanel({
|
||||
{/* Bottom Toolbar */}
|
||||
<div
|
||||
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">
|
||||
@ -15967,7 +15971,7 @@ export function CanvasChatPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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
|
||||
? "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"
|
||||
@ -15975,28 +15979,28 @@ export function CanvasChatPanel({
|
||||
>
|
||||
<span className="flex min-w-0 items-center">
|
||||
{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" ? (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-5 w-5 shrink-0"
|
||||
className="mr-1 h-4 w-4 shrink-0"
|
||||
fallback={Video}
|
||||
item={getToolbarItem("chatModeVideoActive")}
|
||||
/>
|
||||
) : activeComposerMode === "detail" ? (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-5 w-5 shrink-0"
|
||||
className="mr-1 h-4 w-4 shrink-0"
|
||||
fallback={LayoutGrid}
|
||||
item={getToolbarItem("chatModeDetailActive")}
|
||||
/>
|
||||
) : activeComposerMode === "image" ? (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-5 w-5 shrink-0"
|
||||
className="mr-1 h-4 w-4 shrink-0"
|
||||
fallback={ImagePlus}
|
||||
item={getToolbarItem("chatModeImageActive")}
|
||||
/>
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-5 w-5 shrink-0"
|
||||
className="mr-1 h-4 w-4 shrink-0"
|
||||
fallback={Bot}
|
||||
item={null}
|
||||
/>
|
||||
@ -16015,7 +16019,7 @@ export function CanvasChatPanel({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
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-[164px] space-y-0.5 rounded-[var(--ui-radius-panel)] border border-[#d8dce3] bg-white p-1 text-[#20242a] shadow-[0_14px_36px_rgba(15,23,42,0.14)]",
|
||||
isVideoToolbarDark
|
||||
? "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"
|
||||
@ -16243,8 +16247,8 @@ export function CanvasChatPanel({
|
||||
size={isWorkspacePresentation ? "default" : "icon"}
|
||||
className={cn(
|
||||
isWorkspacePresentation
|
||||
? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0"
|
||||
: "h-8 w-8 rounded-full 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-150 flex-shrink-0",
|
||||
isClawdStopAvailable
|
||||
? "bg-rose-500 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400"
|
||||
: isVideoToolbarDark
|
||||
|
||||
@ -127,8 +127,8 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/95 backdrop-blur-sm">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-md flex-col gap-0 overflow-hidden bg-background/95 p-0 backdrop-blur-sm">
|
||||
<DialogHeader className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>已生成文件列表</DialogTitle>
|
||||
<Button size="sm" onClick={handleCreateNew} className="h-8">
|
||||
@ -139,14 +139,14 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
<DialogDescription className="hidden">画布列表</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<div className="py-10 text-center text-[13px] text-muted-foreground">
|
||||
<FileImage className="mx-auto mb-2 h-8 w-8 opacity-20" />
|
||||
<p>暂无画布历史</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -156,7 +156,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
key={project.id}
|
||||
onClick={() => handleSwitch(project.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border",
|
||||
"group flex cursor-pointer items-center gap-2.5 rounded-[var(--ui-radius-control)] border p-2 transition-all",
|
||||
"hover:bg-muted/50 hover:shadow-sm",
|
||||
project.id === currentId
|
||||
? "bg-primary/5 border-primary/20 ring-1 ring-primary/10"
|
||||
@ -164,19 +164,19 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
)}
|
||||
>
|
||||
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
|
||||
<FileImage className="h-5 w-5 text-muted-foreground/40" />
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border bg-muted/50">
|
||||
<FileImage className="h-4 w-4 text-muted-foreground/40" />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={cn(
|
||||
"font-medium text-sm truncate",
|
||||
"truncate text-[13px] font-medium",
|
||||
project.id === currentId && "text-primary"
|
||||
)}>
|
||||
{project.title || "未命名画布"}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(project.updatedAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
@ -188,7 +188,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
|
||||
className="h-7 w-7 text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => handleDelete(e, project.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@ -210,7 +210,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground/50">
|
||||
<div className="py-3 text-center text-[11px] text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -34,24 +34,24 @@ export function CanvasImageDownloadProgressDialog({
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/42 px-4 backdrop-blur-[2px]"
|
||||
data-canvas-download-progress="true"
|
||||
>
|
||||
<div className="w-full max-w-[380px] rounded-[18px] border border-white/12 bg-[#111317] p-5 text-white shadow-[0_24px_80px_rgba(0,0,0,0.42)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
|
||||
<div className="w-full max-w-[360px] rounded-[var(--ui-radius-panel)] border border-white/12 bg-[#111317] p-4 text-white shadow-[0_20px_64px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-[var(--ui-radius-control)] bg-white/10 text-cyan-200">
|
||||
{progress.phase === "saving" ? (
|
||||
<Download className="h-5 w-5" />
|
||||
<Download className="h-4 w-4" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold">下载原图</div>
|
||||
<div className="text-[14px] font-semibold">下载原图</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--ui-radius-control)] text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
onClick={onCancel}
|
||||
title="取消下载"
|
||||
type="button"
|
||||
@ -61,7 +61,7 @@ export function CanvasImageDownloadProgressDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas";
|
||||
import { ImageNode } from "./nodes/ImageNode";
|
||||
import { AgentSlotNode } from "./nodes/AgentSlotNode";
|
||||
import { Model3DNode } from "./nodes/Model3DNode";
|
||||
import { TextNode } from "./nodes/TextNode";
|
||||
import { UiScreenNode } from "./nodes/UiScreenNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||
import { World3DNode } from "./nodes/World3DNode";
|
||||
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -68,6 +66,76 @@ import {
|
||||
} from "@/lib/canvas/agent-pending-requests";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
|
||||
type ImageNodeModule = typeof import("./nodes/ImageNode");
|
||||
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
|
||||
type World3DNodeModule = typeof import("./nodes/World3DNode");
|
||||
|
||||
let imageNodeModulePromise: Promise<ImageNodeModule> | null = null;
|
||||
let model3DNodeModulePromise: Promise<Model3DNodeModule> | null = null;
|
||||
let world3DNodeModulePromise: Promise<World3DNodeModule> | null = null;
|
||||
|
||||
function loadImageNodeModule() {
|
||||
imageNodeModulePromise ??= import("./nodes/ImageNode");
|
||||
return imageNodeModulePromise;
|
||||
}
|
||||
|
||||
function loadModel3DNodeModule() {
|
||||
model3DNodeModulePromise ??= import("./nodes/Model3DNode");
|
||||
return model3DNodeModulePromise;
|
||||
}
|
||||
|
||||
function loadWorld3DNodeModule() {
|
||||
world3DNodeModulePromise ??= import("./nodes/World3DNode");
|
||||
return world3DNodeModulePromise;
|
||||
}
|
||||
|
||||
function preloadCanvas3DNodeRenderers() {
|
||||
void loadModel3DNodeModule();
|
||||
void loadWorld3DNodeModule();
|
||||
}
|
||||
|
||||
function preloadCanvasImageNodeRenderer() {
|
||||
void loadImageNodeModule();
|
||||
}
|
||||
|
||||
function CanvasImageNodeLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f8f9fb] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
|
||||
正在加载图片节点
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Canvas3DNodeLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f6f7f9] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
|
||||
正在加载 3D 预览
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ImageNode = dynamic(
|
||||
() => loadImageNodeModule().then((module) => module.ImageNode),
|
||||
{
|
||||
loading: () => <CanvasImageNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
const Model3DNode = dynamic(
|
||||
() => loadModel3DNodeModule().then((module) => module.Model3DNode),
|
||||
{
|
||||
loading: () => <Canvas3DNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
const World3DNode = dynamic(
|
||||
() => loadWorld3DNodeModule().then((module) => module.World3DNode),
|
||||
{
|
||||
loading: () => <Canvas3DNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const CANVAS_NO_DRAG_SELECTOR =
|
||||
'[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *';
|
||||
const CANVAS_DRAG_CANCEL_SELECTOR = [
|
||||
@ -352,6 +420,14 @@ export function CanvasWorkspace({
|
||||
const tool = useCanvasStore(canvasSelectors.tool);
|
||||
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
const hasImageNodes = useMemo(
|
||||
() => nodes.some((node) => node.type === "image"),
|
||||
[nodes]
|
||||
);
|
||||
const has3DNodes = useMemo(
|
||||
() => nodes.some((node) => node.type === "model3d" || node.type === "world3d"),
|
||||
[nodes]
|
||||
);
|
||||
const viewportRef = useRef(viewport);
|
||||
const pendingViewportRef = useRef<typeof viewport | null>(null);
|
||||
const pendingViewportRafRef = useRef<number | null>(null);
|
||||
@ -370,6 +446,92 @@ export function CanvasWorkspace({
|
||||
viewportRef.current = viewport;
|
||||
}, [viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasImageNodes) {
|
||||
preloadCanvasImageNodeRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const connection = (
|
||||
navigator as Navigator & {
|
||||
connection?: { effectiveType?: string; saveData?: boolean };
|
||||
}
|
||||
).connection;
|
||||
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
let idleId: number | null = null;
|
||||
const warmupTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
idleId = idleWindow.requestIdleCallback(preloadCanvasImageNodeRenderer, {
|
||||
timeout: 12000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
preloadCanvasImageNodeRenderer();
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(warmupTimer);
|
||||
if (idleId !== null) {
|
||||
idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
};
|
||||
}, [hasImageNodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (has3DNodes) {
|
||||
preloadCanvas3DNodeRenderers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const connection = (
|
||||
navigator as Navigator & {
|
||||
connection?: { effectiveType?: string; saveData?: boolean };
|
||||
}
|
||||
).connection;
|
||||
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
let idleId: number | null = null;
|
||||
const warmupTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, {
|
||||
timeout: 15000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
preloadCanvas3DNodeRenderers();
|
||||
}, 12000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(warmupTimer);
|
||||
if (idleId !== null) {
|
||||
idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
};
|
||||
}, [has3DNodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingViewportRafRef.current !== null) {
|
||||
|
||||
@ -3599,7 +3599,7 @@ export function RealtimeCanvasDialog({
|
||||
const primaryActionButtonClass =
|
||||
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black dark:border-white dark:bg-white dark:text-[#111111] dark:hover:border-white/90 dark:hover:bg-white/90";
|
||||
const compactToolButtonClass =
|
||||
"h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
"h-9 w-9 rounded-[var(--ui-radius-control)] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
const compactToolButtonActiveClass =
|
||||
"border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]";
|
||||
const modeSwitchIconSlotClass =
|
||||
@ -3647,12 +3647,12 @@ export function RealtimeCanvasDialog({
|
||||
<div className="pointer-events-none absolute top-5 left-5 right-5 z-50 flex flex-wrap items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto inline-flex max-w-full items-center gap-1.5 rounded-[28px] p-1.5",
|
||||
"pointer-events-auto inline-flex max-w-full items-center gap-1.5 rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="inline-flex h-10 shrink-0 items-center gap-2 rounded-[20px] border border-white/90 bg-white/78 px-3 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
|
||||
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-[var(--ui-radius-control)] border border-white/90 bg-white/78 px-2.5 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
|
||||
onClick={handleCloseRealtime}
|
||||
title={realtimeReturnCanvasItem.label}
|
||||
type="button"
|
||||
@ -3664,15 +3664,15 @@ export function RealtimeCanvasDialog({
|
||||
item={realtimeReturnCanvasItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-semibold text-[13px] tracking-[-0.02em]">
|
||||
<span className="text-[13px] font-semibold">
|
||||
{realtimeReturnCanvasItem.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="inline-flex h-10 min-w-0 items-center gap-0.5 rounded-[20px] border border-[#ebebeb] bg-[#fafafa]/90 p-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/12 dark:bg-white/8 dark:shadow-none">
|
||||
<div className="inline-flex h-9 min-w-0 items-center gap-0.5 rounded-[var(--ui-radius-control)] border border-[#ebebeb] bg-[#fafafa]/90 p-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/12 dark:bg-white/8 dark:shadow-none">
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
workspaceMode === "realtime"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3695,7 +3695,7 @@ export function RealtimeCanvasDialog({
|
||||
{isEnhanceProEnabled ? (
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
workspaceMode === "enhance-pro"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3719,7 +3719,7 @@ export function RealtimeCanvasDialog({
|
||||
{isAiWashEnabled ? (
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
workspaceMode === "ai-wash"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3744,21 +3744,21 @@ export function RealtimeCanvasDialog({
|
||||
{saveProjectDialogOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"-translate-x-1/2 absolute top-24 left-1/2 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
|
||||
"-translate-x-1/2 absolute top-24 left-1/2 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-[var(--ui-radius-panel)] p-3.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-[#111111] text-sm dark:text-white">
|
||||
<div className="text-[13px] font-semibold text-[#111111] dark:text-white">
|
||||
保存项目
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs dark:text-white/55">
|
||||
<div className="text-[11px] text-[#666666] dark:text-white/55">
|
||||
请输入项目名称后保存当前画布
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setSaveProjectDialogOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
@ -3769,7 +3769,7 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn("mt-4 h-11 rounded-2xl", controlSurfaceClass)}
|
||||
className={cn("mt-3 h-9", controlSurfaceClass)}
|
||||
onChange={(event) => setPendingProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
@ -3780,9 +3780,9 @@ export function RealtimeCanvasDialog({
|
||||
placeholder="请输入项目名称"
|
||||
value={pendingProjectName}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
className={cn("h-10 flex-1 rounded-2xl", controlSurfaceClass)}
|
||||
className={cn("h-9 flex-1", controlSurfaceClass)}
|
||||
onClick={() => setSaveProjectDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -3791,7 +3791,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 flex-1 rounded-2xl",
|
||||
"h-9 flex-1",
|
||||
primaryActionButtonClass
|
||||
)}
|
||||
onClick={() => void confirmSaveCurrentProject()}
|
||||
@ -3805,21 +3805,21 @@ export function RealtimeCanvasDialog({
|
||||
{projectPanelOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-20 left-4 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
|
||||
"absolute top-20 left-4 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-[var(--ui-radius-panel)] p-3.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-[#111111] text-sm dark:text-white">
|
||||
<div className="text-[13px] font-semibold text-[#111111] dark:text-white">
|
||||
项目列表
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs dark:text-white/55">
|
||||
<div className="text-[11px] text-[#666666] dark:text-white/55">
|
||||
本地保存,便于下次继续编辑
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setProjectPanelOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
@ -3828,19 +3828,19 @@ export function RealtimeCanvasDialog({
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 max-h-[55vh] min-h-0 space-y-2 overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="mt-3 max-h-[55vh] min-h-0 space-y-1.5 overflow-y-auto overscroll-contain pr-1">
|
||||
{savedProjects.length === 0 ? (
|
||||
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-4 py-8 text-center text-[#666666] text-sm">
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] border-dashed px-4 py-7 text-center text-[13px] text-[#666666]">
|
||||
暂无保存的项目
|
||||
</div>
|
||||
) : (
|
||||
savedProjects.map((project) => (
|
||||
<div
|
||||
className="rounded-2xl border border-[#e5e5e5] bg-white p-3 shadow-sm"
|
||||
className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] bg-white p-2.5 shadow-sm"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-[#e5e5e5] bg-[#fafafa]">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[6px] border border-[#e5e5e5] bg-[#fafafa]">
|
||||
{project.coverImageUrl || project.previewUrl ? (
|
||||
<img
|
||||
alt={project.name}
|
||||
@ -3866,13 +3866,13 @@ export function RealtimeCanvasDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-[#111111] text-sm leading-5">
|
||||
<div className="truncate text-[13px] font-medium leading-5 text-[#111111]">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-xl px-4",
|
||||
"h-8 px-3",
|
||||
softActionButtonClass
|
||||
)}
|
||||
onClick={() => void openSavedProject(project.id)}
|
||||
@ -3883,7 +3883,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-xl px-4",
|
||||
"h-8 px-3",
|
||||
controlSurfaceClass
|
||||
)}
|
||||
onClick={() => deleteSavedProject(project.id)}
|
||||
@ -3950,7 +3950,7 @@ export function RealtimeCanvasDialog({
|
||||
>
|
||||
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.03]" />
|
||||
{isDragImportActive ? (
|
||||
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[28px] border-2 border-[#111111] border-dashed bg-white/92 font-medium text-[#111111] text-sm shadow-inner dark:border-white/70 dark:bg-[#111317]/92 dark:text-white">
|
||||
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[var(--ui-radius-panel)] border-2 border-[#111111] border-dashed bg-white/92 text-[13px] font-medium text-[#111111] shadow-inner dark:border-white/70 dark:bg-[#111317]/92 dark:text-white">
|
||||
松开鼠标,上传图片到图层
|
||||
</div>
|
||||
) : null}
|
||||
@ -4048,7 +4048,7 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-28 bottom-4 left-4 z-10 flex w-16 max-w-[18vw] flex-col rounded-[24px] p-2",
|
||||
"absolute top-28 bottom-4 left-4 z-10 flex w-14 max-w-[18vw] flex-col rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4066,14 +4066,14 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
|
||||
{imageLayers.length === 0 ? (
|
||||
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] text-[#666666] leading-4 dark:border-white/12 dark:text-white/55">
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] leading-4 text-[#666666] dark:border-white/12 dark:text-white/55">
|
||||
暂无图层
|
||||
</div>
|
||||
) : (
|
||||
[...imageLayers].reverse().map((layer) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] p-1.5 transition hover:bg-[#f5f5f5] dark:hover:bg-white/10",
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[6px] p-1 transition hover:bg-[#f5f5f5] dark:hover:bg-white/10",
|
||||
selectedLayerId === layer.id
|
||||
? "bg-[#f5f5f5] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)] dark:bg-white/10 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)]"
|
||||
: ""
|
||||
@ -4224,7 +4224,7 @@ export function RealtimeCanvasDialog({
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[24px] p-1.5",
|
||||
"pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4250,10 +4250,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeSelectToolItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
选择图层并拖动位置
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4288,18 +4288,18 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[250px] rounded-[18px] p-4 backdrop-blur-md",
|
||||
"w-[224px] rounded-[var(--ui-radius-panel)] p-3 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[#666666] text-xs">
|
||||
颜色
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-xl px-3 font-medium text-sm",
|
||||
"flex h-9 w-full items-center justify-between rounded-[var(--ui-radius-control)] px-2.5 text-[13px] font-medium",
|
||||
controlSurfaceClass
|
||||
)}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
@ -4345,10 +4345,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeBrushToolItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
点击后直接调整颜色和粗细
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4383,7 +4383,7 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[220px] rounded-[18px] p-4 backdrop-blur-md",
|
||||
"w-[208px] rounded-[var(--ui-radius-panel)] p-3 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4409,10 +4409,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeEraserToolItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
点击后直接调整橡皮粗细
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4443,7 +4443,7 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[220px] rounded-[18px] p-3 backdrop-blur-md",
|
||||
"w-[208px] rounded-[var(--ui-radius-panel)] p-2.5 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4453,7 +4453,7 @@ export function RealtimeCanvasDialog({
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl border",
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
brushMode === "rect"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4466,7 +4466,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl border",
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
brushMode === "circle"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4479,7 +4479,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl border",
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
brushMode === "line"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4494,10 +4494,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeShapeToolItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
矩形、圆形与直线
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4521,10 +4521,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeSaveProjectItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
保存当前画布状态
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4550,10 +4550,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeProjectListItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">打开已保存项目</div>
|
||||
<div className="text-[10px] text-[#666666]">打开已保存项目</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@ -4574,10 +4574,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeNewBlankItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
清空并开始新项目
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4602,10 +4602,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeUndoItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">回退上一步编辑</div>
|
||||
<div className="text-[10px] text-[#666666]">回退上一步编辑</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@ -4627,10 +4627,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-[12px] font-medium">
|
||||
{realtimeRedoItem.label}
|
||||
</div>
|
||||
<div className="text-[#666666] text-xs">恢复已撤销操作</div>
|
||||
<div className="text-[10px] text-[#666666]">恢复已撤销操作</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -4638,12 +4638,12 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto w-full rounded-[24px] p-4",
|
||||
"pointer-events-auto w-full rounded-[var(--ui-radius-panel)] p-3",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="min-h-[56px] resize-none border-0 bg-transparent px-1 py-0.5 text-[15px] text-[#111111] leading-7 caret-[#111111] shadow-none placeholder:text-[#9b9b9b] focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white dark:caret-white dark:placeholder:text-white/45"
|
||||
className="min-h-[48px] resize-none border-0 bg-transparent px-1 py-0.5 text-[13px] leading-6 text-[#111111] caret-[#111111] shadow-none placeholder:text-[#9b9b9b] focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white dark:caret-white dark:placeholder:text-white/45"
|
||||
onBlur={() => scheduleFlush(true, false)}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@ -4699,7 +4699,7 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className="w-auto rounded-2xl border border-[#e5e5e5] bg-white p-1 shadow-[0_16px_36px_rgba(0,0,0,0.12)] dark:border-white/12 dark:bg-[#171717]"
|
||||
className="w-auto rounded-[var(--ui-radius-panel)] border border-[#e5e5e5] bg-white p-1 shadow-[0_12px_28px_rgba(0,0,0,0.12)] dark:border-white/12 dark:bg-[#171717]"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
@ -4709,7 +4709,7 @@ export function RealtimeCanvasDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"h-8 rounded-full px-3 font-medium text-xs transition",
|
||||
"h-8 rounded-[var(--ui-radius-control)] px-2.5 text-[12px] font-medium transition",
|
||||
active
|
||||
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
|
||||
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
|
||||
@ -4812,7 +4812,7 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="h-[97vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-white p-0 text-[#111111] shadow-2xl dark:bg-[#050608] dark:text-white sm:rounded-[20px] md:rounded-[24px]">
|
||||
<DialogContent className="h-[97vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[var(--ui-radius-panel)] border-0 bg-white p-0 text-[#111111] shadow-2xl dark:bg-[#050608] dark:text-white">
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
12
components/canvas/canvas-chat-panel-loader.ts
Normal file
12
components/canvas/canvas-chat-panel-loader.ts
Normal file
@ -0,0 +1,12 @@
|
||||
type CanvasChatPanelModule = typeof import("./CanvasChatPanel");
|
||||
|
||||
let canvasChatPanelModulePromise: Promise<CanvasChatPanelModule> | null = null;
|
||||
|
||||
export function loadCanvasChatPanelModule() {
|
||||
canvasChatPanelModulePromise ??= import("./CanvasChatPanel");
|
||||
return canvasChatPanelModulePromise;
|
||||
}
|
||||
|
||||
export function preloadCanvasChatPanel() {
|
||||
void loadCanvasChatPanelModule();
|
||||
}
|
||||
@ -3,8 +3,11 @@ import {
|
||||
extractOriginalImageUrlFromAlt,
|
||||
inferOriginalImageUrl,
|
||||
} from "@/lib/image-url";
|
||||
import { getMessageImagePartUrl } from "@/lib/chat/message-image-attachments";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
export { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments";
|
||||
|
||||
export function extractImageUrls(text: string): string[] {
|
||||
if (!text) return [];
|
||||
return extractImageUrlsFromText(text);
|
||||
@ -108,12 +111,7 @@ export function collectMessageImageUrlKeys(message: ChatMessage | undefined): Se
|
||||
const parts = (message as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
if (
|
||||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||||
(typeof part.url === "string" || typeof part.data === "string")
|
||||
) {
|
||||
pushUrl(typeof part.url === "string" ? part.url : part.data);
|
||||
}
|
||||
pushUrl(getMessageImagePartUrl(part));
|
||||
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
extractImageUrls(part.text).forEach((url) => pushUrl(url));
|
||||
@ -146,11 +144,8 @@ export function collectMessageImageUrls(message: ChatMessage | undefined): strin
|
||||
const parts = (message as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
if (
|
||||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||||
(typeof part.url === "string" || typeof part.data === "string")
|
||||
) {
|
||||
const direct = typeof part.url === "string" ? part.url : part.data;
|
||||
const direct = getMessageImagePartUrl(part);
|
||||
if (direct) {
|
||||
pushUrl(inferOriginalImageUrl(String(direct || "").trim()));
|
||||
}
|
||||
|
||||
|
||||
@ -9967,7 +9967,7 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||||
className="fixed z-[10000] overflow-hidden rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white text-slate-950 shadow-[0_14px_36px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||||
data-canvas-no-drag="true"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
@ -9981,21 +9981,21 @@ export function ImageNode({
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||
<CanvasToolbarIcon
|
||||
className="h-5 w-5"
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
|
||||
<div className="min-w-0 truncate text-[14px] font-semibold">
|
||||
{aiWashQuickModeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||
title="关闭"
|
||||
type="button"
|
||||
@ -10005,20 +10005,20 @@ export function ImageNode({
|
||||
</div>
|
||||
|
||||
{aiWashConfigLoading ? (
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2.5 text-[12px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在加载 AI洗图 配置...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-slate-950 dark:text-white">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
目标
|
||||
</div>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||
<span className="rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||
</span>
|
||||
</div>
|
||||
@ -10029,7 +10029,7 @@ export function ImageNode({
|
||||
}}
|
||||
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||
<SelectValue placeholder="选择渲染目标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||
@ -10047,8 +10047,8 @@ export function ImageNode({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[13px] font-semibold text-slate-950 dark:text-white">
|
||||
参数
|
||||
</div>
|
||||
{shouldShowAiWashQuickModelSelector ? (
|
||||
@ -10060,7 +10060,7 @@ export function ImageNode({
|
||||
onValueChange={setAiWashQuickModelId}
|
||||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||
<SelectValue placeholder="选择生图模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||
@ -10097,7 +10097,7 @@ export function ImageNode({
|
||||
</div>
|
||||
</div>
|
||||
) : selectedAiWashQuickModel ? (
|
||||
<div className="mb-3 rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||||
<div className="mb-3 rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 px-2.5 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||||
{selectedAiWashQuickModel.displayName}
|
||||
{selectedAiWashQuickModel.unitCost > 0
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||
@ -10105,7 +10105,7 @@ export function ImageNode({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
|
||||
<div className="-mx-3 divide-y divide-slate-100 dark:divide-white/10">
|
||||
{aiWashQuickControls.map((control) => {
|
||||
const selectedIndex =
|
||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||
@ -10115,14 +10115,14 @@ export function ImageNode({
|
||||
null;
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-3"
|
||||
className="px-3 py-2.5"
|
||||
key={control.id}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[14px] font-semibold">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold">
|
||||
{control.label}
|
||||
</div>
|
||||
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||
<span className="min-w-7 rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-center text-[10px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||
{activeLevel?.label || selectedIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
@ -10148,9 +10148,9 @@ export function ImageNode({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3">
|
||||
<div className="px-3 py-2.5">
|
||||
<Button
|
||||
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||
className="h-9 w-full bg-slate-950 text-[13px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||
disabled={
|
||||
isSubmittingAiWashQuick ||
|
||||
!selectedAiWashQuickPreset ||
|
||||
@ -11682,7 +11682,7 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
|
||||
className="fixed z-[9999] w-[340px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-950"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@ -11694,7 +11694,7 @@ export function ImageNode({
|
||||
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">编辑文字</span>
|
||||
<span className="text-[13px] font-medium">编辑文字</span>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 w-7"
|
||||
@ -11707,12 +11707,12 @@ export function ImageNode({
|
||||
</div>
|
||||
|
||||
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
||||
</p>
|
||||
|
||||
{isDetectingText && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在识别图片文字...
|
||||
</div>
|
||||
@ -11734,7 +11734,7 @@ export function ImageNode({
|
||||
|
||||
{textEditItems.map((item, index) => (
|
||||
<div
|
||||
className="space-y-2 rounded-lg border border-border/70 p-3"
|
||||
className="space-y-2 rounded-[var(--ui-radius-control)] border border-border/70 p-2.5"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@ -71,12 +71,12 @@ export function PromptRegenerateDialog({
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
提示词重绘
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-[13px] leading-5 text-muted-foreground">
|
||||
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
@ -147,15 +147,15 @@ export function ThreeDGenerationDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isWorldMode ? (
|
||||
<Globe2 className="h-5 w-5" />
|
||||
<Globe2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Box className="h-5 w-5" />
|
||||
<Box className="h-4 w-4" />
|
||||
)}
|
||||
{isWorldMode ? "选择 3D 世界模型" : "选择 3D 模型"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-[13px] leading-5 text-muted-foreground">
|
||||
{isWorldMode
|
||||
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
|
||||
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
|
||||
@ -186,11 +186,11 @@ export function ThreeDGenerationDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 p-2.5 text-[13px] dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{selectedEntry?.displayName || "未选择模型"}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-slate-600 dark:text-slate-400">
|
||||
{selectedEntry?.description || "当前模型暂无说明。"}
|
||||
</div>
|
||||
{costLabel ? (
|
||||
|
||||
@ -35,7 +35,7 @@ export const Action = ({
|
||||
const button = (
|
||||
<Button
|
||||
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
|
||||
)}
|
||||
size={size}
|
||||
|
||||
@ -30,7 +30,7 @@ export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn("p-4", className)} {...props} />
|
||||
<StickToBottom.Content className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
@ -10,7 +10,7 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
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",
|
||||
"[&>div]:max-w-[80%]",
|
||||
className
|
||||
@ -28,7 +28,7 @@ export const MessageContent = ({
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
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-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
|
||||
"is-user:dark",
|
||||
|
||||
@ -24,7 +24,7 @@ export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
|
||||
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
|
||||
<form
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -72,7 +72,7 @@ export const PromptInputTextarea = ({
|
||||
return (
|
||||
<Textarea
|
||||
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
|
||||
? "field-sizing-fixed"
|
||||
: resizeOnNewLinesOnly
|
||||
|
||||
@ -56,12 +56,12 @@ const AGENT_SECTION_TITLES = new Set([
|
||||
"输出设置",
|
||||
]);
|
||||
const MARKDOWN_HEADING_CLASS_NAME =
|
||||
"[&_h1]:!mb-3 [&_h1]:!mt-4 [&_h1]:!text-[26px] [&_h1]:!font-semibold [&_h1]:!leading-[1.25] [&_h1]:!tracking-normal " +
|
||||
"[&_h2]:!mb-2.5 [&_h2]:!mt-4 [&_h2]:!text-[22px] [&_h2]:!font-semibold [&_h2]:!leading-[1.3] [&_h2]:!tracking-normal " +
|
||||
"[&_h3]:!mb-2 [&_h3]:!mt-3 [&_h3]:!text-[18px] [&_h3]:!font-semibold [&_h3]:!leading-[1.35] [&_h3]:!tracking-normal " +
|
||||
"[&_h4]:!mb-1.5 [&_h4]:!mt-3 [&_h4]:!text-[16px] [&_h4]:!font-semibold [&_h4]:!leading-[1.4] [&_h4]:!tracking-normal " +
|
||||
"[&_h5]:!text-[15px] [&_h5]:!font-semibold [&_h5]:!leading-[1.4] [&_h5]:!tracking-normal " +
|
||||
"[&_h6]:!text-[14px] [&_h6]:!font-semibold [&_h6]:!leading-[1.4] [&_h6]:!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 [&_h2]:!mt-3.5 [&_h2]:!text-[18px] [&_h2]:!font-semibold [&_h2]:!leading-[1.35] [&_h2]:!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-2.5 [&_h4]:!text-[14px] [&_h4]:!font-semibold [&_h4]:!leading-[1.45] [&_h4]:!tracking-normal " +
|
||||
"[&_h5]:!text-[13px] [&_h5]:!font-semibold [&_h5]:!leading-[1.45] [&_h5]:!tracking-normal " +
|
||||
"[&_h6]:!text-[12px] [&_h6]:!font-semibold [&_h6]:!leading-[1.45] [&_h6]:!tracking-normal";
|
||||
|
||||
type AnalysisSection = {
|
||||
before: string;
|
||||
|
||||
@ -40,7 +40,7 @@ export const Suggestion = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
className={cn("cursor-pointer rounded-full px-3", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
|
||||
@ -19,6 +19,20 @@ export function HomeAnimationEmbed({
|
||||
frameClassName,
|
||||
}: HomeAnimationEmbedProps) {
|
||||
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(() => {
|
||||
if (!interactive) {
|
||||
@ -78,16 +92,18 @@ export function HomeAnimationEmbed({
|
||||
frameClassName,
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
title={title || "首页互动动画"}
|
||||
srcDoc={htmlContent}
|
||||
sandbox="allow-scripts allow-forms allow-pointer-lock"
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"h-full w-full border-0 transition-opacity",
|
||||
interactive ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-95",
|
||||
)}
|
||||
/>
|
||||
{frameUrl ? (
|
||||
<iframe
|
||||
title={title || "首页互动动画"}
|
||||
src={frameUrl}
|
||||
sandbox="allow-scripts allow-forms allow-pointer-lock"
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"h-full w-full border-0 transition-opacity",
|
||||
interactive ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-95",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!interactive ? (
|
||||
<button
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn, signOut } from "next-auth/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
@ -59,8 +59,11 @@ import {
|
||||
type CanvasLaunchAttachment,
|
||||
getCanvasLaunchStorageKey,
|
||||
} from "@/lib/canvas-launch";
|
||||
import {
|
||||
preloadCanvasWorkspace,
|
||||
prepareCanvasNavigation,
|
||||
} from "@/lib/client/canvas-workspace-preload";
|
||||
import type { MembershipConfig } from "@/lib/membership/config";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import {
|
||||
normalizeLovartHomeSidebarConfig,
|
||||
type LovartHomeSidebarItemKey,
|
||||
@ -71,7 +74,18 @@ import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-compose
|
||||
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
||||
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
|
||||
|
||||
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
|
||||
const ImageAnnotatorLazy = dynamic(
|
||||
() => import("@/components/image-annotator").then((module) => module.ImageAnnotator),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 text-white backdrop-blur-sm">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
正在加载图片编辑器...
|
||||
</div>
|
||||
),
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
type HomeUser = {
|
||||
id: string;
|
||||
@ -455,21 +469,25 @@ export function LovartHome({
|
||||
const [showDingTalkOrgMenu, setShowDingTalkOrgMenu] = useState(false);
|
||||
const [showImageEditor, setShowImageEditor] = useState(false);
|
||||
const [editorImageUrl, setEditorImageUrl] = useState("");
|
||||
const [ImageAnnotatorLazy, setImageAnnotatorLazy] =
|
||||
useState<ImageAnnotatorComponent | null>(null);
|
||||
const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false);
|
||||
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
|
||||
const [contactDialogOpen, setContactDialogOpen] = useState(false);
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const [typedPreviewText, setTypedPreviewText] = useState("");
|
||||
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
|
||||
const [isSecondaryDataReady, setIsSecondaryDataReady] = useState(false);
|
||||
const [isThemeReady, setIsThemeReady] = useState(false);
|
||||
const isSecondaryDataReady = true;
|
||||
const [isFeaturedDataReady, setIsFeaturedDataReady] = useState(false);
|
||||
const [isHomeAnimationReady, setIsHomeAnimationReady] = useState(false);
|
||||
const projectSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const featuredSectionRef = useRef<HTMLElement | null>(null);
|
||||
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const filesRef = useRef<UploadFileItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsThemeReady(true);
|
||||
}, []);
|
||||
|
||||
const { data: sessionData, mutate: mutateSession } = useSWR<HomeSessionResponse>(
|
||||
"/api/auth/validate-session",
|
||||
fetcher,
|
||||
@ -482,30 +500,6 @@ export function LovartHome({
|
||||
const user = sessionData?.user ?? initialUser;
|
||||
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(() => {
|
||||
if (typeof window === "undefined" || isFeaturedDataReady) {
|
||||
return;
|
||||
@ -535,6 +529,31 @@ export function LovartHome({
|
||||
};
|
||||
}, [isFeaturedDataReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !isSecondaryDataReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadHomeAnimation = () => setIsHomeAnimationReady(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(loadHomeAnimation, {
|
||||
timeout: 3200,
|
||||
});
|
||||
return () => idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(loadHomeAnimation, 1800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isSecondaryDataReady]);
|
||||
|
||||
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
|
||||
success: boolean;
|
||||
data: CanvasProject[];
|
||||
@ -649,8 +668,9 @@ export function LovartHome({
|
||||
siteSettingsData?.lovartHeroSubtitle ?? initialHeroSubtitleText;
|
||||
const surpriseCategories =
|
||||
siteSettingsData?.surpriseCategories ?? initialSurpriseCategories;
|
||||
const effectiveResolvedTheme = isThemeReady ? resolvedTheme : undefined;
|
||||
const displayLogo =
|
||||
resolvedTheme === "dark"
|
||||
effectiveResolvedTheme === "dark"
|
||||
? siteLogoDark || siteLogo || ""
|
||||
: siteLogo || siteLogoDark || "";
|
||||
|
||||
@ -669,7 +689,10 @@ export function LovartHome({
|
||||
enabled?: boolean;
|
||||
htmlContent?: string;
|
||||
fileName?: string;
|
||||
}>(isSecondaryDataReady ? "/api/home-animation" : null, fetcher);
|
||||
}>(
|
||||
isSecondaryDataReady && isHomeAnimationReady ? "/api/home-animation" : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const membershipConfig = membershipData?.config;
|
||||
const membershipPlans = membershipConfig?.plans || [];
|
||||
@ -894,6 +917,7 @@ export function LovartHome({
|
||||
} catch (_error) {}
|
||||
|
||||
try {
|
||||
const { signOut } = await import("next-auth/react");
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: false,
|
||||
@ -908,6 +932,28 @@ export function LovartHome({
|
||||
setLoginDialogOpen(false);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || typeof window === "undefined") return;
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const idleId = idleWindow.requestIdleCallback(preloadCanvasWorkspace, {
|
||||
timeout: 1200,
|
||||
});
|
||||
return () => idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(preloadCanvasWorkspace, 450);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginDialogOpen) {
|
||||
setIsLoggingIn(false);
|
||||
@ -1052,23 +1098,6 @@ export function LovartHome({
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showImageEditor || ImageAnnotatorLazy) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
void import("@/components/image-annotator").then((module) => {
|
||||
if (!disposed) {
|
||||
setImageAnnotatorLazy(() => module.ImageAnnotator);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [ImageAnnotatorLazy, showImageEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
filesRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||
@ -1107,6 +1136,9 @@ export function LovartHome({
|
||||
};
|
||||
|
||||
const uploadSelectedFiles = async (): Promise<CanvasLaunchAttachment[]> => {
|
||||
const { fetchUploadWithRetry } = await import(
|
||||
"@/lib/client/upload-with-retry"
|
||||
);
|
||||
const result: CanvasLaunchAttachment[] = [];
|
||||
|
||||
for (const item of files) {
|
||||
@ -1144,6 +1176,7 @@ export function LovartHome({
|
||||
|
||||
setIsLoggingIn(true);
|
||||
try {
|
||||
const { signIn } = await import("next-auth/react");
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password: loginPassword,
|
||||
@ -1169,6 +1202,7 @@ export function LovartHome({
|
||||
toast.error("Google 登录未启用");
|
||||
return;
|
||||
}
|
||||
const { signIn } = await import("next-auth/react");
|
||||
await signIn("google", { callbackUrl: "/" });
|
||||
};
|
||||
|
||||
@ -1224,6 +1258,9 @@ export function LovartHome({
|
||||
const blob = await response.blob();
|
||||
const formData = new FormData();
|
||||
formData.append("file", blob, `edited-${Date.now()}.png`);
|
||||
const { fetchUploadWithRetry } = await import(
|
||||
"@/lib/client/upload-with-retry"
|
||||
);
|
||||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
@ -1327,6 +1364,7 @@ export function LovartHome({
|
||||
|
||||
if (!prompt.trim() && files.length === 0) return;
|
||||
|
||||
preloadCanvasWorkspace();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const uploadedAttachments = await uploadSelectedFiles();
|
||||
@ -1346,6 +1384,7 @@ export function LovartHome({
|
||||
}
|
||||
|
||||
const canvasId: string = canvasResult.data.id;
|
||||
prepareCanvasNavigation(canvasId);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(
|
||||
@ -1370,7 +1409,16 @@ export function LovartHome({
|
||||
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||
setFiles([]);
|
||||
setPrompt("");
|
||||
await mutateCanvas();
|
||||
void mutateCanvas((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
data: [
|
||||
canvasResult.data as CanvasProject,
|
||||
...(current.data || []).filter((item) => item.id !== canvasId),
|
||||
],
|
||||
};
|
||||
}, false);
|
||||
router.push(`/canvas/${canvasId}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建项目失败");
|
||||
@ -1379,14 +1427,63 @@ export function LovartHome({
|
||||
}
|
||||
};
|
||||
|
||||
const openNewCanvas = () => {
|
||||
const openNewCanvas = async () => {
|
||||
if (!isAuthenticated) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
router.push("/canvas?new=1");
|
||||
if (isSubmitting) return;
|
||||
|
||||
preloadCanvasWorkspace();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "未命名画布" }),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.data?.id) {
|
||||
throw new Error(result?.error || "创建项目失败");
|
||||
}
|
||||
|
||||
const canvasId = String(result.data.id);
|
||||
prepareCanvasNavigation(canvasId);
|
||||
void mutateCanvas((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
data: [
|
||||
result.data as CanvasProject,
|
||||
...(current.data || []).filter((item) => item.id !== canvasId),
|
||||
],
|
||||
};
|
||||
}, false);
|
||||
router.push(`/canvas/${canvasId}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建项目失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareCanvasProject = useCallback(
|
||||
(projectId: string) => {
|
||||
prepareCanvasNavigation(projectId);
|
||||
router.prefetch(`/canvas/${projectId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const openCanvasProject = useCallback(
|
||||
(projectId: string) => {
|
||||
prepareCanvasProject(projectId);
|
||||
router.push(`/canvas/${projectId}`);
|
||||
},
|
||||
[prepareCanvasProject, router]
|
||||
);
|
||||
|
||||
const handleDeleteProject = async (projectId: string, projectTitle?: string) => {
|
||||
if (deletingProjectId === projectId) return;
|
||||
|
||||
@ -1514,7 +1611,9 @@ export function LovartHome({
|
||||
};
|
||||
}, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]);
|
||||
const selectedThemeMode: ThemeMode =
|
||||
theme === "light" || theme === "dark" || theme === "system" ? theme : "system";
|
||||
isThemeReady && (theme === "light" || theme === "dark" || theme === "system")
|
||||
? theme
|
||||
: "system";
|
||||
const selectedThemeOption =
|
||||
THEME_MODE_OPTIONS.find((option) => option.value === selectedThemeMode) ||
|
||||
THEME_MODE_OPTIONS[0];
|
||||
@ -1523,7 +1622,7 @@ export function LovartHome({
|
||||
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">
|
||||
<BrandTopBar
|
||||
contentClassName="pl-[98px]"
|
||||
contentClassName="pl-[80px]"
|
||||
logo={displayLogo}
|
||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
showBrand={false}
|
||||
@ -1784,10 +1883,10 @@ export function LovartHome({
|
||||
tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)}
|
||||
tutorialVideos={tutorialVideos}
|
||||
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 && (
|
||||
<LovartHeroComposer
|
||||
composerPlaceholder={composerPlaceholder}
|
||||
@ -1798,7 +1897,9 @@ export function LovartHome({
|
||||
heroPromoText={heroPromoText}
|
||||
heroSubtitleText={heroSubtitleText}
|
||||
heroTitleText={heroTitleText}
|
||||
homeAnimationData={homeAnimationData}
|
||||
homeAnimationData={
|
||||
isHomeAnimationReady ? homeAnimationData : undefined
|
||||
}
|
||||
isAgentModeActive={isComposerAgentModeActive}
|
||||
isMembershipEnabled={isMembershipEnabled}
|
||||
isSubmitting={isSubmitting}
|
||||
@ -1821,7 +1922,9 @@ export function LovartHome({
|
||||
isProjectsOnlyView={isProjectsOnlyView}
|
||||
onCreateProject={openNewCanvas}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onOpenProject={(projectId) => router.push(`/canvas/${projectId}`)}
|
||||
onOpenProject={openCanvasProject}
|
||||
onPrepareCreate={preloadCanvasWorkspace}
|
||||
onPrepareProject={prepareCanvasProject}
|
||||
onViewAllProjects={openAllProjectsInPage}
|
||||
projects={recentProjects}
|
||||
projectSectionRef={projectSectionRef}
|
||||
@ -1844,13 +1947,13 @@ export function LovartHome({
|
||||
<button
|
||||
type="button"
|
||||
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" })}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showImageEditor && ImageAnnotatorLazy ? (
|
||||
{showImageEditor ? (
|
||||
<ImageAnnotatorLazy
|
||||
imageUrl={editorImageUrl}
|
||||
onConfirm={handleImageEditorConfirm}
|
||||
@ -1858,11 +1961,6 @@ export function LovartHome({
|
||||
onCancel={handleImageEditorCancel}
|
||||
onHomeClick={handleImageEditorCancel}
|
||||
/>
|
||||
) : showImageEditor ? (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 text-white backdrop-blur-sm">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
正在加载图片编辑器...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
@ -1938,23 +2036,23 @@ export function LovartHome({
|
||||
onClick={() => setLoginDialogOpen(false)}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
<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">
|
||||
{displayLogo ? (
|
||||
<img
|
||||
src={displayLogo}
|
||||
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">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<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 w-3" />
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@ -1967,9 +2065,9 @@ export function LovartHome({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 text-center">
|
||||
<h3 className="text-[18px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3>
|
||||
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45">
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="text-[16px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3>
|
||||
<p className="mt-1.5 text-[12px] text-black/45 dark:text-white/45">
|
||||
{canUseRegistration ? "登录或注册以继续" : "登录以继续"}
|
||||
</p>
|
||||
</div>
|
||||
@ -2021,7 +2119,7 @@ export function LovartHome({
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
className="space-y-2.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleEmailLogin();
|
||||
@ -2032,18 +2130,18 @@ export function LovartHome({
|
||||
onChange={(event) => setLoginEmail(event.target.value)}
|
||||
placeholder="电子邮箱"
|
||||
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
|
||||
value={loginPassword}
|
||||
onChange={(event) => setLoginPassword(event.target.value)}
|
||||
placeholder="密码"
|
||||
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
|
||||
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}
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
@ -2058,7 +2156,7 @@ export function LovartHome({
|
||||
</form>
|
||||
|
||||
{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
|
||||
type="button"
|
||||
|
||||
@ -24,18 +24,18 @@ export function LovartFeaturedGallery({
|
||||
setActiveCategory,
|
||||
}: LovartFeaturedGalleryProps) {
|
||||
return (
|
||||
<section ref={featuredSectionRef} className="mt-[70px]">
|
||||
<h2 className="text-[18px] font-semibold leading-none tracking-[-0.01em] text-[#1f1f1f] dark:text-white">
|
||||
<section ref={featuredSectionRef} className="mt-14">
|
||||
<h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
|
||||
灵感发现
|
||||
</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) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
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
|
||||
? "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]"
|
||||
@ -62,7 +62,9 @@ export function LovartFeaturedGallery({
|
||||
<img
|
||||
src={item.thumbnailUrl || item.originalUrl}
|
||||
alt={item.prompt || "灵感图"}
|
||||
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]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
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="flex min-w-0 items-center gap-[6px] pr-2">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@ -12,6 +12,19 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModelOption, UploadFileItem } from "./types";
|
||||
|
||||
const HomeAnimationEmbed = dynamic(
|
||||
() =>
|
||||
import("@/components/home/home-animation-embed").then(
|
||||
(module) => module.HomeAnimationEmbed
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<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,
|
||||
}
|
||||
);
|
||||
|
||||
type LovartHeroComposerProps = {
|
||||
composerPlaceholder: string;
|
||||
displayLogo: string;
|
||||
@ -66,73 +79,78 @@ export function LovartHeroComposer({
|
||||
selectedModelId,
|
||||
}: LovartHeroComposerProps) {
|
||||
return (
|
||||
<section className="mt-8 text-center">
|
||||
<section className="mt-6 text-center">
|
||||
{isMembershipEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||||
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
|
||||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||||
</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>
|
||||
{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 className="mt-8 flex items-center justify-center gap-2">
|
||||
<div className="mt-7 flex items-center justify-center gap-1.5">
|
||||
{displayLogo ? (
|
||||
<img
|
||||
src={displayLogo}
|
||||
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">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-[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"} 让设计更简单`}
|
||||
</h1>
|
||||
</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 || "懂你的设计代理,帮你搞定一切"}
|
||||
</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
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
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 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
||||
{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" />
|
||||
<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)}
|
||||
aria-label={`移除${item.file.name}`}
|
||||
>
|
||||
x
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
<Plus className="h-5 w-5" strokeWidth={1.9} />
|
||||
<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-4 w-4" strokeWidth={1.9} />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@ -146,11 +164,11 @@ export function LovartHeroComposer({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex h-11 items-center gap-5 pl-2">
|
||||
<div className="flex h-9 items-center gap-3 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? "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"
|
||||
@ -159,28 +177,28 @@ export function LovartHeroComposer({
|
||||
onClick={onStartAgentMode}
|
||||
title="开启 Agent 模式"
|
||||
>
|
||||
<Lightbulb className="block h-5 w-5" strokeWidth={1.9} />
|
||||
<Lightbulb className="block h-4 w-4" strokeWidth={1.9} />
|
||||
</button>
|
||||
<Select value={selectedModelId} onValueChange={onModelChange}>
|
||||
<SelectTrigger
|
||||
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="选择模型"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center">
|
||||
<Box className="block h-5 w-5" strokeWidth={1.9} />
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<Box className="block h-4 w-4" strokeWidth={1.9} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="end"
|
||||
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) => (
|
||||
<SelectItem
|
||||
key={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 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">
|
||||
@ -203,12 +221,12 @@ export function LovartHeroComposer({
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
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 ? (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,8 @@ type LovartProjectsSectionProps = {
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: (projectId: string, projectTitle?: string) => void;
|
||||
onOpenProject: (projectId: string) => void;
|
||||
onPrepareCreate: () => void;
|
||||
onPrepareProject: (projectId: string) => void;
|
||||
onViewAllProjects: () => void;
|
||||
projects: CanvasProject[];
|
||||
projectSectionRef: Ref<HTMLElement>;
|
||||
@ -22,6 +24,8 @@ export function LovartProjectsSection({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onOpenProject,
|
||||
onPrepareCreate,
|
||||
onPrepareProject,
|
||||
onViewAllProjects,
|
||||
projects,
|
||||
projectSectionRef,
|
||||
@ -29,34 +33,36 @@ export function LovartProjectsSection({
|
||||
return (
|
||||
<section
|
||||
ref={projectSectionRef}
|
||||
className={isProjectsOnlyView ? "mt-6" : "mt-[156px]"}
|
||||
className={isProjectsOnlyView ? "mt-5" : "mt-[112px]"}
|
||||
>
|
||||
<div className="mb-[32px] flex items-center justify-between">
|
||||
<h2 className="text-[26px] font-semibold leading-none tracking-[-0.01em] text-[#1f1f1f] dark:text-white">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-[20px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
|
||||
{isProjectsOnlyView ? "所有项目" : "最近项目"}
|
||||
</h2>
|
||||
{!isProjectsOnlyView && hasMoreProjects ? (
|
||||
<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}
|
||||
>
|
||||
查看全部
|
||||
<ChevronRight className="h-[18px] w-[18px]" strokeWidth={1.8} />
|
||||
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
</button>
|
||||
) : null}
|
||||
</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
|
||||
type="button"
|
||||
onClick={onCreateProject}
|
||||
onFocus={onPrepareCreate}
|
||||
onPointerEnter={onPrepareCreate}
|
||||
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]">
|
||||
<span className="text-[34px] font-light leading-none text-[#8e8e8e]">+</span>
|
||||
<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-[26px] font-light leading-none text-[#8e8e8e]">+</span>
|
||||
</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>
|
||||
</button>
|
||||
@ -69,23 +75,27 @@ export function LovartProjectsSection({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenProject(item.id)}
|
||||
onFocus={() => onPrepareProject(item.id)}
|
||||
onPointerEnter={() => onPrepareProject(item.id)}
|
||||
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 ? (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.025]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
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>
|
||||
<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"}
|
||||
</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)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -83,10 +83,10 @@ export function LovartSidebar({
|
||||
}: LovartSidebarProps) {
|
||||
const isDark = tone === "dark";
|
||||
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) =>
|
||||
[
|
||||
"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
|
||||
? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
|
||||
: "",
|
||||
@ -110,7 +110,7 @@ export function LovartSidebar({
|
||||
>
|
||||
{renderIcon(key, {
|
||||
active: options?.active,
|
||||
sizeClassName: "h-[18px] w-[18px]",
|
||||
sizeClassName: "h-4 w-4",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -131,13 +131,13 @@ export function LovartSidebar({
|
||||
className={sidebarButtonClass()}
|
||||
title={getItem(key).label}
|
||||
>
|
||||
{renderIcon(key, { sizeClassName: "h-[18px] w-[18px]" })}
|
||||
{renderIcon(key, { sizeClassName: "h-4 w-4" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
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">
|
||||
{getItem(key).label}
|
||||
@ -147,7 +147,7 @@ export function LovartSidebar({
|
||||
<button
|
||||
key={link.id}
|
||||
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)}
|
||||
>
|
||||
<span className="line-clamp-1">{link.name}</span>
|
||||
@ -162,19 +162,19 @@ export function LovartSidebar({
|
||||
return (
|
||||
<aside
|
||||
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
|
||||
? "border-white/10 bg-black"
|
||||
: "border-[#e9e9e9] bg-white",
|
||||
].join(" ")}
|
||||
>
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<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}
|
||||
title={siteName}
|
||||
>
|
||||
@ -182,10 +182,10 @@ export function LovartSidebar({
|
||||
<img
|
||||
src={siteLogo}
|
||||
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()}
|
||||
</span>
|
||||
)}
|
||||
@ -194,7 +194,7 @@ export function LovartSidebar({
|
||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||
</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") ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@ -205,7 +205,7 @@ export function LovartSidebar({
|
||||
title={getItem("newProject").label}
|
||||
>
|
||||
{renderIcon("newProject", {
|
||||
sizeClassName: "h-[18px] w-[18px]",
|
||||
sizeClassName: "h-4 w-4",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -225,13 +225,13 @@ export function LovartSidebar({
|
||||
className={sidebarButtonClass()}
|
||||
title={getItem("tutorial").label}
|
||||
>
|
||||
{renderIcon("tutorial", { sizeClassName: "h-[18px] w-[18px]" })}
|
||||
{renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
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">
|
||||
{getItem("tutorial").label}
|
||||
@ -242,7 +242,7 @@ export function LovartSidebar({
|
||||
<button
|
||||
key={video.id}
|
||||
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)}
|
||||
>
|
||||
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@ -57,12 +58,26 @@ import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { createClientLogger } from "@/lib/observability/client-logger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AngleEditDialog } from "./angle-edit-dialog";
|
||||
import { SceneFusionView } from "./scene-fusion-view";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Slider } from "./ui/slider";
|
||||
|
||||
const AngleEditDialog = dynamic(
|
||||
() => import("./angle-edit-dialog").then((module) => module.AngleEditDialog),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const SceneFusionView = dynamic(
|
||||
() => import("./scene-fusion-view").then((module) => module.SceneFusionView),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
type Tool = "pen" | "arrow" | "circle" | "text";
|
||||
|
||||
const logger = createClientLogger("image-annotator");
|
||||
@ -1770,7 +1785,7 @@ export function ImageAnnotator({
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]",
|
||||
"h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[var(--ui-radius-panel)] border-0 p-0 shadow-2xl",
|
||||
forceDarkAppearance ? "bg-[#09090b] text-zinc-100" : "bg-background"
|
||||
)}
|
||||
>
|
||||
@ -2013,7 +2028,7 @@ export function ImageAnnotator({
|
||||
|
||||
{!showSceneFusion && isResizeMode && (
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-8 left-1/2 z-20 w-[min(92vw,1120px)]">
|
||||
<div className="scrollbar-hide pointer-events-auto flex items-center justify-between gap-3 overflow-x-auto rounded-[28px] border border-border/50 bg-background/95 px-3 py-2 shadow-2xl backdrop-blur-xl">
|
||||
<div className="scrollbar-hide pointer-events-auto flex items-center justify-between gap-2 overflow-x-auto rounded-[var(--ui-radius-panel)] border border-border/50 bg-background/95 px-2.5 py-1.5 shadow-xl backdrop-blur-xl">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<Popover
|
||||
onOpenChange={setResizeRatioPickerOpen}
|
||||
@ -2021,7 +2036,7 @@ export function ImageAnnotator({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="h-10 shrink-0 rounded-[20px] bg-muted/35 px-4 font-medium text-foreground text-xs hover:bg-muted/45"
|
||||
className="h-9 shrink-0 rounded-[var(--ui-radius-control)] bg-muted/35 px-3 text-[12px] font-medium text-foreground hover:bg-muted/45"
|
||||
variant="ghost"
|
||||
>
|
||||
比例
|
||||
@ -2033,7 +2048,7 @@ export function ImageAnnotator({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="z-[10020] w-[320px] rounded-[22px] border-border/60 bg-background/96 p-3 backdrop-blur-xl"
|
||||
className="z-[10020] w-[288px] rounded-[var(--ui-radius-panel)] border-border/60 bg-background/96 p-2.5 backdrop-blur-xl"
|
||||
side="top"
|
||||
>
|
||||
<div className="mb-2 font-medium text-muted-foreground text-xs">
|
||||
@ -2043,7 +2058,7 @@ export function ImageAnnotator({
|
||||
{aspectRatios.map((ratio) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-[16px] px-3 font-medium text-xs",
|
||||
"h-8 rounded-[var(--ui-radius-control)] px-2.5 text-[12px] font-medium",
|
||||
selectedAspectRatio === ratio.value && "shadow-sm"
|
||||
)}
|
||||
key={ratio.value}
|
||||
@ -2069,9 +2084,9 @@ export function ImageAnnotator({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||||
<div className="flex items-center gap-2 rounded-[20px] bg-muted/30 px-2 py-1">
|
||||
<div className="flex items-center gap-2 rounded-[var(--ui-radius-control)] bg-muted/30 px-2 py-1">
|
||||
<Input
|
||||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
|
||||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-[13px] shadow-none focus-visible:ring-0"
|
||||
onChange={(e) =>
|
||||
handleWidthChange(Number.parseInt(e.target.value) || 1)
|
||||
}
|
||||
@ -2080,7 +2095,7 @@ export function ImageAnnotator({
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">×</span>
|
||||
<Input
|
||||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
|
||||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-[13px] shadow-none focus-visible:ring-0"
|
||||
onChange={(e) =>
|
||||
handleHeightChange(Number.parseInt(e.target.value) || 1)
|
||||
}
|
||||
@ -2089,7 +2104,7 @@ export function ImageAnnotator({
|
||||
/>
|
||||
</div>
|
||||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||||
<div className="flex items-center gap-1 rounded-[22px] bg-muted/35 px-1 py-1">
|
||||
<div className="flex items-center gap-1 rounded-[var(--ui-radius-control)] bg-muted/35 p-1">
|
||||
{resizeQuickPresets.map((preset) => {
|
||||
const isActive =
|
||||
resizeCanvasWidth === preset.width &&
|
||||
@ -2097,7 +2112,7 @@ export function ImageAnnotator({
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 whitespace-nowrap rounded-[18px] px-3 font-medium text-xs",
|
||||
"h-8 whitespace-nowrap rounded-[6px] px-2.5 text-[12px] font-medium",
|
||||
isActive && "shadow-sm"
|
||||
)}
|
||||
key={`${preset.width}x${preset.height}`}
|
||||
@ -2111,7 +2126,7 @@ export function ImageAnnotator({
|
||||
})}
|
||||
</div>
|
||||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||||
<div className="flex min-w-[280px] items-center gap-3 rounded-[22px] bg-muted/35 px-3 py-2">
|
||||
<div className="flex min-w-[260px] items-center gap-2.5 rounded-[var(--ui-radius-control)] bg-muted/35 px-2.5 py-1.5">
|
||||
<span className="shrink-0 font-medium text-muted-foreground text-xs">
|
||||
缩放
|
||||
</span>
|
||||
@ -2162,14 +2177,14 @@ export function ImageAnnotator({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
className="rounded-[18px] px-4 text-muted-foreground hover:text-foreground"
|
||||
className="px-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[18px] bg-primary px-5 text-primary-foreground hover:bg-primary/90"
|
||||
className="bg-primary px-4 text-primary-foreground hover:bg-primary/90"
|
||||
disabled={isResizing}
|
||||
onClick={handleResizeConfirm}
|
||||
>
|
||||
@ -2192,7 +2207,7 @@ export function ImageAnnotator({
|
||||
|
||||
{!showSceneFusion && !isResizeMode && (
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-8 left-1/2 z-20 w-[min(94vw,1120px)]">
|
||||
<div className="scrollbar-hide pointer-events-auto flex items-center justify-between gap-3 overflow-x-auto rounded-[28px] border border-border/60 bg-background px-3 py-2 shadow-2xl">
|
||||
<div className="scrollbar-hide pointer-events-auto flex items-center justify-between gap-2 overflow-x-auto rounded-[var(--ui-radius-panel)] border border-border/60 bg-background px-2.5 py-1.5 shadow-xl">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex min-w-max items-center gap-1">
|
||||
<Tooltip>
|
||||
@ -2355,14 +2370,14 @@ export function ImageAnnotator({
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
className="rounded-[18px] px-4 text-muted-foreground hover:text-foreground"
|
||||
className="px-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[18px] bg-primary px-6 text-primary-foreground hover:bg-primary/90"
|
||||
className="bg-primary px-4 text-primary-foreground hover:bg-primary/90"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -492,18 +492,18 @@ export function ImageParamsBar({
|
||||
align="center"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className="w-[304px] max-w-[calc(100vw-32px)] rounded-[18px] border border-[#c7ccd5] bg-white p-3.5 text-[#252b33] shadow-[0_22px_56px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-[#141416] dark:text-zinc-100 dark:shadow-[0_24px_64px_rgba(0,0,0,0.42)]"
|
||||
className="w-[288px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border border-[#c7ccd5] bg-white p-3 text-[#252b33] shadow-[0_14px_36px_rgba(15,23,42,0.14)] dark:border-white/12 dark:bg-[#141416] dark:text-zinc-100 dark:shadow-[0_18px_44px_rgba(0,0,0,0.38)]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-2 text-[14px] font-medium leading-none">质量</div>
|
||||
<div className="grid h-9 grid-cols-4 rounded-full bg-[#f4f4f5] p-0.5 dark:bg-white/8">
|
||||
<div className="mb-1.5 text-[12px] font-medium leading-none">质量</div>
|
||||
<div className="grid h-8 grid-cols-4 rounded-[var(--ui-radius-control)] bg-[#f4f4f5] p-0.5 dark:bg-white/8">
|
||||
{(["自动", "高", "中", "低"] as const).map((quality) => (
|
||||
<button
|
||||
key={quality}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full text-[13px] font-medium transition-colors",
|
||||
"rounded-[6px] text-[12px] font-medium transition-colors",
|
||||
qualityLabel === quality
|
||||
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
|
||||
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
|
||||
@ -517,14 +517,14 @@ export function ImageParamsBar({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[14px] font-medium leading-none">尺寸</div>
|
||||
<div className="mb-1.5 text-[12px] font-medium leading-none">尺寸</div>
|
||||
<div className="grid grid-cols-[1fr_20px_1fr] items-center gap-2">
|
||||
<div className="flex h-10 items-center gap-2 rounded-[10px] bg-[#f3f3f4] px-3 text-[15px] dark:bg-white/8">
|
||||
<div className="flex h-8 items-center gap-2 rounded-[var(--ui-radius-control)] bg-[#f3f3f4] px-2.5 text-[13px] dark:bg-white/8">
|
||||
<span className="text-[#68707c] dark:text-zinc-400">W</span>
|
||||
<span>{outputSize.width}</span>
|
||||
</div>
|
||||
<Link2 className="mx-auto h-3.5 w-3.5 text-[#8b929c]" />
|
||||
<div className="flex h-10 items-center gap-2 rounded-[10px] bg-[#f3f3f4] px-3 text-[15px] dark:bg-white/8">
|
||||
<div className="flex h-8 items-center gap-2 rounded-[var(--ui-radius-control)] bg-[#f3f3f4] px-2.5 text-[13px] dark:bg-white/8">
|
||||
<span className="text-[#68707c] dark:text-zinc-400">H</span>
|
||||
<span>{outputSize.height}</span>
|
||||
</div>
|
||||
@ -533,8 +533,8 @@ export function ImageParamsBar({
|
||||
|
||||
{sizeOptions.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-[14px] font-medium leading-none">Size</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="mb-1.5 text-[12px] font-medium leading-none">Size</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{sizeOptions.map((option) => {
|
||||
const selected =
|
||||
params.aspectRatio === option.aspectRatio &&
|
||||
@ -544,7 +544,7 @@ export function ImageParamsBar({
|
||||
key={`${option.aspectRatio}-${option.imageSize}-${option.label}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-[66px] flex-col items-center justify-center gap-1.5 rounded-[10px] border text-[13px] transition-colors",
|
||||
"flex h-14 flex-col items-center justify-center gap-1 rounded-[var(--ui-radius-control)] border text-[12px] transition-colors",
|
||||
selected
|
||||
? "border-[#bfc4cc] bg-[#e4e4e5] text-[#171b21] dark:border-white/18 dark:bg-white/16 dark:text-zinc-50"
|
||||
: "border-[#d0d3d9] bg-white text-[#4c535d] hover:bg-[#f5f5f6] dark:border-white/12 dark:bg-transparent dark:text-zinc-300 dark:hover:bg-white/8"
|
||||
@ -568,14 +568,14 @@ export function ImageParamsBar({
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[14px] font-medium leading-none">Image</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="mb-1.5 text-[12px] font-medium leading-none">Image</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{imageCountOptions.map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-9 rounded-[9px] border text-[14px] transition-colors",
|
||||
"h-8 rounded-[var(--ui-radius-control)] border text-[12px] transition-colors",
|
||||
selectedImageCount === count
|
||||
? "border-[#bfc4cc] bg-[#e4e4e5] text-[#171b21] dark:border-white/18 dark:bg-white/16 dark:text-zinc-50"
|
||||
: "border-[#d0d3d9] bg-white text-[#4c535d] hover:bg-[#f5f5f6] dark:border-white/12 dark:bg-transparent dark:text-zinc-300 dark:hover:bg-white/8"
|
||||
@ -640,7 +640,7 @@ export function ImageParamsBar({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
"w-[288px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
isDarkInline
|
||||
? "border border-white/12 bg-[#0d1013]/98 text-white backdrop-blur-xl"
|
||||
: "border border-[#d8dde4] bg-[#f3f4f6]/90"
|
||||
@ -651,7 +651,7 @@ export function ImageParamsBar({
|
||||
<p className={cn("text-[11px] font-medium", isDarkInline ? "text-white/45" : "text-[#8a94a3]")}>选择比例</p>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 grid grid-cols-4 gap-1.5 rounded-[12px] p-1.5 md:grid-cols-5",
|
||||
"mt-1.5 grid grid-cols-4 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5 md:grid-cols-5",
|
||||
isDarkInline ? "bg-white/[0.04]" : "bg-[#eceff2]/90"
|
||||
)}
|
||||
>
|
||||
@ -660,7 +660,7 @@ export function ImageParamsBar({
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-[46px] flex-col items-center justify-center rounded-[10px] border transition",
|
||||
"flex h-10 flex-col items-center justify-center rounded-[6px] border transition",
|
||||
params.aspectRatio === option.value
|
||||
? isDarkInline
|
||||
? "border-emerald-400/50 bg-emerald-500/12 text-emerald-200 shadow-[0_1px_5px_rgba(15,23,42,0.18)]"
|
||||
@ -687,7 +687,7 @@ export function ImageParamsBar({
|
||||
<p className={cn("text-[11px] font-medium", isDarkInline ? "text-white/45" : "text-[#8a94a3]")}>选择分辨率</p>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1",
|
||||
"mt-1.5 grid grid-cols-2 gap-1.5 rounded-[var(--ui-radius-control)] p-1",
|
||||
isDarkInline ? "bg-white/[0.04]" : "bg-[#eceff2]/90"
|
||||
)}
|
||||
>
|
||||
@ -696,7 +696,7 @@ export function ImageParamsBar({
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-8 rounded-[9px] text-[12px] font-semibold transition",
|
||||
"h-8 rounded-[6px] text-[12px] font-semibold transition",
|
||||
params.imageSize === option.value
|
||||
? isDarkInline
|
||||
? "bg-emerald-500/12 text-emerald-200 shadow-[0_1px_5px_rgba(15,23,42,0.18)]"
|
||||
@ -779,7 +779,7 @@ export function ImageParamsBar({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"w-auto min-w-[120px] rounded-[14px] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
"w-auto min-w-[120px] rounded-[var(--ui-radius-panel)] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
isDarkInline
|
||||
? "border border-white/12 bg-[#0d1013]/98 text-white backdrop-blur-xl"
|
||||
: "border border-[#d8dde4] bg-[#f3f4f6]/95"
|
||||
@ -788,7 +788,7 @@ export function ImageParamsBar({
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", isDarkInline ? "text-white/45" : "text-[#8a94a3]")}>生成数量</p>
|
||||
<div className={cn(
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] p-1.5",
|
||||
"mt-1.5 grid gap-1.5 rounded-[var(--ui-radius-control)] p-1.5",
|
||||
isDarkInline ? "bg-white/[0.04]" : "bg-[#eceff2]/90",
|
||||
imageCountOptions.length <= 3 ? "grid-cols-3" : "grid-cols-3"
|
||||
)}>
|
||||
@ -797,7 +797,7 @@ export function ImageParamsBar({
|
||||
key={count}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 min-w-[36px] items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
|
||||
"flex h-8 min-w-[36px] items-center justify-center rounded-[6px] border text-[12px] font-semibold transition",
|
||||
(params.imageCount || 1) === count
|
||||
? isDarkInline
|
||||
? "border-emerald-400/50 bg-emerald-500/12 text-emerald-200 shadow-[0_1px_5px_rgba(15,23,42,0.18)]"
|
||||
@ -851,7 +851,7 @@ export function ImageParamsBar({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
"w-[280px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]",
|
||||
isDarkInline
|
||||
? "border border-white/12 bg-[#0d1013]/98 text-white backdrop-blur-xl"
|
||||
: "border border-[#d8dde4] bg-[#f3f4f6]/95"
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
import { AngleEditDialog } from "./angle-edit-dialog";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const AngleEditDialog = dynamic(
|
||||
() => import("./angle-edit-dialog").then((module) => module.AngleEditDialog),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
function triggerBrowserDownload(
|
||||
url: string,
|
||||
filename?: string,
|
||||
|
||||
@ -39,13 +39,13 @@ export function BrandTopBar({
|
||||
<header
|
||||
className={cn(
|
||||
positionClass,
|
||||
"h-[68px] bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90",
|
||||
"h-14 bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full items-center px-7",
|
||||
"flex h-full items-center px-5",
|
||||
showBrand ? "justify-between" : "justify-end",
|
||||
contentClassName
|
||||
)}
|
||||
@ -59,15 +59,15 @@ export function BrandTopBar({
|
||||
{logo ? (
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
className="h-7 w-7 rounded-full object-cover"
|
||||
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">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<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-3.5 w-3.5" />
|
||||
</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}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 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-50 bg-black/55 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 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-50 isolate grid w-full max-w-md 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 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-[var(--ui-radius-panel)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -51,7 +51,7 @@ const AlertDialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
className={cn("text-base font-semibold leading-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -91,7 +91,7 @@ const AlertDialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-[13px] leading-5 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@ -20,10 +20,10 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-9 px-3.5",
|
||||
sm: "h-8 px-2.5 text-xs",
|
||||
lg: "h-10 px-5 text-sm",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -8,6 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
@ -23,6 +24,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
@ -35,6 +37,7 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
@ -50,6 +53,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
@ -60,7 +64,12 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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"
|
||||
|
||||
@ -70,6 +79,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@ -22,7 +22,7 @@ const DialogOverlay = forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -42,15 +42,15 @@ const DialogContent = forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<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-3.5 w-3.5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
@ -66,7 +66,7 @@ function DialogHeader({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -98,7 +98,7 @@ const DialogTitle = forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
"text-base font-semibold leading-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -112,7 +112,7 @@ const DialogDescription = forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-[13px] leading-5 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"flex cursor-default select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-[13px] outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 isolate min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-border/50 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-dropdown-menu-content-transform-origin]",
|
||||
"z-50 isolate min-w-[8rem] overflow-hidden rounded-[var(--ui-radius-control)] border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-border/50 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-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 isolate max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-border/50 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-dropdown-menu-content-transform-origin]",
|
||||
"z-50 isolate max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-[var(--ui-radius-control)] border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-border/50 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-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -83,7 +83,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-[13px] outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -99,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-[5px] py-1.5 pl-8 pr-2 text-[13px] outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@ -123,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-[5px] py-1.5 pl-8 pr-2 text-[13px] outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -147,7 +147,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
"px-2 py-1.5 text-[13px] font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -174,7 +174,7 @@ const DropdownMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
className={cn("ml-auto text-[11px] opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -17,6 +17,7 @@ const Label = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="label"
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -19,14 +19,14 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
@ -79,7 +79,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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" &&
|
||||
"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
|
||||
@ -119,7 +119,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
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}
|
||||
/>
|
||||
))
|
||||
@ -132,7 +132,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
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: {
|
||||
side: {
|
||||
@ -65,8 +65,8 @@ const SheetContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<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-3.5 w-3.5" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
@ -80,7 +80,7 @@ const SheetHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
className={cn("text-base font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@ -120,7 +120,7 @@ const SheetDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-[13px] leading-5 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -28,7 +28,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -52,4 +52,3 @@ const TabsContent = React.forwardRef<
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -499,7 +499,7 @@ export function VideoParamsBar({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
|
||||
"w-[288px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border p-2",
|
||||
panelClass
|
||||
)}
|
||||
>
|
||||
@ -507,7 +507,7 @@ export function VideoParamsBar({
|
||||
{/* 模式 */}
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择模式</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -527,7 +527,7 @@ export function VideoParamsBar({
|
||||
{/* 比例 */}
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择比例</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
|
||||
{ratioOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -548,7 +548,7 @@ export function VideoParamsBar({
|
||||
{showResolutionControl && (
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择分辨率</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1", groupPanelClass)}>
|
||||
{resolutionOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -572,7 +572,7 @@ export function VideoParamsBar({
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>选择时长</p>
|
||||
<div className={cn(
|
||||
"mt-1.5 grid gap-1.5 rounded-[12px] p-1.5",
|
||||
"mt-1.5 grid gap-1.5 rounded-[var(--ui-radius-control)] p-1.5",
|
||||
groupPanelClass,
|
||||
durationOptions.length > 6 ? "grid-cols-5" : "grid-cols-2"
|
||||
)}>
|
||||
@ -616,7 +616,7 @@ export function VideoParamsBar({
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
|
||||
"w-[280px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border p-2",
|
||||
panelClass
|
||||
)}
|
||||
>
|
||||
@ -624,7 +624,7 @@ export function VideoParamsBar({
|
||||
{showGenerateAudioControl ? (
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>音频</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@ -656,7 +656,7 @@ export function VideoParamsBar({
|
||||
) : !showFpsControl && !showCameraMoveControl ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[12px] p-2 text-[11px]",
|
||||
"rounded-[var(--ui-radius-control)] p-2 text-[11px]",
|
||||
isDarkInline
|
||||
? "bg-white/8 text-zinc-400"
|
||||
: "bg-[#eceff2]/90 text-[#7b8796] dark:bg-white/8 dark:text-zinc-400"
|
||||
@ -669,7 +669,7 @@ export function VideoParamsBar({
|
||||
{showFpsControl && (
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>帧率</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
|
||||
{FPS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@ -692,7 +692,7 @@ export function VideoParamsBar({
|
||||
{showCameraMoveControl && (
|
||||
<div>
|
||||
<p className={cn("text-[11px] font-medium", labelClass)}>运镜模式</p>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
|
||||
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[var(--ui-radius-control)] p-1.5", groupPanelClass)}>
|
||||
{CAMERA_MOVE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
|
||||
221
docs/Claw模式技能使用宣导.md
Normal file
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
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
BIN
docs/Claw模式技能使用宣导2.pdf
Normal file
Binary file not shown.
364
docs/video-canvas-competitor-audit.md
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 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) {
|
||||
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) {
|
||||
return MOONSHOT_BASE_URL_PATTERN.test(String(value || "").trim());
|
||||
}
|
||||
@ -55,7 +62,7 @@ export function shouldUseKimiDetailProfile(params: {
|
||||
providerBaseUrl?: string | null;
|
||||
isLocalMoonshot?: boolean;
|
||||
}) {
|
||||
if (!isKimiK26ModelId(params.modelId)) return false;
|
||||
if (!isKimiDetailModelId(params.modelId)) return false;
|
||||
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 =
|
||||
"__moonshot_kimi_k25_local__";
|
||||
export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL =
|
||||
"https://api.moonshot.cn/v1";
|
||||
export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-k2.6";
|
||||
export const DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME = "官方 Kimi K2.6";
|
||||
"https://api.kimi.com/coding/v1";
|
||||
export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-for-coding";
|
||||
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 = {
|
||||
enabled: boolean;
|
||||
@ -23,15 +31,47 @@ export function isDetailAgentLocalMoonshotOption(value: unknown) {
|
||||
|
||||
function normalizeMoonshotModelId(value: unknown) {
|
||||
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 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) {
|
||||
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 displayName;
|
||||
@ -48,9 +88,8 @@ export function normalizeDetailAgentLocalMoonshotConfig(
|
||||
return {
|
||||
enabled: raw.enabled !== false,
|
||||
providerName:
|
||||
String(raw.providerName || "").trim() || "Moonshot AI (Official)",
|
||||
baseUrl:
|
||||
String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||
String(raw.providerName || "").trim() || "Kimi Code 编程套餐",
|
||||
baseUrl: normalizeDetailAgentLocalMoonshotBaseUrl(raw.baseUrl),
|
||||
apiKey: String(raw.apiKey || "").trim(),
|
||||
modelId: normalizeMoonshotModelId(raw.modelId),
|
||||
displayName: normalizeMoonshotDisplayName(raw.displayName),
|
||||
|
||||
@ -181,7 +181,7 @@ export const FAL_IMAGE_MODEL_PRESETS: FalImageModelPreset[] = [
|
||||
"quality",
|
||||
"num_images",
|
||||
"output_format",
|
||||
"mask_url",
|
||||
"mask_image_url",
|
||||
"openai_api_key",
|
||||
],
|
||||
supportsMultiImageReference: true,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
import sharp from "sharp";
|
||||
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
@ -763,8 +764,12 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
|
||||
// 根据图片数量确定编辑模式
|
||||
const editMode = preparedImageUrls.length === 1 ? "单图编辑" : `多图融合 (${preparedImageUrls.length}张)`;
|
||||
const promptForRequest = buildReferenceImageOrderPrompt(
|
||||
prompt,
|
||||
preparedImageUrls
|
||||
);
|
||||
console.log("[Alibaba Image] 编辑模式:", editMode);
|
||||
console.log("[Alibaba Image] Prompt:", prompt.substring(0, 100) + (prompt.length > 100 ? "..." : ""));
|
||||
console.log("[Alibaba Image] Prompt:", promptForRequest.substring(0, 100) + (promptForRequest.length > 100 ? "..." : ""));
|
||||
console.log("[Alibaba Image] Input images:", preparedImageUrls.length);
|
||||
console.log("[Alibaba Image] Final params:", params);
|
||||
|
||||
@ -773,7 +778,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
baseURL,
|
||||
apiKey,
|
||||
activeModelId,
|
||||
prompt,
|
||||
promptForRequest,
|
||||
preparedImageUrls,
|
||||
params
|
||||
);
|
||||
|
||||
@ -22,12 +22,14 @@ interface FalImageParams {
|
||||
safetyTolerance?: string | number;
|
||||
seed?: string | number;
|
||||
enableWebSearch?: boolean;
|
||||
falMaskImageUrl?: string;
|
||||
quality?: string;
|
||||
maskImageUrl?: string;
|
||||
openaiApiKey?: string;
|
||||
systemPrompt?: string;
|
||||
thinkingLevel?: string;
|
||||
syncMode?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface UploadedImage {
|
||||
@ -50,6 +52,8 @@ const DEFAULT_RESOLUTION = "1K";
|
||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||
const DEFAULT_SAFETY_TOLERANCE = "4";
|
||||
const DEFAULT_TIMEOUT_MS = 600000;
|
||||
const MIN_TIMEOUT_MS = 30_000;
|
||||
const MAX_TIMEOUT_MS = 600_000;
|
||||
const FAL_GENERIC_MAX_EDGE = 4096;
|
||||
const FAL_GENERIC_MAX_PIXELS = 4096 * 4096;
|
||||
const GPT_IMAGE_2_MAX_EDGE = 3840;
|
||||
@ -434,6 +438,12 @@ function normalizeOptionalHttpUrl(value?: string) {
|
||||
return /^https?:\/\//i.test(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: unknown) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_TIMEOUT_MS;
|
||||
return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function detectImageExtension(imageUrl: string, contentType?: string | null) {
|
||||
const type = (contentType || "").toLowerCase();
|
||||
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
|
||||
@ -606,7 +616,16 @@ function summarizeFalError(error: any) {
|
||||
error?.message ||
|
||||
(typeof body === "string" ? body : "") ||
|
||||
"FAL request failed";
|
||||
const cause = error?.cause;
|
||||
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,
|
||||
status,
|
||||
message,
|
||||
@ -650,6 +669,67 @@ function createFalClientFromApiKey(apiKey: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadImageUrlToFalStorage(params: {
|
||||
fal: Awaited<ReturnType<typeof createFalClientFromApiKey>>;
|
||||
url: string;
|
||||
}) {
|
||||
const { fal, url } = params;
|
||||
if (/fal\.(?:media|run)\//i.test(url) || /fal-ai/i.test(url)) return url;
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`FAL input image download failed before storage upload: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const blob = new Blob([buffer], { type: contentType });
|
||||
return fal.storage.upload(blob);
|
||||
}
|
||||
|
||||
async function prepareFalInputImageUrls(params: {
|
||||
fal: Awaited<ReturnType<typeof createFalClientFromApiKey>>;
|
||||
input: Record<string, any>;
|
||||
sourceImageUrls: string[];
|
||||
}) {
|
||||
const { fal, input, sourceImageUrls } = params;
|
||||
const cache = new Map<string, Promise<string>>();
|
||||
const upload = (url: string) => {
|
||||
const normalized = String(url || "").trim();
|
||||
if (!/^https?:\/\//i.test(normalized)) return Promise.resolve(normalized);
|
||||
const existing = cache.get(normalized);
|
||||
if (existing) return existing;
|
||||
const uploaded = uploadImageUrlToFalStorage({ fal, url: normalized });
|
||||
cache.set(normalized, uploaded);
|
||||
return uploaded;
|
||||
};
|
||||
|
||||
const preparedInput = { ...input };
|
||||
if (typeof preparedInput.image_url === "string") {
|
||||
preparedInput.image_url = await upload(preparedInput.image_url);
|
||||
}
|
||||
if (Array.isArray(preparedInput.image_urls)) {
|
||||
preparedInput.image_urls = await Promise.all(
|
||||
preparedInput.image_urls.map((url) => upload(String(url || "")))
|
||||
);
|
||||
}
|
||||
if (typeof preparedInput.mask_url === "string") {
|
||||
preparedInput.mask_url = await upload(preparedInput.mask_url);
|
||||
}
|
||||
if (typeof preparedInput.mask_image_url === "string") {
|
||||
preparedInput.mask_image_url = await upload(preparedInput.mask_image_url);
|
||||
}
|
||||
|
||||
const preparedSourceImageUrls = await Promise.all(
|
||||
sourceImageUrls.map((url) => upload(url))
|
||||
);
|
||||
|
||||
return {
|
||||
input: preparedInput,
|
||||
sourceImageUrls: preparedSourceImageUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFalRequestPayload(params: {
|
||||
modelId: string;
|
||||
overrideModelId?: string;
|
||||
@ -696,9 +776,11 @@ function buildFalRequestPayload(params: {
|
||||
output_format: outputFormat,
|
||||
};
|
||||
|
||||
const maskImageUrl = normalizeOptionalHttpUrl(params.imageParams?.maskImageUrl);
|
||||
const maskImageUrl =
|
||||
normalizeOptionalHttpUrl(params.imageParams?.falMaskImageUrl) ||
|
||||
normalizeOptionalHttpUrl(params.imageParams?.maskImageUrl);
|
||||
if (maskImageUrl) {
|
||||
input.mask_url = maskImageUrl;
|
||||
input.mask_image_url = maskImageUrl;
|
||||
}
|
||||
|
||||
if (seed !== undefined) {
|
||||
@ -828,18 +910,26 @@ export async function submitFalImageTask(params: {
|
||||
}): Promise<FalSubmittedImageTask> {
|
||||
const request = buildFalRequestPayload(params);
|
||||
const fal = await createFalClientFromApiKey(params.apiKey);
|
||||
const prepared = await prepareFalInputImageUrls({
|
||||
fal,
|
||||
input: request.input,
|
||||
sourceImageUrls: request.imageUrls,
|
||||
});
|
||||
|
||||
console.log("[Fal Image] Queue submit", {
|
||||
model: request.activeModelId,
|
||||
endpoint: request.falEndpointId,
|
||||
imageCount: request.imageCount,
|
||||
hasInputImages: request.imageUrls.length > 0,
|
||||
hasInputImages: prepared.sourceImageUrls.length > 0,
|
||||
resolution: request.outputSizeLabel,
|
||||
inputKeys: Object.keys(request.input),
|
||||
inputKeys: Object.keys(prepared.input),
|
||||
storagePrepared: prepared.sourceImageUrls.some(
|
||||
(url, index) => url !== request.imageUrls[index]
|
||||
),
|
||||
});
|
||||
|
||||
const enqueueResult = await fal.queue.submit(request.falEndpointId, {
|
||||
input: request.input,
|
||||
input: prepared.input,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -849,7 +939,7 @@ export async function submitFalImageTask(params: {
|
||||
imageCount: request.imageCount,
|
||||
outputSizeLabel: request.outputSizeLabel,
|
||||
prompt: request.prompt,
|
||||
sourceImageUrls: request.imageUrls,
|
||||
sourceImageUrls: prepared.sourceImageUrls,
|
||||
};
|
||||
}
|
||||
|
||||
@ -914,13 +1004,19 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
});
|
||||
|
||||
const fal = await createFalClientFromApiKey(apiKey);
|
||||
const prepared = await prepareFalInputImageUrls({
|
||||
fal,
|
||||
input: request.input,
|
||||
sourceImageUrls: request.imageUrls,
|
||||
});
|
||||
const timeoutMs = normalizeTimeoutMs(options.imageParams?.timeoutMs);
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await fal.subscribe(request.falEndpointId, {
|
||||
input: request.input,
|
||||
input: prepared.input,
|
||||
logs: true,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
timeout: timeoutMs,
|
||||
onQueueUpdate: (update: any) => {
|
||||
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
|
||||
for (const log of update.logs) {
|
||||
@ -938,7 +1034,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
|
||||
const text = await normalizeFalResultToMarkdown({
|
||||
payload: result,
|
||||
sourceImageUrls: request.imageUrls,
|
||||
sourceImageUrls: prepared.sourceImageUrls,
|
||||
imageCount: request.imageCount,
|
||||
outputSizeLabel: request.outputSizeLabel,
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { resolveGrsaiGptImageSize } from "@/lib/ai/grsai-gpt-image-sizes";
|
||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
@ -1141,17 +1142,18 @@ async function sendGeminiRequest(
|
||||
processedUrls.push(s3Url);
|
||||
}
|
||||
}
|
||||
const promptForRequest = buildReferenceImageOrderPrompt(prompt, processedUrls);
|
||||
|
||||
logger.debug(`${apiLogPrefix} Sending request to:`, url);
|
||||
logger.debug(`${apiLogPrefix} Using model:`, modelId);
|
||||
logger.debug(`${apiLogPrefix} Prompt:`, prompt.substring(0, 100));
|
||||
logger.debug(`${apiLogPrefix} Prompt:`, promptForRequest.substring(0, 100));
|
||||
logger.debug(`${apiLogPrefix} Reference image count:`, urls.length);
|
||||
logger.debug(`${apiLogPrefix} Image params:`, imageParams);
|
||||
|
||||
// 使用对应模型的原生格式
|
||||
const requestBody: any = {
|
||||
model: modelId,
|
||||
prompt: prompt,
|
||||
prompt: promptForRequest,
|
||||
};
|
||||
|
||||
if (isGptImageModel) {
|
||||
@ -1191,7 +1193,7 @@ async function sendGeminiRequest(
|
||||
baseURL,
|
||||
apiKey,
|
||||
modelId,
|
||||
prompt,
|
||||
prompt: promptForRequest,
|
||||
size: resolvedSize,
|
||||
imageUrls: processedUrls,
|
||||
timeout,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export function buildSnapgenReferenceOrderPrompt(prompt: string, imageUrls: string[]) {
|
||||
export function buildReferenceImageOrderPrompt(prompt: string, imageUrls: string[]) {
|
||||
const normalizedPrompt = String(prompt || "").trim();
|
||||
const referenceCount = imageUrls.filter((url) => String(url || "").trim()).length;
|
||||
if (referenceCount < 2) return normalizedPrompt;
|
||||
@ -6,6 +6,8 @@ export function buildSnapgenReferenceOrderPrompt(prompt: string, imageUrls: stri
|
||||
return [
|
||||
`参考图顺序说明:本次请求包含 ${referenceCount} 张参考图,顺序与上传/选中顺序一致。`,
|
||||
"用户提示中的“第一张图/图1/图一/图片1/图片一”对应第 1 张参考图,“第二张图/图2/图二/图片2/图片二”对应第 2 张参考图,依次类推。",
|
||||
"必须读取全部参考图,不得忽略后续图片;严格按用户提示判断目标底图、素材图及编辑关系,不要交换图片编号。",
|
||||
"若用户要求“把图A中的对象放入图B”,图A是对象素材来源,图B是目标底图;最终结果以图B为基础,只修改用户指定区域,保留图B其余内容,不要反向替换,也不要输出拼图或并排对比图。",
|
||||
"本说明只用于明确图片编号,不改变用户对主体、风格、构图、权重或融合方式的要求;若用户指定主参考或融合方式,以用户提示为准。",
|
||||
normalizedPrompt,
|
||||
]
|
||||
@ -3,7 +3,7 @@ import "server-only";
|
||||
import sharp from "sharp";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { buildSnapgenReferenceOrderPrompt } from "@/lib/ai/providers/snapgen-reference-order";
|
||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||
import {
|
||||
SNAPGEN_GPT_IMAGE_2_REQUEST_ASPECT_RATIOS,
|
||||
SNAPGEN_STANDARD_REQUEST_ASPECT_RATIOS,
|
||||
@ -1026,7 +1026,7 @@ export function createSnapgenImage({ apiKey, baseURL }: SnapgenImageConfig) {
|
||||
);
|
||||
const maxImages = options.imageGenConfig?.maxImages || (apiFormat === "snapgen-grok-image" ? 8 : apiFormat === "snapgen-meta-ai-image" ? 4 : 1);
|
||||
const imageCount = clampImageCount(options.imageParams?.imageCount, maxImages);
|
||||
const promptForRequest = buildSnapgenReferenceOrderPrompt(prompt, imageUrls);
|
||||
const promptForRequest = buildReferenceImageOrderPrompt(prompt, imageUrls);
|
||||
const formData = await buildRequestForm({
|
||||
apiFormat,
|
||||
prompt: promptForRequest,
|
||||
|
||||
@ -43,6 +43,7 @@ async function getQwenImageProviderApiKey() {
|
||||
export async function callQwen3Vl(params: {
|
||||
imageUrl?: string;
|
||||
imageUrls?: string[];
|
||||
model?: string;
|
||||
prompt: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
@ -64,9 +65,17 @@ export async function callQwen3Vl(params: {
|
||||
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;
|
||||
|
||||
for (const model of QWEN3_VL_MODELS) {
|
||||
for (const model of models) {
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
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(
|
||||
(item) =>
|
||||
item.model.id === parentModelId &&
|
||||
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video"),
|
||||
isDirectExecutableVideoModel(item),
|
||||
);
|
||||
|
||||
if (!directModel) {
|
||||
@ -199,13 +222,13 @@ async function getCompatibleModel(
|
||||
|
||||
const volcanoVideoModels = models.filter(
|
||||
(m) =>
|
||||
(m.provider.type === "volcano-video" || m.provider.type === "doubao-video") &&
|
||||
isDirectExecutableVideoModel(m) &&
|
||||
m.model.isEnabled &&
|
||||
m.model.capabilities
|
||||
);
|
||||
|
||||
if (volcanoVideoModels.length === 0) {
|
||||
throw new Error("没有启用的火山引擎视频模型");
|
||||
throw new Error("没有启用的可执行视频模型");
|
||||
}
|
||||
|
||||
const compatibleModels = volcanoVideoModels.filter((m) => {
|
||||
@ -339,7 +362,7 @@ async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const matched = models.find(
|
||||
(item) =>
|
||||
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") &&
|
||||
isDirectExecutableVideoModel(item) &&
|
||||
(item.model.id === normalizedRef || item.model.modelId === normalizedRef),
|
||||
);
|
||||
|
||||
@ -350,6 +373,153 @@ async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
|
||||
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(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
@ -365,13 +535,13 @@ export async function executeDirectVideoGeneration(
|
||||
const fallbackParentModel =
|
||||
allModels.find(
|
||||
(item) =>
|
||||
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") &&
|
||||
isVolcanoVideoModel(item) &&
|
||||
!item.model.isSubModel &&
|
||||
item.model.modelId === "volcano-video-master",
|
||||
) ||
|
||||
allModels.find(
|
||||
(item) =>
|
||||
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") &&
|
||||
isDirectExecutableVideoModel(item) &&
|
||||
!item.model.isSubModel,
|
||||
);
|
||||
|
||||
@ -380,7 +550,7 @@ export async function executeDirectVideoGeneration(
|
||||
fallbackParentModel?.model.id ||
|
||||
allModels.find(
|
||||
(item) =>
|
||||
(item.provider.type === "volcano-video" || item.provider.type === "doubao-video") &&
|
||||
isDirectExecutableVideoModel(item) &&
|
||||
item.model.isEnabled &&
|
||||
item.model.isSubModel &&
|
||||
item.model.parentModelId,
|
||||
@ -408,6 +578,12 @@ export async function executeDirectVideoGeneration(
|
||||
);
|
||||
} catch (preferredModelError) {
|
||||
if (parentModelId) {
|
||||
if (
|
||||
preferredModelError instanceof Error &&
|
||||
/不支持|没有模型支持/.test(preferredModelError.message)
|
||||
) {
|
||||
throw preferredModelError;
|
||||
}
|
||||
console.warn(
|
||||
"[Generate Video Tool] Preferred parent model selection failed, fallback to global compatible model:",
|
||||
preferredModelError,
|
||||
@ -435,14 +611,26 @@ export async function executeDirectVideoGeneration(
|
||||
);
|
||||
|
||||
let videoMetadata: Record<string, any> | null = null;
|
||||
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);
|
||||
if (isAgnesVideoModel(model)) {
|
||||
const agnesResult = await executeAgnesVideoGeneration({
|
||||
apiKey: model.provider.apiKey,
|
||||
baseUrl: model.provider.baseUrl,
|
||||
options,
|
||||
});
|
||||
videoMetadata = {
|
||||
...(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -59,7 +59,7 @@ interface QueueState {
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const PENDING_LOOKAHEAD_LIMIT = 100;
|
||||
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
|
||||
|
||||
81
lib/chat/message-image-attachments.ts
Normal file
81
lib/chat/message-image-attachments.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { Attachment } from "@/lib/types";
|
||||
|
||||
type MessageImageSource = {
|
||||
parts?: unknown[] | null;
|
||||
attachments?: unknown[] | null;
|
||||
};
|
||||
|
||||
function normalizeImageContentType(value: unknown) {
|
||||
const contentType = String(value || "").trim().toLowerCase();
|
||||
return contentType.startsWith("image/") ? contentType : "image/jpeg";
|
||||
}
|
||||
|
||||
export function getMessageImagePartUrl(part: unknown) {
|
||||
const value = part as Record<string, any> | null;
|
||||
if (!value) return "";
|
||||
|
||||
const imageUrl = value.image_url;
|
||||
const candidate =
|
||||
value.type === "image_url"
|
||||
? typeof imageUrl === "string"
|
||||
? imageUrl
|
||||
: imageUrl?.url || value.url
|
||||
: value.type === "image" || value.type === "input_image"
|
||||
? value.image || value.url || value.data
|
||||
: value.type === "file"
|
||||
? value.url || value.data
|
||||
: "";
|
||||
|
||||
return typeof candidate === "string" ? candidate.trim() : "";
|
||||
}
|
||||
|
||||
export function collectMessageImageAttachments(
|
||||
message: MessageImageSource | null | undefined
|
||||
): Attachment[] {
|
||||
if (!message) return [];
|
||||
|
||||
const result: Attachment[] = [];
|
||||
const seen = new Set<string>();
|
||||
const append = (value: unknown, fallbackIndex: number, fromPart: boolean) => {
|
||||
const item = value as Record<string, any> | null;
|
||||
if (!item) return;
|
||||
|
||||
const url = fromPart
|
||||
? getMessageImagePartUrl(item)
|
||||
: typeof item.url === "string"
|
||||
? item.url.trim()
|
||||
: "";
|
||||
if (!url || seen.has(url)) return;
|
||||
|
||||
const explicitContentType = String(
|
||||
item.contentType || item.mediaType || item.mimeType || ""
|
||||
).trim();
|
||||
if (explicitContentType && !explicitContentType.startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(url);
|
||||
result.push({
|
||||
name: String(item.name || item.filename || item.alt || `图片${fallbackIndex + 1}`),
|
||||
url,
|
||||
contentType: normalizeImageContentType(explicitContentType),
|
||||
assetId:
|
||||
typeof item.assetId === "string" && item.assetId.trim()
|
||||
? item.assetId.trim()
|
||||
: undefined,
|
||||
width: Number.isFinite(Number(item.width)) ? Number(item.width) : undefined,
|
||||
height: Number.isFinite(Number(item.height)) ? Number(item.height) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
(Array.isArray(message.parts) ? message.parts : []).forEach((part, index) => {
|
||||
append(part, index, true);
|
||||
});
|
||||
(Array.isArray(message.attachments) ? message.attachments : []).forEach(
|
||||
(attachment, index) => {
|
||||
append(attachment, result.length + index, false);
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
42
lib/client/canvas-chat-preload.ts
Normal file
42
lib/client/canvas-chat-preload.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { preload } from "swr";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
const CANVAS_CHAT_DATA_KEYS = [
|
||||
"/api/models?includeUnavailable=1",
|
||||
"/api/site-settings?scope=canvas-chat",
|
||||
"/api/canvas-skill-tags",
|
||||
] as const;
|
||||
|
||||
async function nullableNoStoreFetcher(url: string) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function preloadWithoutBlocking(
|
||||
key: string,
|
||||
dataFetcher: (url: string) => Promise<unknown>
|
||||
) {
|
||||
void Promise.resolve(preload(key, dataFetcher)).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function preloadCanvasChatData(canvasId?: string | null) {
|
||||
CANVAS_CHAT_DATA_KEYS.forEach((key) => {
|
||||
preloadWithoutBlocking(key, fetcher);
|
||||
});
|
||||
|
||||
preloadWithoutBlocking(
|
||||
"/api/user/membership-summary",
|
||||
nullableNoStoreFetcher
|
||||
);
|
||||
preloadWithoutBlocking("/api/user/image-stats", nullableNoStoreFetcher);
|
||||
|
||||
const normalizedCanvasId = String(canvasId || "").trim();
|
||||
if (normalizedCanvasId) {
|
||||
const params = new URLSearchParams({
|
||||
chatId: normalizedCanvasId,
|
||||
scope: "canvas",
|
||||
});
|
||||
preloadWithoutBlocking(`/api/messages?${params.toString()}`, fetcher);
|
||||
}
|
||||
}
|
||||
13
lib/client/canvas-workspace-preload.ts
Normal file
13
lib/client/canvas-workspace-preload.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { preloadCanvasEditor } from "@/app/canvas/[id]/canvas-editor-loader";
|
||||
import { preloadCanvasChatPanel } from "@/components/canvas/canvas-chat-panel-loader";
|
||||
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
||||
|
||||
export function preloadCanvasWorkspace() {
|
||||
preloadCanvasEditor();
|
||||
preloadCanvasChatPanel();
|
||||
}
|
||||
|
||||
export function prepareCanvasNavigation(canvasId?: string | null) {
|
||||
preloadCanvasWorkspace();
|
||||
preloadCanvasChatData(canvasId);
|
||||
}
|
||||
35
lib/db/migrations/0088_add_access_ip_events.sql
Normal file
35
lib/db/migrations/0088_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/0089_add_access_ip_blocklist.sql
Normal file
20
lib/db/migrations/0089_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");
|
||||
@ -519,6 +519,20 @@
|
||||
"when": 1783680000000,
|
||||
"tag": "0087_add_image_archive_tasks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1783680600000,
|
||||
"tag": "0088_add_access_ip_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1783681200000,
|
||||
"tag": "0089_add_access_ip_blocklist",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -4760,9 +4760,19 @@ export async function getFrontendDisplayAiModelsWithProvider(
|
||||
|
||||
export async function getAgentAiModelWithProvider() {
|
||||
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
|
||||
.filter(({ model }) => isAiModelAgentAuto(model))
|
||||
.filter(({ model }) => isAiModelAgentAuto(model) && model.healthStatus !== "auto_disabled")
|
||||
.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) {
|
||||
return Number(b.model.isDefault) - Number(a.model.isDefault);
|
||||
}
|
||||
|
||||
@ -675,6 +675,43 @@ export type ClientPerformanceEvent = InferSelectModel<
|
||||
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", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
200
lib/security/access-log.ts
Normal file
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"),
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user