diff --git a/web/src/app/admin/tower-models/page.tsx b/web/src/app/admin/tower-models/page.tsx index 74b2cd9..6447bf3 100644 --- a/web/src/app/admin/tower-models/page.tsx +++ b/web/src/app/admin/tower-models/page.tsx @@ -20,13 +20,12 @@ import { Typography, } from "antd"; import type { ColumnsType } from "antd/es/table"; -import Image from "next/image"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/components/auth-provider"; import { Card } from "@/components/ui-antd"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; -import { getApiBaseUrl, readApiError } from "@/lib/api"; +import { readApiError } from "@/lib/api"; import type { FileListResponse, FileStorageMount, @@ -112,6 +111,124 @@ function buildPayload(values: TowerModelFormValues): Record { }; } +type TowerModelImageCellProps = { + model: TowerModelSummary; + fetchWithAuth: ReturnType["fetchWithAuth"]; + onPreviewError: (message: string) => void; +}; + +function TowerModelImageCell({ + model, + fetchWithAuth, + onPreviewError, +}: TowerModelImageCellProps) { + const [imageUrl, setImageUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [previewing, setPreviewing] = useState(false); + + useEffect(() => { + let objectUrl: string | null = null; + const abortController = new AbortController(); + + const loadPreview = async () => { + if (!model.image_path) { + setImageUrl(null); + setLoading(false); + return; + } + + setLoading(true); + try { + const response = await fetchWithAuth(`/api/v1/tower-models/${model.id}/image`, { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + objectUrl = URL.createObjectURL(await response.blob()); + setImageUrl(objectUrl); + } catch (candidate) { + if (!(candidate instanceof DOMException && candidate.name === "AbortError")) { + setImageUrl(null); + } + } finally { + setLoading(false); + } + }; + + void loadPreview(); + + return () => { + abortController.abort(); + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [fetchWithAuth, model.id, model.image_path]); + + const openPreview = useCallback(async () => { + setPreviewing(true); + try { + const response = await fetchWithAuth(`/api/v1/tower-models/${model.id}/image`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + const objectUrl = URL.createObjectURL(await response.blob()); + const nextWindow = window.open(objectUrl, "_blank", "noopener,noreferrer"); + if (!nextWindow) { + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + window.setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 60_000); + } catch (candidate) { + const message = candidate instanceof Error ? candidate.message : "图片加载失败"; + onPreviewError(message); + } finally { + setPreviewing(false); + } + }, [fetchWithAuth, model.id, onPreviewError]); + + return ( + + {imageUrl ? ( + {model.name} + ) : ( +
+ + {loading ? "加载中" : "无预览"} + +
+ )} + +
+ ); +} + export default function AdminTowerModelsPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); @@ -131,6 +248,10 @@ export default function AdminTowerModelsPage() { const [seedUploadOpen, setSeedUploadOpen] = useState(false); const [seedOverwrite, setSeedOverwrite] = useState(false); + const handleImagePreviewError = useCallback((message: string) => { + setError(message); + }, []); + const canRead = hasPermission("tower_model.read") || hasPermission("tower_model.manage") || hasPermission("tower.read") || hasPermission("tower.manage"); const canManage = hasPermission("tower_model.manage"); @@ -376,20 +497,7 @@ export default function AdminTowerModelsPage() { if (!row.image_path) { return 未上传; } - return ( - - {row.name} - - - ); + return ; }, }, { @@ -436,7 +544,7 @@ export default function AdminTowerModelsPage() { ), }, ], - [canManage, deleteMutation, openEdit], + [canManage, deleteMutation, fetchWithAuth, handleImagePreviewError, openEdit], ); const mounts = mountsQuery.data?.mounts ?? [];