From af8189908db7391643255c37f2f97ea4b5b5fb54 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 3 Nov 2026 17:41:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=AE=B0=E5=BD=95=E5=88=86=E9=A1=B5=E4=B8=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。 模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。 验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。 已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。 --- .../settings/admin-settings-client.tsx | 948 +++--------------- app/api/admin/ai-model-health/route.ts | 36 +- app/api/admin/ai-models/route.ts | 26 +- app/api/admin/images/route.ts | 246 +---- app/api/admin/media-tasks/route.ts | 31 + app/api/admin/vip98k-models/route.ts | 16 + components/admin/admin-surface.tsx | 30 + components/admin/media-records-panel.tsx | 601 +++++++++++ components/admin/media-thumbnail.tsx | 147 +++ components/admin/model-health-panel.tsx | 191 ++++ components/admin/model-management-list.tsx | 326 ++++++ lib/admin/media-records.ts | 83 ++ lib/admin/preview-queue.ts | 31 + lib/admin/record-time.ts | 7 + package.json | 1 + playwright.helpers.config.ts | 1 + pnpm-lock.yaml | 8 + scripts/sql/admin-media-record-indexes.sql | 10 + tests/routes/admin-media-loading.test.ts | 45 + 19 files changed, 1708 insertions(+), 1076 deletions(-) create mode 100644 app/api/admin/media-tasks/route.ts create mode 100644 components/admin/admin-surface.tsx create mode 100644 components/admin/media-records-panel.tsx create mode 100644 components/admin/media-thumbnail.tsx create mode 100644 components/admin/model-health-panel.tsx create mode 100644 components/admin/model-management-list.tsx create mode 100644 lib/admin/media-records.ts create mode 100644 lib/admin/preview-queue.ts create mode 100644 lib/admin/record-time.ts create mode 100644 scripts/sql/admin-media-record-indexes.sql create mode 100644 tests/routes/admin-media-loading.test.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 28f553080..1cfa1078c 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -96,6 +96,10 @@ import { } from "react"; import { toast } from "sonner"; import useSWR from "swr"; +import { ModelManagementList } from "@/components/admin/model-management-list"; +import { ModelHealthPanel } from "@/components/admin/model-health-panel"; +import { AdminSurface } from "@/components/admin/admin-surface"; +import { MediaRecordsPanel } from "@/components/admin/media-records-panel"; import { ExternalImageApiSettingsCard } from "@/components/admin/external-image-api-settings-card"; import { CANVAS_SKILL_ICON_OPTIONS, @@ -1750,7 +1754,13 @@ function isMoonshotOfficialBaseUrl(value?: string | null) { } export function AdminSettingsClient() { - const [activeTab, setActiveTab] = useState("users"); + const [activeTab, setActiveTab] = useState(""); + const [aiSection, setAiSection] = useState("models"); + const [syncPreview, setSyncPreview] = useState<{ providerId: string; added: Array<{ modelId: string; displayName: string }>; updated: Array<{ modelId: string; displayName: string; changedFields: string[] }>; disabled: Array<{ modelId: string; displayName: string }> } | null>(null); + const [syncBusy, setSyncBusy] = useState(false); + const [modelSaving, setModelSaving] = useState(false); + const [healthModelId, setHealthModelId] = useState(""); + const [providerSearch, setProviderSearch] = useState(""); useEffect(() => { const requestedTab = new URLSearchParams(window.location.search).get("tab"); @@ -1761,7 +1771,7 @@ export function AdminSettingsClient() { ) ) { setActiveTab(requestedTab); - } + } else { setActiveTab("users"); } }, []); const handleActiveTabChange = (value: string) => { @@ -1790,14 +1800,6 @@ export function AdminSettingsClient() { const [batchLimitValue, setBatchLimitValue] = useState(""); const [batchLimitDepartment, setBatchLimitDepartment] = useState(""); - // 🆕 生图记录状态 - const [imageSearch, setImageSearch] = useState(""); - const [imagePage, setImagePage] = useState(1); - const [imageStartDate, setImageStartDate] = useState(""); - const [imageEndDate, setImageEndDate] = useState(""); - const [selectedImage, setSelectedImage] = - useState(null); - // 🆕 数据分析状态 const [selectedMonth, setSelectedMonth] = useState(() => { const now = new Date(); @@ -1818,141 +1820,103 @@ export function AdminSettingsClient() { page: number; totalPages: number; }>( - `/api/admin/users?page=${userPage}&limit=${userLimit}&search=${encodeURIComponent(userSearch)}`, - fetcher + ["users"].includes(activeTab) ? `/api/admin/users?page=${userPage}&limit=${userLimit}&search=${encodeURIComponent(userSearch)}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); // 获取部门列表(用于批量设置) const { data: departmentsData } = useSWR<{ departments: string[] }>( - "/api/admin/users/batch-limit", - fetcher + ["users"].includes(activeTab) ? "/api/admin/users/batch-limit" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: providersData, mutate: mutateProviders } = useSWR<{ providers: AiProvider[]; - }>("/api/admin/ai-providers", fetcher); - const { data: modelsData, mutate: mutateModels } = useSWR<{ + }>(["ai","site","rmbg","analytics"].includes(activeTab) ? "/api/admin/ai-providers" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); + const { data: modelsData, mutate: mutateModels, error: modelsError, isLoading: modelsLoading } = useSWR<{ models: AiModel[]; - }>("/api/admin/ai-models", fetcher); - const { data: modelHealthData, mutate: mutateModelHealth } = useSWR<{ - events: AiModelHealthEvent[]; - }>("/api/admin/ai-model-health?limit=120", fetcher); + }>(["ai","site","rmbg","analytics"].includes(activeTab) ? (activeTab === "ai" ? "/api/admin/ai-models?view=summary" : "/api/admin/ai-models") : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[]; - }>("/api/admin/storage", fetcher); + }>(["storage","rmbg"].includes(activeTab) ? "/api/admin/storage" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: siteSettingsData, mutate: mutateSiteSettings } = - useSWR("/api/site-settings", fetcher); + useSWR(["site","images"].includes(activeTab) ? "/api/site-settings" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[]; - }>("/api/nav-links", fetcher); + }>(["nav"].includes(activeTab) ? "/api/nav-links" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: tutorialVideosData, mutate: mutateTutorialVideos } = useSWR<{ videos: TutorialVideo[]; - }>("/api/tutorial-videos", fetcher); + }>(["nav"].includes(activeTab) ? "/api/tutorial-videos" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR( - "/api/auth-settings", - fetcher + ["auth"].includes(activeTab) ? "/api/auth-settings" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: moonshotDetailAgentSettingData, mutate: mutateMoonshotDetailAgentSetting, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}`, - fetcher + ["site"].includes(activeTab) ? `/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: productPlanningAiModelConfigData, mutate: mutateProductPlanningAiModelConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: worldlabsKeyPoolConfigData, mutate: mutateWorldlabsKeyPoolConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: dingTalkRobotNotificationConfigData, mutate: mutateDingTalkRobotNotificationConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY}`, - fetcher + ["notification"].includes(activeTab) ? `/api/admin/settings?key=${DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: dingTalkNotificationDraftData, mutate: mutateDingTalkNotificationDraft, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY}`, - fetcher + ["notification"].includes(activeTab) ? `/api/admin/settings?key=${DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: dingTalkNotificationTasksData, mutate: mutateDingTalkNotificationTasks, } = useSWR<{ tasks?: AdminDingTalkNotificationTask[] }>( - "/api/admin/dingtalk-robot/notifications?limit=30", - fetcher + ["notification"].includes(activeTab) ? "/api/admin/dingtalk-robot/notifications?limit=30" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: threeDModelAliasConfigData, mutate: mutateThreeDModelAliasConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${THREED_MODEL_ALIAS_CONFIG_SETTING_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${THREED_MODEL_ALIAS_CONFIG_SETTING_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: enhanceProConfigData, mutate: mutateEnhanceProConfig } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{ setting?: { value?: unknown }; - }>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher); + }>(["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${AI_WASH_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const { data: canvasLocalEditConfigData, mutate: mutateCanvasLocalEditConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: canvasImageTaskQueueConfigData, mutate: mutateCanvasImageTaskQueueConfig, } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: homeAnimationSettingData, mutate: mutateHomeAnimationSetting } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}`, - fetcher + ["site"].includes(activeTab) ? `/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const { data: openClawChatSettingData, mutate: mutateOpenClawChatSetting } = useSWR<{ setting?: { value?: unknown } }>( - `/api/admin/settings?key=${OPENCLAW_CHAT_CONFIG_KEY}`, - fetcher + ["site"].includes(activeTab) ? `/api/admin/settings?key=${OPENCLAW_CHAT_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); - // 🆕 生图记录数据 - const imageQueryParams = new URLSearchParams({ - page: imagePage.toString(), - pageSize: "200", - ...(imageSearch && { search: imageSearch }), - ...(imageStartDate && { startDate: imageStartDate }), - ...(imageEndDate && { endDate: imageEndDate }), - }); - const { data: imagesData, mutate: mutateImages } = useSWR<{ - images: GeneratedImageRecord[]; - pagination: { - page: number; - pageSize: number; - total: number; - totalPages: number; - totalImages?: number; - totalVideos?: number; - }; - }>(`/api/admin/images?${imageQueryParams}`, fetcher); - // 🆕 生图数据分析 const analyticsUrl = activeTab === "analytics" @@ -2151,27 +2115,6 @@ export function AdminSettingsClient() { }); return map; }, [providersData?.providers]); - const modelHealthEventsByModelId = useMemo(() => { - const map = new Map(); - (modelHealthData?.events || []).forEach((event) => { - const modelId = String(event.modelId || ""); - if (!modelId) return; - const list = map.get(modelId) || []; - if (list.length < 5) { - list.push(event); - } - map.set(modelId, list); - }); - return map; - }, [modelHealthData?.events]); - const recentCriticalModelHealthEvents = useMemo( - () => - (modelHealthData?.events || []) - .filter((event) => event.counted || event.autoDisabled) - .slice(0, 12), - [modelHealthData?.events] - ); - // 网站设置状态 const [siteSettings, setSiteSettings] = useState({ siteName: "AI Chatbot", @@ -2791,8 +2734,7 @@ export function AdminSettingsClient() { // 加载抠图API配置 const { data: rmbgConfigData } = useSWR( - "/api/admin/settings?key=rmbg_api_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=rmbg_api_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (rmbgConfigData?.setting?.value) { @@ -2828,8 +2770,7 @@ export function AdminSettingsClient() { // 加载变清晰API配置 const { data: enhanceConfigData } = useSWR( - "/api/admin/settings?key=enhance_api_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=enhance_api_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (enhanceConfigData?.setting?.value) { @@ -2945,8 +2886,7 @@ export function AdminSettingsClient() { // 加载图像拓展API配置 const { data: extendConfigData } = useSWR( - "/api/admin/settings?key=extend_api_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=extend_api_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (extendConfigData?.setting?.value) { @@ -2965,8 +2905,7 @@ export function AdminSettingsClient() { // 加载去水印API配置 const { data: watermarkRemoveConfigData } = useSWR( - "/api/admin/settings?key=watermark_remove_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=watermark_remove_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (watermarkRemoveConfigData?.setting?.value) { @@ -2980,8 +2919,7 @@ export function AdminSettingsClient() { }, [watermarkRemoveConfigData]); const { data: productPlanningApiConfigData } = useSWR( - `/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { setProductPlanningApiConfig( @@ -3001,8 +2939,7 @@ export function AdminSettingsClient() { // 加载角度编辑API配置 const { data: angleEditConfigData } = useSWR( - "/api/admin/settings?key=angle_edit_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=angle_edit_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (angleEditConfigData?.setting?.value) { @@ -3028,8 +2965,7 @@ export function AdminSettingsClient() { }, [angleEditConfigData]); const { data: layerSplitConfigData } = useSWR( - "/api/admin/settings?key=layer_split_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=layer_split_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (layerSplitConfigData?.setting?.value) { @@ -3077,8 +3013,7 @@ export function AdminSettingsClient() { }, [layerSplitConfigData]); const { data: thirdPartySqlServerConfigData } = useSWR( - `/api/admin/settings?key=${THIRD_PARTY_SQLSERVER_CONFIG_KEY}`, - fetcher + ["rmbg"].includes(activeTab) ? `/api/admin/settings?key=${THIRD_PARTY_SQLSERVER_CONFIG_KEY}` : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (thirdPartySqlServerConfigData?.setting?.value) { @@ -3127,8 +3062,7 @@ export function AdminSettingsClient() { // 加载实时画布API配置 const { data: realtimeCanvasConfigData } = useSWR( - "/api/admin/settings?key=realtime_canvas_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=realtime_canvas_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (realtimeCanvasConfigData?.setting?.value) { @@ -3233,8 +3167,7 @@ export function AdminSettingsClient() { // 加载批量处理配置 const { data: sceneFusionConfigData } = useSWR( - "/api/admin/settings?key=scene_fusion_config", - fetcher + ["rmbg"].includes(activeTab) ? "/api/admin/settings?key=scene_fusion_config" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); useEffect(() => { if (!canvasImageTaskQueueConfigData) return; @@ -3446,8 +3379,7 @@ export function AdminSettingsClient() { // 快捷短语状态 const [questions, setQuestions] = useState([]); const { data: questionsData, mutate: mutateQuestions } = useSWR( - "/api/starter-questions", - fetcher + ["questions"].includes(activeTab) ? "/api/starter-questions" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true } ); const [canvasSkillTagsTitle, setCanvasSkillTagsTitle] = useState( DEFAULT_CANVAS_SKILL_TAGS_TITLE @@ -3475,7 +3407,7 @@ export function AdminSettingsClient() { title?: string; categories?: CanvasSkillCategory[]; tags?: CanvasSkillTag[]; - }>("/api/canvas-skill-tags", fetcher); + }>(["questions"].includes(activeTab) ? "/api/canvas-skill-tags" : null, fetcher, { revalidateOnFocus: false, keepPreviousData: true }); const [dingTalkNotificationDraft, setDingTalkNotificationDraft] = useState( @@ -7715,15 +7647,19 @@ export function AdminSettingsClient() { providerId: string, options?: { skipConfirm?: boolean } ) => { - if ( - !options?.skipConfirm && - !confirm( - "将同步 VIP98K 当前可用目录,停用服务商已关闭的模型,并排除纯文生图和纯文生视频模型。图片编辑、图生视频、参考媒体、多模态、3D 与工具模型会保留;你此前手动删除的模型不会恢复。\n\n继续吗?" - ) - ) { + if (syncBusy) return; + if (!options?.skipConfirm) { + setSyncBusy(true); + try { + const response = await fetch("/api/admin/vip98k-models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerId, dryRun: true }) }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "无法读取同步预览"); + setSyncPreview({ providerId, ...data.preview }); + } catch (error) { toast.error(error instanceof Error ? error.message : "无法读取同步预览"); } + finally { setSyncBusy(false); } return; } - + setSyncBusy(true); try { toast.info("正在读取 VIP98K 模型目录..."); const response = await fetch("/api/admin/vip98k-models", { @@ -7748,8 +7684,8 @@ export function AdminSettingsClient() { } catch (error: any) { console.error("[Quick Add VIP98K Models] Error:", error); toast.error(error.message || "同步 VIP98K 模型失败"); - throw error; - } + + } finally { setSyncBusy(false); } }; const handleUseFalImageProviderPreset = () => { @@ -8167,6 +8103,8 @@ export function AdminSettingsClient() { }; const handleSaveModel = async () => { + if (modelSaving) return; + setModelSaving(true); try { // 🆕 检查是否是火山引擎视频Provider const selectedProvider = providersData?.providers?.find( @@ -8220,7 +8158,7 @@ export function AdminSettingsClient() { } catch (error: any) { console.error("[Save Model] Error:", error); toast.error(error.message || "保存模型失败"); - } + } finally { setModelSaving(false); } }; const handleDeleteModel = async (id: string) => { @@ -8250,7 +8188,7 @@ export function AdminSettingsClient() { } toast.success(`已恢复 ${model.displayName},失败计数已清零`); mutateModels(); - mutateModelHealth(); + } catch (error: any) { toast.error(error?.message || "恢复模型失败"); } @@ -9401,393 +9339,8 @@ export function AdminSettingsClient() { - {/* 🆕 生图记录 */} - - - 所有生图/视频记录 - - 查看所有用户的 AI 图片与视频生成历史记录 - - - - {/* 筛选条件 */} -
-
- -
- - { - setImageSearch(e.target.value); - setImagePage(1); - }} - placeholder="输入用户名或邮箱..." - value={imageSearch} - /> -
-
-
- - { - setImageStartDate(e.target.value); - setImagePage(1); - }} - type="date" - value={imageStartDate} - /> -
-
- - { - setImageEndDate(e.target.value); - setImagePage(1); - }} - type="date" - value={imageEndDate} - /> -
-
- -
-
- - {/* 统计信息 */} -
- 共 {imagesData?.pagination.total || 0} 条记录 - {imagesData?.pagination.totalImages !== undefined || - imagesData?.pagination.totalVideos !== undefined - ? `(图片 ${imagesData?.pagination.totalImages || 0} / 视频 ${imagesData?.pagination.totalVideos || 0})` - : ""} -
- - {/* 图片网格 */} -
- {imagesData?.images?.map((img) => ( -
setSelectedImage(img)} - > -
- {img.recordType === "video" ? ( -
-
- {img.userName || img.userEmail?.split("@")[0]} -
-
- {formatBeijingTime(img.createdAt, { - month: "numeric", - day: "numeric", - })} -
-
- ))} -
- - {/* 分页 */} - {imagesData && imagesData.pagination.totalPages > 1 && ( -
-
- 第 {imagesData.pagination.page} /{" "} - {imagesData.pagination.totalPages} 页 -
-
- - - - -
-
- )} -
-
- - {/* 图片详情弹窗 */} - !open && setSelectedImage(null)} - open={!!selectedImage} - > - - - - {selectedImage?.recordType === "video" - ? "视频详情" - : "图片详情"} - {selectedImage?.recordType !== "video" && - selectedImage?.isFeatured && ( - - - 精选 - - )} - - - {selectedImage && ( -
-
- {selectedImage.recordType === "video" ? ( -
-
-
- -

{selectedImage.userName || selectedImage.userEmail}

-
-
- -

{selectedImage.userDepartment || "未设置"}

-
-
- -

- {new Date(selectedImage.createdAt).toLocaleString( - "zh-CN" - )} -

-
-
- -

{selectedImage.imageSize || "未知"}

-
-
- -

- {formatAnalyticsModelLabel( - resolveRuntimeModelMeta( - runtimeModelMetaById, - selectedImage.modelId - ), - selectedImage.modelId - )} -

-
- {selectedImage.recordType !== "video" ? ( -
- -

{selectedImage.featuredCategory || "未设置"}

-
- ) : null} - {selectedImage.recordType !== "video" && - selectedImage.isFeatured && - selectedImage.featuredAt && ( -
- -

- {new Date(selectedImage.featuredAt).toLocaleString( - "zh-CN" - )} -

-
- )} -
- -

{selectedImage.prompt || "无"}

-
-
- {selectedImage.recordType !== "video" ? ( -
-
- - - -
- -
- ) : null} -
- )} -
-
+
{/* 🆕 数据分析 */} @@ -15822,13 +15375,18 @@ export function AdminSettingsClient() { {/* AI 模型配置 */} - - + +

AI 模型管理

管理模型与上游,查看实际运行状态。

+
+ {[["models", "模型列表"], ["providers", "上游管理"], ["health", "运行健康"]].map(([value, label]) => )} +
+ +
AI 提供商 配置 AI 服务提供商
-
+
- + {editingProvider ? "编辑提供商" : "添加提供商"} @@ -15898,7 +15456,9 @@ export function AdminSettingsClient() {
)} -
+ setProviderSearch(event.target.value)} /> + {!providersData &&

正在加载上游…

} +
- +
- {providersData?.providers?.map((provider) => ( + {providersData?.providers?.filter(provider => `${provider.name} ${provider.type} ${provider.baseUrl}`.toLowerCase().includes(providerSearch.trim().toLowerCase())).map((provider) => (
-
+
{provider.name} {!provider.isEnabled && ( @@ -16241,6 +15801,7 @@ export function AdminSettingsClient() { {provider.type === "vip98k" && ( - + {editingModel ? "编辑模型" : "添加模型"} @@ -17648,305 +17209,20 @@ export function AdminSettingsClient() { > 取消 - + - +
-
-
-
-
模型故障日志
-

- 只记录真实模型故障和严重超时;质检不通过不会触发自动禁用。 -

-
- -
-
- {recentCriticalModelHealthEvents.length > 0 ? ( - recentCriticalModelHealthEvents.map((event) => ( -
-
- - {event.modelDisplayName || - event.modelRuntimeId || - "未知模型"} - - - {event.eventType} - - {event.autoDisabled && ( - - 已自动禁用 - - )} - - 连续失败 {event.consecutiveFailures} 次 - - {event.elapsedMs ? ( - - 耗时 {Math.round(event.elapsedMs / 1000)} 秒 - - ) : null} - - {formatBeijingTime(event.createdAt, { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} - -
- {event.errorMessage && ( -
- {event.errorMessage} -
- )} -
- )) - ) : ( -
- 暂无模型故障日志。 -
- )} -
-
- {modelsData?.models?.map((model) => { - const provider = providersData?.providers?.find( - (p) => p.id === model.providerId - ); - const isVideoModel = isVideoModelDescriptor({ - providerType: provider?.type, - modelId: model.modelId, - capabilities: (model as any).capabilities, - supportsImageGen: model.supportsImageGen, - }); - const is3DModel = is3DModelDescriptor({ - providerType: provider?.type, - modelId: model.modelId, - capabilities: (model as any).capabilities, - }); - const isImageModel = isImageGenerationModelDescriptor({ - providerType: provider?.type, - modelId: model.modelId, - capabilities: (model as any).capabilities, - supportsImageGen: model.supportsImageGen, - }); - const healthStatus = model.healthStatus || "healthy"; - const isObservationPool = - healthStatus === "degraded" && - Boolean(model.healthObservationStartedAt); - const recoverySuccessStreak = Math.max( - 0, - Number(model.healthRecoverySuccessStreak || 0) - ); - const autoDisableCooldownUntil = model.healthAutoDisabledAt - ? new Date( - new Date(model.healthAutoDisabledAt).getTime() + - 2 * 60 * 60 * 1000 - ) - : null; - const modelHealthEvents = - modelHealthEventsByModelId.get(model.id) || []; - const latestHealthEvent = modelHealthEvents[0]; - return ( -
-
-
- {model.avatarUrl ? ( - {model.displayName} - ) : ( -
- 无图 -
- )} -
-
-
- - {model.displayName} - - {model.isDefault && ( - - 默认 - - )} - {model.isDefaultTextEditModel && ( - - 文字编辑默认 - - )} - {model.isAgentModel && ( - - Agent - - )} - {is3DModel && ( - - 3D模型 - - )} - {isImageModel && ( - - 图片模型 - - )} - {isVideoModel && ( - - 视频模型 - - )} - {!is3DModel && !isImageModel && !isVideoModel && ( - - 对话模型 - - )} - {model.isMultimodal && ( - - 多模态 - - )} - {model.isHidden && ( - - 前台隐藏 - - )} - {(model as any).excludeFromFailover && ( - - 不参与故障迁移 - - )} - {(model as any).excludeFromHealthBan && ( - - 不参与封禁池 - - )} - {healthStatus === "auto_disabled" && ( - - 系统自动禁用 - - )} - {healthStatus === "degraded" && ( - - {isObservationPool - ? `观察池 ${recoverySuccessStreak}/3` - : "健康观察中"} - - )} -
-
- ID: {model.modelId} | 提供商:{" "} - {provider?.name ?? "未知"} -
- {(healthStatus !== "healthy" || - latestHealthEvent) && ( -
-
- - 连续故障: - {model.healthConsecutiveFailures || 0} 次 - - {model.healthLastEventType && ( - 类型:{model.healthLastEventType} - )} - {model.healthAutoDisabledAt && ( - - 禁用时间: - {formatBeijingTime( - model.healthAutoDisabledAt, - { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - } - )} - - )} - {healthStatus === "auto_disabled" && - autoDisableCooldownUntil && ( - - 解冻时间: - {formatBeijingTime( - autoDisableCooldownUntil, - { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - } - )} - - )} - {isObservationPool && ( - - 观察成功:{recoverySuccessStreak}/3 - - )} - {isObservationPool && - model.healthObservationStartedAt && ( - - 观察开始: - {formatBeijingTime( - model.healthObservationStartedAt, - { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - } - )} - - )} -
- {(model.healthLastError || - latestHealthEvent?.errorMessage) && ( -
- 最近原因: - {model.healthLastError || - latestHealthEvent?.errorMessage} -
- )} -
- )} -
-
-
- {healthStatus === "auto_disabled" && ( - - )} - - -
-
- ); - })} + } catch (error) { toast.error(error instanceof Error ? error.message : "模型详情加载失败"); } + }} onDelete={handleDeleteModel} onRestore={handleRestoreModelHealth as any} onHealth={id => { setHealthModelId(id); setAiSection("health"); }} />
+ { if (!open && !syncBusy) setSyncPreview(null); }}> + + 确认同步模型目录 + {syncPreview && <> +

新增 {syncPreview.added.length} 个 · 更新 {syncPreview.updated.length} 个 · 停用 {syncPreview.disabled.length} 个。同步将覆盖下列字段,现有启用状态按原同步规则保留。

+
+ {syncPreview.added.map(item =>

新增 · {item.displayName}

)} + {syncPreview.updated.map(item =>

{item.displayName}

{item.changedFields.map(field => ({ displayName: "显示名称", isHidden: "前台展示", sortOrder: "排序", capabilities: "模型能力", defaultParams: "默认参数", imageGenConfig: "生图与计费参数", avatarUrl: "模型头像", description: "描述", supportsVision: "视觉支持", supportsImageGen: "生图支持", isEnabled: "启用状态" }[field] || field)).join("、")}

)} + {syncPreview.disabled.map(item =>

停用 · {item.displayName}

)} + {!syncPreview.added.length && !syncPreview.updated.length && !syncPreview.disabled.length &&

目录配置没有变化。

} +
+ + } +
+
+ {aiSection === "health" && } + {/* 存储配置 */} diff --git a/app/api/admin/ai-model-health/route.ts b/app/api/admin/ai-model-health/route.ts index ddd703552..0937a69a2 100644 --- a/app/api/admin/ai-model-health/route.ts +++ b/app/api/admin/ai-model-health/route.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import { requireAdmin } from "@/lib/middleware/admin"; import { ChatSDKError } from "@/lib/errors"; +import { db } from "@/lib/db"; +import { aiModelHealthEvent } from "@/lib/db/schema"; +import { and, desc, eq, sql } from "drizzle-orm"; import { - listAiModelHealthEvents, restoreAiModelHealth, } from "@/lib/ai/runtime/model-health"; @@ -15,14 +17,30 @@ export async function GET(request: Request) { await requireAdmin(); const { searchParams } = new URL(request.url); - const modelId = searchParams.get("modelId") || undefined; - const limit = Number(searchParams.get("limit") || 80); - const events = await listAiModelHealthEvents({ - modelId, - limit: Number.isFinite(limit) ? limit : 80, - }); - - return Response.json({ events }); + const modelId = searchParams.get("modelId"); + const providerId = searchParams.get("providerId"); + const rawLimit = Number(searchParams.get("limit") || 20); + const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20; + const rawPage = Number(searchParams.get("page") || 1); + const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1; + const filters = []; + if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId)); + if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId)); + if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!)); + const start = searchParams.get("startDate"); + const end = searchParams.get("endDate"); + if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`); + if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`); + const events = await db.select({ + id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName, + modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName, + providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType, + errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs, + consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled, + counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt, + }).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id)) + .limit(limit + 1).offset((page - 1) * limit); + return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 4ed8895e7..6bf9dd881 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -1,9 +1,10 @@ import { z } from "zod"; +import { db } from "@/lib/db"; +import { aiModel, aiProvider } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; import { requireAdmin } from "@/lib/middleware/admin"; import { - getAllAiModels, getAiModelById, - getAiModelsByProviderId, createAiModel, updateAiModel, deleteAiModel, @@ -154,8 +155,14 @@ export async function GET(request: Request) { const id = searchParams.get("id"); const providerId = searchParams.get("providerId"); + if (searchParams.get("view") === "options") { + const models = await db.select({ modelId: aiModel.modelId, displayName: aiModel.displayName, + providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)); + return Response.json({ models: sortModelsForDisplay(models) }); + } + if (id) { - const model = await getAiModelById({ id }); + const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) }); if (!model) { return new ChatSDKError("bad_request:api", "Model not found").toResponse(); } @@ -163,12 +170,19 @@ export async function GET(request: Request) { } if (providerId) { - const models = await getAiModelsByProviderId({ providerId }); + const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId)); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); } - const models = await getAllAiModels(); - return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); + const models = await db.select().from(aiModel); + const normalized = models.map(normalizeModel); + const output = searchParams.get("view") === "summary" ? normalized.map(model => { + const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model; + const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"]; + return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200), + capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) }; + }) : normalized; + return Response.json({ models: sortModelsForDisplay(output) }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); diff --git a/app/api/admin/images/route.ts b/app/api/admin/images/route.ts index 332642ce0..7918f02fa 100644 --- a/app/api/admin/images/route.ts +++ b/app/api/admin/images/route.ts @@ -1,237 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { chat, generatedImage, message, user } from "@/lib/db/schema"; -import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm"; +import { generatedImage, user } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; - -function extractVideoUrlFromParts(parts: unknown): string | null { - const text = Array.isArray(parts) - ? parts - .map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : "")) - .join("\n") - : typeof parts === "string" - ? parts - : ""; - - if (!text) return null; - - const htmlMatch = text.match(/]+src="([^"]+)"/i); - if (htmlMatch?.[1]) return htmlMatch[1]; - - const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i); - if (markdownMatch?.[1]) return markdownMatch[1]; - - const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i); - return plainMatch?.[1] || null; -} +import { requireAdmin } from "@/lib/middleware/admin"; +import { ChatSDKError } from "@/lib/errors"; +import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records"; +import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db"; export async function GET(request: NextRequest) { try { - const session = await auth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const session = await requireAdmin(); + const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) }); + if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const query = parseMediaQuery(request.nextUrl.searchParams); + if (query.startDate && query.endDate && query.startDate > query.endDate) { + return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 }); } - - // 检查是否为管理员 - const currentUser = await db.query.user.findFirst({ - where: eq(user.id, session.user.id), - }); - - if (currentUser?.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - // 获取查询参数 - const searchParams = request.nextUrl.searchParams; - const page = Number.parseInt(searchParams.get("page") || "1"); - const pageSize = Number.parseInt(searchParams.get("pageSize") || "200"); - const offset = (page - 1) * pageSize; - const search = searchParams.get("search") || ""; - const startDate = searchParams.get("startDate"); - const endDate = searchParams.get("endDate"); - - // 构建查询条件 - const imageConditions = []; - const videoConditions = [ - eq(message.role, "assistant"), - or( - isNotNull(message.temporaryVideoUrl), - isNotNull(message.permanentVideoUrl), - sql`${message.parts}::text ILIKE '% 0) { - const matchedUserIds = matchingUsers.map((u) => u.id); - imageConditions.push( - sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})` - ); - videoConditions.push( - sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})` - ); - } else { - // 没有匹配的用户,返回空结果 - return NextResponse.json({ - images: [], - pagination: { page, pageSize, total: 0, totalPages: 0 } - }); - } - } - - if (startDate) { - const start = new Date(startDate); - imageConditions.push(gte(generatedImage.createdAt, start)); - videoConditions.push(gte(message.createdAt, start)); - } - - if (endDate) { - const end = new Date(endDate); - end.setHours(23, 59, 59, 999); - imageConditions.push(lte(generatedImage.createdAt, end)); - videoConditions.push(lte(message.createdAt, end)); - } - - const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined; - const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined; - - // 查询图片总数 - const imageCountResult = await db - .select({ count: sql`count(*)::int` }) - .from(generatedImage) - .where(imageWhereClause); - - // 查询视频总数 - const videoCountResult = await db - .select({ count: sql`count(*)::int` }) - .from(message) - .innerJoin(chat, eq(message.chatId, chat.id)) - .where(videoWhereClause); - - const totalImages = imageCountResult[0]?.count || 0; - const totalVideos = videoCountResult[0]?.count || 0; - const total = totalImages + totalVideos; - const totalPages = Math.ceil(total / pageSize); - - const fetchLimit = Math.max(page * pageSize, pageSize); - - // 查询图片列表(包含用户信息) - const imageRows = await db - .select({ - id: generatedImage.id, - userId: generatedImage.userId, - userName: user.name, - userEmail: user.email, - userDepartment: user.department, - thumbnailUrl: generatedImage.thumbnailUrl, - originalUrl: generatedImage.originalUrl, - imageSize: generatedImage.imageSize, - prompt: generatedImage.prompt, - modelId: generatedImage.modelId, - isFeatured: generatedImage.isFeatured, - featuredCategory: generatedImage.featuredCategory, - featuredAt: generatedImage.featuredAt, - createdAt: generatedImage.createdAt, - chatId: generatedImage.chatId, - recordType: sql<"image">`'image'`, - videoUrl: sql`null`, - }) - .from(generatedImage) - .leftJoin(user, eq(generatedImage.userId, user.id)) - .where(imageWhereClause) - .orderBy(desc(generatedImage.createdAt)) - .limit(fetchLimit); - - // 查询视频列表(来自消息表) - const videoRows = await db - .select({ - id: message.id, - userId: chat.userId, - userName: user.name, - userEmail: user.email, - userDepartment: user.department, - temporaryVideoUrl: message.temporaryVideoUrl, - permanentVideoUrl: message.permanentVideoUrl, - parts: message.parts, - videoMetadata: message.videoMetadata, - createdAt: message.createdAt, - chatId: message.chatId, - }) - .from(message) - .innerJoin(chat, eq(message.chatId, chat.id)) - .leftJoin(user, eq(chat.userId, user.id)) - .where(videoWhereClause) - .orderBy(desc(message.createdAt)) - .limit(fetchLimit); - - const normalizedVideos = videoRows - .map((item) => { - const metadata = (item.videoMetadata as Record | null) || {}; - const videoUrl = - item.permanentVideoUrl || - item.temporaryVideoUrl || - extractVideoUrlFromParts(item.parts); - - if (!videoUrl) return null; - - const resolution = - typeof metadata.resolution === "string" && metadata.resolution.trim() - ? metadata.resolution.trim() - : null; - const prompt = - typeof metadata.prompt === "string" && metadata.prompt.trim() - ? metadata.prompt.trim() - : null; - const modelId = - typeof metadata.model === "string" && metadata.model.trim() - ? metadata.model.trim() - : null; - - return { - id: `video-${item.id}`, - sourceMessageId: item.id, - userId: item.userId, - userName: item.userName, - userEmail: item.userEmail, - userDepartment: item.userDepartment, - thumbnailUrl: videoUrl, - originalUrl: videoUrl, - videoUrl, - imageSize: resolution, - prompt, - modelId, - isFeatured: false, - featuredCategory: null, - featuredAt: null, - createdAt: item.createdAt, - chatId: item.chatId, - recordType: "video" as const, - }; - }) - .filter((item): item is NonNullable => Boolean(item)); - - const merged = [...imageRows, ...normalizedVideos].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - const images = merged.slice(offset, offset + pageSize); - - return NextResponse.json({ - images, - pagination: { page, pageSize, total, totalPages, totalImages, totalVideos } - }); + const [result] = await db.execute(mediaRecordsQuery(query)); + const images = await resolveArchivedImageUrls(result.records); + const total = Number(result.total); + return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total, + totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } }); } catch (error) { - console.error("[Admin Images API] Error:", error); - return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 }); + if (error instanceof ChatSDKError) return error.toResponse(); + console.error("[Admin Images API] GET failed", error); + return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 }); } } diff --git a/app/api/admin/media-tasks/route.ts b/app/api/admin/media-tasks/route.ts new file mode 100644 index 000000000..75be88cd0 --- /dev/null +++ b/app/api/admin/media-tasks/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { sql } from "drizzle-orm"; +import { requireAdmin } from "@/lib/middleware/admin"; +import { ChatSDKError } from "@/lib/errors"; +import { parseMediaQuery } from "@/lib/admin/media-records"; + +export async function GET(request: NextRequest) { + try { + await requireAdmin(); + const q = parseMediaQuery(request.nextUrl.searchParams); + const status = request.nextUrl.searchParams.get("status") || "all"; + const filters = [sql`true`]; + if (q.search) + filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`); + if (["pending", "running", "failed", "succeeded"].includes(status)) + filters.push(sql`t.status=${status}`); + if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`); + if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`); + const [result] = await db.execute(sql`with filtered as not materialized ( + select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model, + extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs" + from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)} + ) select (select count(*)::int from filtered) as total, + coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`); + return NextResponse.json(result); + } catch (error) { + if (error instanceof ChatSDKError) return error.toResponse(); + return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 }); + } +} diff --git a/app/api/admin/vip98k-models/route.ts b/app/api/admin/vip98k-models/route.ts index d1c406818..07f4ce0c5 100644 --- a/app/api/admin/vip98k-models/route.ts +++ b/app/api/admin/vip98k-models/route.ts @@ -106,6 +106,22 @@ export async function POST(request: Request) { ? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId)) : catalogPresets; + if (body?.dryRun === true) { + const catalogIds = new Set(catalogPresets.map(preset => preset.modelId)); + const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const; + const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName })); + const updated = presets.flatMap(preset => { + const existing = existingModels.find(model => model.modelId === preset.modelId); + if (!existing) return []; + const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field])); + if (existing.avatarUrl) changedFields.push("avatarUrl"); + if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled"); + return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : []; + }); + const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : []; + return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length }); + } + const addedModels: AiModel[] = []; const updatedModels: AiModel[] = []; const disabledModels: AiModel[] = []; diff --git a/components/admin/admin-surface.tsx b/components/admin/admin-surface.tsx new file mode 100644 index 000000000..5ac50b124 --- /dev/null +++ b/components/admin/admin-surface.tsx @@ -0,0 +1,30 @@ +"use client"; +import { useEffect, useRef, type ReactNode } from "react"; + +export function AdminSurface({ children }: { children: ReactNode }) { + const ref = useRef(null); + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + let context: { revert: () => void } | undefined; + let disposed = false; + import("gsap").then(({ gsap }) => { + if (disposed) return; + context = gsap.context(() => { + gsap.fromTo( + ref.current, + { y: 6, opacity: 0.85 }, + { y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" }, + ); + }, ref); + }); + return () => { + disposed = true; + context?.revert(); + }; + }, []); + return ( +
+ {children} +
+ ); +} diff --git a/components/admin/media-records-panel.tsx b/components/admin/media-records-panel.tsx new file mode 100644 index 000000000..71aa6c8af --- /dev/null +++ b/components/admin/media-records-panel.tsx @@ -0,0 +1,601 @@ +"use client"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { + RefreshCw, + Search, + ChevronLeft, + ChevronRight, + ExternalLink, + Download, + Star, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { MediaThumbnail } from "./media-thumbnail"; +import { AdminSurface } from "./admin-surface"; +import { formatRecordTime } from "@/lib/admin/record-time"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; + +export type MediaRecord = { + id: string; + recordType: "image" | "video"; + userName: string | null; + userEmail: string | null; + userDepartment: string | null; + createdAt: string; + modelId: string | null; + chatId: string | null; + thumbnailUrl: string; + originalUrl: string; + videoUrl?: string | null; + prompt: string | null; + imageSize: string | null; + isFeatured: boolean; + featuredCategory: string | null; + featuredAt?: string | null; +}; +type MediaResponse = { + images: MediaRecord[]; + pagination: { + total: number; + totalPages: number; + totalImages: number; + totalVideos: number; + page: number; + }; +}; +export async function adminFetcher(url: string) { + const response = await fetch(url); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试"); + return data; +} +const selectClass = + "h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; +function recentDate() { + const d = new Date(); + d.setDate(d.getDate() - 6); + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d); +} + +export function MediaRecordsPanel({ categories }: { categories: string[] }) { + const [view, setView] = useState("assets"); + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState({ + search: "", + type: "all", + startDate: recentDate(), + endDate: "", + model: "", + featured: "false", + status: "all", + }); + const [page, setPage] = useState(1); + const [selected, setSelected] = useState(null); + const [saving, setSaving] = useState(false); + const [originalFailed, setOriginalFailed] = useState(false); + const update = (changes: Partial) => { + setFilters((f) => ({ ...f, ...changes })); + setPage(1); + }; + useEffect(() => { + const timer = setTimeout(() => { + setFilters((f) => (f.search === search ? f : { ...f, search })); + setPage(1); + }, 350); + return () => clearTimeout(timer); + }, [search]); + const { data: options } = useSWR<{ + models: Array<{ modelId: string; displayName: string; providerName: string }>; + }>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false }); + const modelNames = new Map(); + for (const model of options?.models || []) + if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName); + const openDetails = (item: MediaRecord) => { + setSelected(item); + setOriginalFailed(false); + }; + const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" }); + const { data, error, isLoading, isValidating, mutate } = useSWR( + view === "assets" ? `/api/admin/images?${query}` : null, + adminFetcher, + { keepPreviousData: true, revalidateOnFocus: false }, + ); + const tasks = useSWR<{ + tasks: Array<{ + id: string; + userName: string; + status: string; + createdAt: string; + error: string | null; + model: string | null; + elapsedMs: number | null; + }>; + total: number; + }>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, { + keepPreviousData: true, + revalidateOnFocus: false, + }); + const busy = view === "assets" ? isValidating : tasks.isValidating; + const failure = view === "assets" ? error : tasks.error; + const loading = view === "assets" ? isLoading : tasks.isLoading; + const total = view === "assets" ? data?.pagination.total : tasks.data?.total; + const pages = Math.max(1, Math.ceil((total || 0) / 40)); + async function save(changes: Partial) { + if (!selected || saving) return; + setSaving(true); + try { + const response = await fetch("/api/admin/images", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageId: selected.id, ...changes }), + }); + if (!response.ok) throw new Error("保存失败,请重试"); + setSelected({ ...selected, ...changes }); + toast.success("已保存"); + await mutate(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + } + return ( + +
+
+

生成记录

+

+ 查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。 +

+
+ +
+
+ {[ + ["assets", "产物记录"], + ["tasks", "画布任务"], + ].map(([key, label]) => ( + + ))} +
+
+ + {view === "assets" ? ( + + ) : ( + + )} + + + + {view === "assets" && ( +
+ + +
+ )} +
+
+ + {total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`} + {view === "assets" && data + ? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}` + : ""} + + {busy && !loading && 正在更新…} +
+
+ + 第 {page} / {pages} 页 · 每页 40 条 + +
+ + +
+
+ {failure ? ( +
+

{failure.message}

+ +
+ ) : loading ? ( +
+ {[0, 1, 2, 3].map((x) => ( +
+ ))} + 正在加载记录 +
+ ) : total === 0 ? ( +
+ 没有符合条件的记录,请调整筛选条件。 +
+ ) : ( +
+ {view === "assets" ? ( + + + + {["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => ( + + ))} + + + + {data?.images.map((item) => ( + + + + + + + + + ))} + +
+ {v} +
+
+ openDetails(item)} + /> +
+
+
{item.userName || item.userEmail}
+
+ {item.userDepartment || "未设置部门"} +
+
+ {formatRecordTime(item.createdAt)} + +
+ {modelNames.get(item.modelId || "") || item.modelId || "未记录模型"} +
+
+ {item.recordType === "video" ? "视频" : "图片"} ·{" "} + {item.imageSize || "未知尺寸"} +
+
+ {item.isFeatured ? ( + + + {item.featuredCategory || "精选"} + + ) : ( + "—" + )} + + +
+ ) : ( + + + + {["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => ( + + ))} + + + + {tasks.data?.tasks.map((task) => ( + + + + + + + + ))} + +
+ {x} +
+ {task.userName} +
+ {formatRecordTime(task.createdAt)} +
+
+ {{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[ + task.status + ] || task.status} + {task.model || "自动选择"} + {task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`} + +

