From 3252cbd2c829a745f942aa1d3553a2ed1e800885 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 10 Jul 2026 12:57:01 +0800 Subject: [PATCH] feat: refine canvas workflows and unify application UI --- .../_components/access-security-dashboard.tsx | 698 + .../_components/admin-settings-navigation.tsx | 56 +- .../_components/local-ai-command-center.tsx | 42 +- .../_components/local-ai-section-shell.tsx | 24 +- .../local-ai-sections/planning-sections.tsx | 20 +- .../_components/setting-status-badge.tsx | 2 +- .../settings/_lib/settings-navigation.ts | 7 + .../settings/admin-settings-client.tsx | 131 +- .../agent-workflow-settings-client.tsx | 27 +- .../psd-templates-settings-client.tsx | 25 +- .../style-pro/style-pro-settings-client.tsx | 14 +- .../video-canvas-settings-client.tsx | 180 +- app/(auth)/auth.ts | 32 +- app/(chat)/api/files/direct-upload/route.ts | 18 +- app/(chat)/api/models/route.ts | 72 + app/api/admin/access-blocklist/route.ts | 207 + app/api/admin/access-logs/route.ts | 271 + app/api/admin/settings/route.ts | 24 +- app/api/canvas/[id]/route.ts | 63 +- app/api/canvas/workflow/analyze/route.ts | 456 +- app/api/favicon/route.ts | 9 + app/api/internal/access-log/route.ts | 147 + app/api/internal/ip-blocklist/check/route.ts | 140 + app/api/pick-object/route.ts | 119 +- app/api/site-settings/route.ts | 30 + app/api/user/membership-summary/route.ts | 30 +- app/api/video-canvas/config/route.ts | 4 + .../[id]/CanvasEditorLegacyPageClient.tsx | 31 +- app/canvas/[id]/CanvasPageClientRouter.tsx | 2 +- app/canvas/[id]/page.tsx | 11 +- app/globals.css | 136 + components/canvas/CanvasChatPanel.tsx | 132 +- .../canvas/LiblibVideoCanvasReplica.tsx | 14161 +++++++++++++++- components/elements/actions.tsx | 2 +- components/elements/conversation.tsx | 2 +- components/elements/message.tsx | 4 +- components/elements/prompt-input.tsx | 4 +- components/elements/response.tsx | 12 +- components/elements/suggestion.tsx | 2 +- components/home/home-animation-embed.tsx | 36 +- components/home/lovart-home.tsx | 100 +- .../home/lovart/lovart-featured-gallery.tsx | 10 +- .../home/lovart/lovart-hero-composer.tsx | 69 +- .../home/lovart/lovart-projects-section.tsx | 26 +- components/home/lovart/lovart-sidebar.tsx | 32 +- components/layout/brand-top-bar.tsx | 12 +- components/ui/button.tsx | 10 +- components/ui/card.tsx | 12 +- components/ui/dialog.tsx | 14 +- components/ui/input.tsx | 2 +- components/ui/label.tsx | 1 + components/ui/popover.tsx | 2 +- components/ui/select.tsx | 10 +- components/ui/sheet.tsx | 12 +- components/ui/tabs.tsx | 5 +- components/ui/textarea.tsx | 2 +- components/ui/tooltip.tsx | 2 +- docs/Claw模式技能使用宣导.md | 221 + docs/Claw模式技能使用宣导.txt | 197 + docs/Claw模式技能使用宣导2.pdf | Bin 0 -> 191290 bytes docs/video-canvas-competitor-audit.md | 364 + lib/ai/detail-agent-kimi-profile.ts | 11 +- lib/ai/detail-agent-model-config.ts | 55 +- lib/ai/providers/fal-image.ts | 9 + lib/ai/qwen3-vl.ts | 11 +- lib/ai/tools/generate-video.ts | 218 +- lib/client/upload-with-retry.ts | 2 +- .../migrations/0087_add_access_ip_events.sql | 35 + .../0088_add_access_ip_blocklist.sql | 20 + lib/db/migrations/meta/_journal.json | 14 + lib/db/queries.ts | 12 +- lib/db/schema.ts | 37 + lib/security/access-log.ts | 200 + lib/security/manual-ip-blocklist.ts | 88 + lib/storage/service.ts | 3 +- lib/video-canvas/config.ts | 207 + proxy.ts | 501 +- public/video-canvas/effects/bee-camera.webp | Bin 0 -> 3050362 bytes public/video-canvas/effects/cloud-dive.webp | Bin 0 -> 2110984 bytes public/video-canvas/effects/earth-dive.webp | Bin 0 -> 1934196 bytes public/video-canvas/effects/earth-zoom.webp | Bin 0 -> 1315378 bytes public/video-canvas/effects/global-zoom.webp | Bin 0 -> 1926700 bytes .../video-canvas/effects/horizon-flight.webp | Bin 0 -> 1784842 bytes public/video-canvas/effects/iris-push.webp | Bin 0 -> 1652144 bytes public/video-canvas/effects/multi-angle.webp | Bin 0 -> 1754590 bytes public/video-canvas/effects/prada-change.webp | Bin 0 -> 1407378 bytes .../effects/product-light-sweep.webp | Bin 0 -> 1020162 bytes .../video-canvas/effects/reverse-gravity.webp | Bin 0 -> 2052752 bytes .../video-canvas/effects/underwater-slow.webp | Bin 0 -> 1872114 bytes .../storage/configure-direct-upload-cors.ts | 147 +- .../storage/configure-multipart-lifecycle.ts | 181 +- services/canvas/index.ts | 12 + 92 files changed, 18716 insertions(+), 1533 deletions(-) create mode 100644 app/(admin)/settings/_components/access-security-dashboard.tsx create mode 100644 app/api/admin/access-blocklist/route.ts create mode 100644 app/api/admin/access-logs/route.ts create mode 100644 app/api/internal/access-log/route.ts create mode 100644 app/api/internal/ip-blocklist/check/route.ts create mode 100644 docs/Claw模式技能使用宣导.md create mode 100644 docs/Claw模式技能使用宣导.txt create mode 100644 docs/Claw模式技能使用宣导2.pdf create mode 100644 docs/video-canvas-competitor-audit.md create mode 100644 lib/db/migrations/0087_add_access_ip_events.sql create mode 100644 lib/db/migrations/0088_add_access_ip_blocklist.sql create mode 100644 lib/security/access-log.ts create mode 100644 lib/security/manual-ip-blocklist.ts create mode 100644 public/video-canvas/effects/bee-camera.webp create mode 100644 public/video-canvas/effects/cloud-dive.webp create mode 100644 public/video-canvas/effects/earth-dive.webp create mode 100644 public/video-canvas/effects/earth-zoom.webp create mode 100644 public/video-canvas/effects/global-zoom.webp create mode 100644 public/video-canvas/effects/horizon-flight.webp create mode 100644 public/video-canvas/effects/iris-push.webp create mode 100644 public/video-canvas/effects/multi-angle.webp create mode 100644 public/video-canvas/effects/prada-change.webp create mode 100644 public/video-canvas/effects/product-light-sweep.webp create mode 100644 public/video-canvas/effects/reverse-gravity.webp create mode 100644 public/video-canvas/effects/underwater-slow.webp diff --git a/app/(admin)/settings/_components/access-security-dashboard.tsx b/app/(admin)/settings/_components/access-security-dashboard.tsx new file mode 100644 index 000000000..2662cfeb7 --- /dev/null +++ b/app/(admin)/settings/_components/access-security-dashboard.tsx @@ -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 = { + info: "正常", + watch: "关注", + high: "异常", + critical: "高危", +}; + +const severityClassNames: Record = { + 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 ( + + {severityLabels[severity]} + + ); +} + +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( + 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 ( +
+ + +
+
+ 访问安全分析 + + IP 访问记录、登录来源、异常标注与请求趋势 + +
+
+
+ + setQuery(event.target.value)} + placeholder="搜索 IP、路径、用户" + value={query} + /> +
+ + + +
+
+
+ +
+ {overviewCards.map((item) => { + const Icon = item.icon; + return ( +
+
+
{item.label}
+ +
+
+ {item.value} +
+
{item.note}
+
+ ); + })} +
+ +
+
+
+ + 小时趋势 +
+
+ 橙色表示异常事件占比 +
+
+
+ {(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 ( +
+
+
+ {item.hour} · {formatNumber(item.count)} 次 · 异常{" "} + {formatNumber(item.abnormalCount)} +
+
+ ); + })} + {(!data?.hourlyStats || data.hourlyStats.length === 0) && ( +
+ 暂无访问记录 +
+ )} +
+
+ + + + + + 手动 IP 黑名单 + + 管理员手动加入后,命中 IP 会被阻断并写入访问日志 + + + +
+ setBlockIp(event.target.value)} + placeholder="IP 或 IPv4 CIDR" + value={blockIp} + /> + setBlockReason(event.target.value)} + placeholder="拉黑原因" + value={blockReason} + /> + +
+ +
+ {activeBlocklistEntries.map((entry) => ( +
+
+
+
+ + + {entry.ip} + +
+
+ {entry.reason || "未填写原因"} +
+
+ {formatTime(entry.updatedAt)} +
+
+ +
+
+ ))} + {activeBlocklistEntries.length === 0 ? ( +
+ 暂无手动拉黑 IP +
+ ) : null} +
+
+
+ +
+ + + 异常 IP 标注 + + 按限流、失败率、认证异常、敏感路径和请求量综合排序 + + + +
+ {(data?.topIps || []).map((item) => ( +
+
+
+
+ + {item.ip} + + +
+
+ {formatNumber(item.total)} 次访问 + {formatNumber(item.uniqueUsers)} 个用户 + 失败率 {formatPercent(item.failureRate)} + 最近 {formatTime(item.lastSeen)} +
+
+
+ {(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map( + (reason) => ( + + {reason} + + ) + )} + +
+
+
+ ))} + {(!data?.topIps || data.topIps.length === 0) && ( +
+ 暂无 IP 聚合数据 +
+ )} +
+
+
+ + + + 访问路径 + 按请求数量排序 + + +
+ {(data?.pathStats || []).map((item) => { + const abnormalRate = + item.count > 0 ? item.abnormalCount / item.count : 0; + return ( +
+
+ + {item.path || "/"} + + + {formatNumber(item.count)} + +
+
+
+
+
+
+ ); + })} + {(!data?.pathStats || data.pathStats.length === 0) && ( +
+ 暂无路径统计 +
+ )} +
+ + +
+ + + +
+
+ 访问日志列表 + 最近 {formatNumber(data?.recentEvents.length)} 条 +
+
+ {(data?.rules || []).map((rule) => ( + + {rule} + + ))} +
+
+
+ +
+
+
+
时间
+
IP
+
状态
+
路径
+
用户
+
标注
+
+ {(data?.recentEvents || []).map((event) => ( +
+
+ {formatTime(event.createdAt)} +
+
{event.ip}
+
+ {event.statusCode || "--"} +
+
+
+ {event.method || "GET"} {event.path || "/"} +
+
+ {event.reasons?.join("、") || event.outcome} +
+
+
+
+ {event.userEmail || "匿名"} +
+
+ + {event.userRole || "unknown"} +
+
+
+ {event.severity === "high" || event.severity === "critical" ? ( + + ) : null} + +
+
+ ))} + {(!data?.recentEvents || data.recentEvents.length === 0) && ( +
+ 暂无访问日志 +
+ )} +
+
+
+
+
+ ); +} diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index 346e073b7..f449fa28f 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -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" > - + {item.label} @@ -59,7 +59,7 @@ function SettingsQuickAction({ size="sm" variant="ghost" > - + {item.label} ); @@ -84,11 +84,14 @@ export function AdminSettingsNavigation({ }; return ( -
-
+