+ {task.error || "—"} +

+ {task.id} +
+ )} +
+ )} +
+ + 每页 40 条 · 第 {page} / {pages} 页 + +
+ + + + +
+
+ { + if (!open) setSelected(null); + }} + > + + + {selected?.recordType === "video" ? "视频详情" : "图片详情"} + + {selected && ( +
+
+ {selected.recordType === "video" ? ( +
+
+ {[ + ["用户", selected.userName || selected.userEmail], + ["部门", selected.userDepartment], + ["生成时间", formatRecordTime(selected.createdAt)], + ["尺寸", selected.imageSize], + ["模型", selected.modelId], + ["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"], + ].map(([k, v]) => ( +
+
{k}
+
{v || "未记录"}
+
+ ))} +
+
+

提示词

+

+ {selected.prompt || "未记录提示词"} +

+
+
+ + {selected.recordType === "image" && ( + + )} +
+ {selected.recordType === "image" && ( +
+ + + +
+ )} +
+ )} +
+
+ + ); +} diff --git a/components/admin/media-thumbnail.tsx b/components/admin/media-thumbnail.tsx new file mode 100644 index 000000000..8ce8a8c7d --- /dev/null +++ b/components/admin/media-thumbnail.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ImageOff, Video, RotateCw } from "lucide-react"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { isRetiredStorageUrl } from "@/lib/storage/retired-storage"; + +import { queuePreview } from "@/lib/admin/preview-queue"; + +export function MediaThumbnail({ + url, + video = false, + onOpen, +}: { + url: string; + video?: boolean; + onOpen?: () => void; +}) { + const container = useRef(null); + const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting"); + const [attempt, setAttempt] = useState(0); + const [source, setSource] = useState(""); + const [failure, setFailure] = useState(""); + const retired = isRetiredStorageUrl(url); + useEffect(() => { + setState("waiting"); + setSource(""); + setFailure(""); + if (video || retired || !url || !container.current) return; + let dispose: (() => void) | undefined; + let abort = () => {}; + let objectUrl = ""; + let alive = true; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || dispose) return; + observer.disconnect(); + dispose = queuePreview((release) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 18000); + setState("loading"); + // Fetch is abortable, so leaving a page also stops in-flight previews. + const parsed = new URL(url, window.location.origin); + const target = + parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search + ? `${url}?imageView2/2/w/192/q/72/format/webp` + : buildImageProxyUrl(url, { + width: 192, + quality: 72, + format: "webp", + delivery: "proxy", + }); + fetch(target, { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error(`预览请求失败(HTTP ${response.status})`); + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) throw new Error("Not an image"); + if (alive) { + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + setState("ready"); + } + }) + .catch((error) => { + if (alive) { + setFailure( + error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败", + ); + setState("error"); + } + }) + .finally(() => { + clearTimeout(timeout); + release(); + }); + abort = () => { + controller.abort(); + clearTimeout(timeout); + }; + }); + }, + { rootMargin: "80px" }, + ); + observer.observe(container.current); + return () => { + alive = false; + observer.disconnect(); + abort(); + dispose?.(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [url, video, retired, attempt]); + return ( +
+ {video ? ( + + ) : retired ? ( + + 旧存储 +
+ 已停用 +
+ ) : source && state === "ready" ? ( + + ) : state === "error" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/components/admin/model-health-panel.tsx b/components/admin/model-health-panel.tsx new file mode 100644 index 000000000..c08367b7d --- /dev/null +++ b/components/admin/model-health-panel.tsx @@ -0,0 +1,191 @@ +"use client"; +import { useState } from "react"; +import useSWR from "swr"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { adminFetcher } from "./media-records-panel"; +import { formatRecordTime } from "@/lib/admin/record-time"; +type Event = { + id: string; + modelDisplayName: string; + modelRuntimeId: string; + providerName: string; + eventType: string; + createdAt: string; + errorMessage: string; + elapsedMs: number | null; + consecutiveFailures: number; + autoDisabled: boolean; +}; +export function ModelHealthPanel({ + providers, + models, + initialModelId = "", +}: { + initialModelId?: string; + providers: Array<{ id: string; name: string }>; + models: Array<{ id: string; displayName: string; providerId: string }>; +}) { + const [filters, setFilters] = useState({ + providerId: "", + modelId: initialModelId, + eventType: "", + startDate: "", + endDate: "", + }); + const [page, setPage] = useState(1); + const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" }); + const { data, error, isLoading, isValidating, mutate } = useSWR<{ + events: Event[]; + hasMore: boolean; + }>(`/api/admin/ai-model-health?${query}`, adminFetcher, { + keepPreviousData: true, + revalidateOnFocus: false, + }); + const update = (change: Partial) => { + setFilters((f) => ({ ...f, ...change })); + setPage(1); + }; + return ( +
+
+

运行健康

+

+ 按上游和模型追踪故障,查看完整原因与发生时间。 +

+
+
+ {[ + ["providerId", "全部上游", providers.map((p) => [p.id, p.name])], + [ + "modelId", + "全部模型", + models + .filter((m) => !filters.providerId || m.providerId === filters.providerId) + .map((m) => [m.id, m.displayName]), + ], + [ + "eventType", + "全部错误", + [ + ["quota", "额度 / 余额"], + ["timeout", "超时"], + ["unavailable", "不可用"], + ["invalid_model", "模型不可用"], + ["download_error", "结果下载失败"], + ["error", "其他错误"], + ["success", "成功"], + ], + ], + ].map(([key, label, options]) => ( + + ))} + update({ startDate: e.target.value })} + /> + update({ endDate: e.target.value })} + /> + +
+ {error ? ( +

+ 日志加载失败,请点击刷新重试。 +

+ ) : isLoading ? ( +

+ 正在加载故障日志… +

+ ) : ( +
+ {data?.events.map((event) => ( +
+
+ + {event.modelDisplayName || event.modelRuntimeId || "已删除模型"} + + {event.providerName || "未知上游"} + + {{ + quota: "额度 / 余额", + timeout: "超时", + unavailable: "不可用", + error: "服务错误", + invalid_model: "模型不可用", + download_error: "结果下载失败", + success: "成功", + }[event.eventType] || event.eventType} + + +
+

+ {event.errorMessage || "未记录详细原因"} +

+
+ 连续失败 {event.consecutiveFailures} 次 + {event.elapsedMs !== null && ( + 耗时 {Math.round(event.elapsedMs / 1000)} 秒 + )} + {event.autoDisabled && 已自动停用} +
+
+ ))} + {!data?.events.length && ( +

该范围内没有故障记录。

+ )} +
+ )} +
+ + 第 {page} 页{isValidating && " · 正在更新…"} + +
+ + +
+
+
+ ); +} diff --git a/components/admin/model-management-list.tsx b/components/admin/model-management-list.tsx new file mode 100644 index 000000000..adca72829 --- /dev/null +++ b/components/admin/model-management-list.tsx @@ -0,0 +1,326 @@ +"use client"; +import { useMemo, useState } from "react"; +import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { AiModel } from "@/lib/db/schema"; +import { + is3DModelDescriptor, + isVideoModelDescriptor, + isImageGenerationModelDescriptor, +} from "@/lib/ai/model-classification"; + +type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean }; +type Provider = { id: string; name: string; type: string; isEnabled: boolean }; +const selectClass = "h-10 rounded-md border bg-background px-3 text-sm"; +export function ModelManagementList({ + models, + providers, + error, + loading, + onEdit, + onDelete, + onRestore, + onRefresh, + onHealth, +}: { + models: Model[]; + providers: Provider[]; + error?: Error; + loading: boolean; + onEdit: (model: Model) => Promise; + onDelete: (id: string) => void; + onRestore: (model: Model) => void; + onRefresh: () => void; + onHealth?: (id: string) => void; +}) { + const [search, setSearch] = useState(""); + const [provider, setProvider] = useState(""); + const [status, setStatus] = useState("enabled"); + const [kind, setKind] = useState(""); + const [page, setPage] = useState(1); + const [opening, setOpening] = useState(null); + const rows = useMemo( + () => + models + .map((model) => { + const upstream = providers.find((p) => p.id === model.providerId); + const descriptor = { ...model, providerType: upstream?.type }; + const type = is3DModelDescriptor(descriptor) + ? "3d" + : isVideoModelDescriptor(descriptor) + ? "video" + : isImageGenerationModelDescriptor(descriptor) + ? "image" + : "chat"; + return { model, upstream, type }; + }) + .filter(({ model, upstream, type }) => { + const text = + `${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase(); + return ( + text.includes(search.trim().toLowerCase()) && + (!provider || model.providerId === provider) && + (!kind || kind === type) && + (status === "all" || + (status === "enabled" && model.isEnabled) || + (status === "disabled" && !model.isEnabled) || + (status === "unhealthy" && model.healthStatus !== "healthy") || + (status === "visible" && !model.isHidden)) + ); + }), + [models, providers, search, provider, status, kind], + ); + const pages = Math.max(1, Math.ceil(rows.length / 25)); + const currentPage = Math.min(page, pages); + const change = (fn: () => void) => { + fn(); + setPage(1); + }; + return ( +
+
+
+ + change(() => setSearch(e.target.value))} + /> +
+ + + + +
+

+ {loading + ? "正在加载模型…" + : `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`} +

+
+ + 第 {currentPage} / {pages} 页 + + + +
+ {error ? ( +
+ 模型加载失败。 + +
+ ) : loading ? ( +
+ ) : ( +
+ + + + {["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => ( + + ))} + + + + {rows + .slice((currentPage - 1) * 25, currentPage * 25) + .map(({ model, upstream, type }) => ( + + + + + + + + + ))} + +
+ {x} +
+
{model.displayName}
+
+ {model.modelId} +
+
+ {model.isDefault && 默认模型} + {model.isDefaultTextEditModel && 文字编辑默认} + {model.isAgentModel && Agent} + {model.isMultimodal && 多模态} + {model.isSubModel && 子模型} +
+
+ {upstream?.name || "未知上游"} +
+ {{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]} + {upstream && !upstream.isEnabled ? " · 上游已停用" : ""} +
+
+ + {model.isEnabled ? "已启用" : "已停用"} + + + {model.isHidden || model.isSubModel ? "隐藏" : "可见"} + + + {model.healthStatus === "auto_disabled" + ? "自动停用" + : model.healthStatus === "degraded" + ? "观察中" + : "正常"} + + {model.healthConsecutiveFailures > 0 && ( + + 连续失败 {model.healthConsecutiveFailures} 次 + + )} + {model.healthLastError && ( +
+ + 查看最近原因 + +

+ {model.healthLastError} +

+
+ )} + +
+ {model.excludeFromHealthBan && "不自动封禁 "} + {model.excludeFromFailover && "不参与迁移"} +
+
+
+ + {model.healthStatus === "auto_disabled" && ( + + )} + +
+
+ {rows.length === 0 && ( +

没有符合条件的模型。

+ )} +
+ )} +
+ + 每页 25 个 · 第 {currentPage} / {pages} 页 + +
+ + +
+
+
+ ); +} diff --git a/lib/admin/media-records.ts b/lib/admin/media-records.ts new file mode 100644 index 000000000..d43f2fc2b --- /dev/null +++ b/lib/admin/media-records.ts @@ -0,0 +1,83 @@ +import { sql, type SQL } from "drizzle-orm"; + +export function parseMediaQuery(params: URLSearchParams) { + const integer = (key: string, fallback: number, max: number) => { + const value = Number(params.get(key)); + return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback; + }; + const date = (key: string) => { + const value = params.get(key) || ""; + return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : ""; + }; + return { + page: integer("page", 1, 100000), + pageSize: integer("pageSize", 40, 100), + search: (params.get("search") || "").trim().slice(0, 128), + type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all", + model: params.get("model") || "", + featured: params.get("featured") === "true", + startDate: date("startDate"), + endDate: date("endDate"), + }; +} + +export function mediaRecordsQuery(params: ReturnType) { + const conditions: SQL[] = [sql`true`]; + if (params.search) + conditions.push( + sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`, + ); + if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`); + if (params.endDate) + conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`); + if (params.model) conditions.push(sql`m."modelId" = ${params.model}`); + const where = sql.join(conditions, sql` and `); + const videoFilters: SQL[] = [sql`true`]; + if (params.startDate) + videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`); + if (params.endDate) + videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`); + if (params.model) videoFilters.push(sql`v."video_metadata"->>'model'=${params.model}`); + if (params.search) + videoFilters.push( + sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`, + ); + return sql` + with video_candidates as materialized ( + select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId", + coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''), + (regexp_match(t.body, ']+src="([^"]+)"', 'i'))[1], + (regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url + from "Message_v2" v join "Chat" c on c.id=v."chatId" + cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array' + then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p) + else v.parts::jsonb #>> '{}' end as body) t + where ${params.type !== "image" && !params.featured} and v.role='assistant' + and ${sql.join(videoFilters, sql` and `)} + and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%>'model', url + from video_candidates where url is not null + ), filtered as not materialized (select * from media m where ${where}), + totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered), + page_rows as ( + select * from filtered order by "createdAt" desc, kind, id desc + limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize} + ) + select t.total, t.images as "totalImages", t.videos as "totalVideos", + coalesce((select jsonb_agg(jsonb_build_object( + 'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end, + 'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department, + 'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"), + 'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url, + 'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'), + 'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours' + ) order by p."createdAt" desc,p.kind,p.id desc) + from page_rows p left join "User" u on u.id=p."userId" + left join "GeneratedImage" g on p.kind='image' and g.id=p.id + left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records + from totals t`; +} diff --git a/lib/admin/preview-queue.ts b/lib/admin/preview-queue.ts new file mode 100644 index 000000000..05787d980 --- /dev/null +++ b/lib/admin/preview-queue.ts @@ -0,0 +1,31 @@ +// One queue for the records screen: visible previews get four network slots. +const waiting: Array<() => void> = []; +let active = 0; +function pump() { + while (active < 4 && waiting.length) waiting.shift()?.(); +} +export function queuePreview(start: (release: () => void) => void) { + let released = false; + let started = false; + const release = () => { + if (released) return; + released = true; + if (started) active--; + else { + const index = waiting.indexOf(run); + if (index >= 0) waiting.splice(index, 1); + } + pump(); + }; + const run = () => { + started = true; + active++; + start(release); + }; + waiting.push(run); + pump(); + // Active fetches release their slot in finally, after abort has settled. + return () => { + if (!started) release(); + }; +} diff --git a/lib/admin/record-time.ts b/lib/admin/record-time.ts new file mode 100644 index 000000000..a91bc7b87 --- /dev/null +++ b/lib/admin/record-time.ts @@ -0,0 +1,7 @@ +export function formatRecordTime(value: string) { + // The admin API normalizes each storage convention to Beijing wall-clock time. + return value + .replace("T", " ") + .replace(/(?:\.\d+)?Z$/, "") + .slice(0, 16); +} diff --git a/package.json b/package.json index e6a169be2..df30395dd 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "form-data": "^4.0.5", "framer-motion": "^11.18.2", "geist": "^1.3.1", + "gsap": "3.14.2", "heic-convert": "^2.1.0", "immer": "^11.1.3", "is-hotkey": "^0.2.0", diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 93563a7c1..c1a33aad8 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/admin-media-loading\.test\.ts/, /routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-toolbar-jobs\.test\.ts/, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29cd3b285..a08db200c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: geist: specifier: ^1.3.1 version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)) + gsap: + specifier: 3.14.2 + version: 3.14.2 heic-convert: specifier: ^2.1.0 version: 2.1.0 @@ -460,6 +463,9 @@ importers: packages: + gsap@3.14.2: + resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==} + '@ai-sdk/gateway@1.0.15': resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==} engines: {node: '>=18'} @@ -6967,6 +6973,8 @@ packages: snapshots: + gsap@3.14.2: {} + '@ai-sdk/gateway@1.0.15(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 diff --git a/scripts/sql/admin-media-record-indexes.sql b/scripts/sql/admin-media-record-indexes.sql new file mode 100644 index 000000000..cc15d2636 --- /dev/null +++ b/scripts/sql/admin-media-record-indexes.sql @@ -0,0 +1,10 @@ +-- Run outside a transaction. Concurrent creation keeps normal inserts available. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "GeneratedImage_created_id_idx" +ON public."GeneratedImage" ("createdAt" DESC, id DESC); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "Message_v2_video_created_id_idx" +ON public."Message_v2" ("createdAt" DESC, id DESC) +WHERE role = 'assistant' AND ( + permanent_video_url IS NOT NULL OR temporary_video_url IS NOT NULL + OR parts::text ILIKE '% { + expect(parseMediaQuery(new URLSearchParams("page=-1&pageSize=200"))).toMatchObject({ + page: 1, + pageSize: 100, + }); + expect( + parseMediaQuery(new URLSearchParams("page=2&pageSize=40&search=%E9%99%88%E9%BE%99")), + ).toMatchObject({ page: 2, pageSize: 40, search: "陈龙" }); + expect(parseMediaQuery(new URLSearchParams("page=NaN&pageSize=0"))).toMatchObject({ + page: 1, + pageSize: 40, + }); +}); + +test("normalized Beijing record times do not receive a second timezone offset", () => { + expect(formatRecordTime("2026-11-03T23:55:00.123Z")).toBe("2026-11-03 23:55"); + expect(formatRecordTime("2026-11-03T17:12:00.123456")).toBe("2026-11-03 17:12"); +}); + +test("leaving a page cancels waiting previews and retains active slots until fetch settles", () => { + const started: number[] = []; + const releases: Array<() => void> = []; + const cancel = Array.from({ length: 7 }, (_, index) => + queuePreview((release) => { + started.push(index); + releases[index] = release; + }), + ); + expect(started).toEqual([0, 1, 2, 3]); + cancel[0](); + cancel[4](); + expect(started).toEqual([0, 1, 2, 3]); + releases[0](); + expect(started).toEqual([0, 1, 2, 3, 5]); + releases[0](); + expect(started).toHaveLength(5); + releases[1](); + expect(started).toEqual([0, 1, 2, 3, 5, 6]); + for (const release of releases) release?.(); +});