From 2646341d435ae38c531ebb5917241b8217b6d3e5 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 15 May 2026 01:15:37 +0800 Subject: [PATCH] feat(manage-files): add OG screenshots tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the enrichment-captured page screenshots inside the file management page so admins can review what the headless browser captured for each link card, without leaving the file workflow. - Grid view with thumbnail, title/URL, dimensions, and bytes. - Hover actions: copy screenshot URL, open source page, recapture (popconfirm), delete (popconfirm). - 24 / page, sorted by last_accessed desc; reuses enrichmentApi.screenshots and queryKeys.enrichment.screenshots so cache invalidates consistently with the existing master-detail screenshots view. - Upload header button is suppressed on this tab — screenshots are pipeline-produced, not user-uploaded. --- apps/admin/src/views/manage-files/index.tsx | 181 ++++++----- .../src/views/manage-files/og-screenshots.tsx | 285 ++++++++++++++++++ 2 files changed, 397 insertions(+), 69 deletions(-) create mode 100644 apps/admin/src/views/manage-files/og-screenshots.tsx diff --git a/apps/admin/src/views/manage-files/index.tsx b/apps/admin/src/views/manage-files/index.tsx index b178275ba..482c7863a 100644 --- a/apps/admin/src/views/manage-files/index.tsx +++ b/apps/admin/src/views/manage-files/index.tsx @@ -35,7 +35,15 @@ import { HeaderActionButton } from '~/components/button/header-action-button' import { API_URL } from '~/constants/env' import { useLayout } from '~/layouts/content' +import { + OG_SCREENSHOT_TAB_ICON, + OG_SCREENSHOT_TAB_KEY, + OG_SCREENSHOT_TAB_LABEL, + OgScreenshotsTab, +} from './og-screenshots' + type FileType = 'file' | 'icon' | 'image' | 'avatar' +type TabKey = FileType | typeof OG_SCREENSHOT_TAB_KEY interface FileTypeConfig { key: FileType @@ -51,30 +59,35 @@ const FILE_TYPE_CONFIGS: FileTypeConfig[] = [ { key: 'file', label: '文件', icon: FileIcon, acceptImage: false }, ] +const isFileType = (key: TabKey): key is FileType => + key !== OG_SCREENSHOT_TAB_KEY + export default defineComponent({ setup() { - const type = ref('icon') + const activeTab = ref('icon') const list = ref<{ url: string; name: string; created?: number }[]>([]) const loading = ref(false) const modalShow = ref(false) const currentConfig = computed( () => - FILE_TYPE_CONFIGS.find((c) => c.key === type.value) || - FILE_TYPE_CONFIGS[0], + FILE_TYPE_CONFIGS.find( + (c) => isFileType(activeTab.value) && c.key === activeTab.value, + ) || FILE_TYPE_CONFIGS[0], ) const fetch = async () => { + if (!isFileType(activeTab.value)) return loading.value = true try { - const data = await filesApi.getByType(type.value) + const data = await filesApi.getByType(activeTab.value) list.value = data } finally { loading.value = false } } - watch(() => type.value, fetch) + watch(() => activeTab.value, fetch) onMounted(fetch) const checkUploadFile = async (data: { @@ -108,7 +121,8 @@ export default defineComponent({ } const handleDelete = async (name: string) => { - await filesApi.deleteByTypeAndName(type.value, name) + if (!isFileType(activeTab.value)) return + await filesApi.deleteByTypeAndName(activeTab.value, name) toast.success('删除成功') list.value = list.value.filter((item) => item.name !== name) } @@ -123,25 +137,38 @@ export default defineComponent({ } const { setActions } = useLayout() - setActions( - { - modalShow.value = true - }} - icon={} - name="上传文件" - />, + watch( + () => activeTab.value, + (tab) => { + if (isFileType(tab)) { + setActions( + { + modalShow.value = true + }} + icon={} + name="上传文件" + />, + ) + } else { + setActions(null) + } + }, + { immediate: true }, ) const isImageType = computed(() => currentConfig.value.acceptImage) + const isOgScreenshotTab = computed( + () => activeTab.value === OG_SCREENSHOT_TAB_KEY, + ) return () => (
{ - type.value = val + activeTab.value = val as TabKey }} type="line" class="mb-4" @@ -158,59 +185,75 @@ export default defineComponent({ )} /> ))} + ( +
+ + {OG_SCREENSHOT_TAB_LABEL} +
+ )} + />
-
- {loading.value ? ( -
- -
- ) : list.value.length === 0 ? ( -
- - {{ - extra: () => ( - { - modalShow.value = true - }} - > - 上传文件 - - ), - }} - -
- ) : ( - - {isImageType.value ? ( -
- {list.value.map((item) => ( - handleDelete(item.name)} - onCopy={() => handleCopyUrl(item.url)} - /> - ))} -
- ) : ( -
- {list.value.map((item) => ( - handleDelete(item.name)} - onCopy={() => handleCopyUrl(item.url)} - /> - ))} -
- )} -
- )} -
+ {isOgScreenshotTab.value ? ( +
+ +
+ ) : ( +
+ {loading.value ? ( +
+ +
+ ) : list.value.length === 0 ? ( +
+ + {{ + extra: () => ( + { + modalShow.value = true + }} + > + 上传文件 + + ), + }} + +
+ ) : ( + + {isImageType.value ? ( +
+ {list.value.map((item) => ( + handleDelete(item.name)} + onCopy={() => handleCopyUrl(item.url)} + /> + ))} +
+ ) : ( +
+ {list.value.map((item) => ( + handleDelete(item.name)} + onCopy={() => handleCopyUrl(item.url)} + /> + ))} +
+ )} +
+ )} +
+ )} ({ + page: page.value, + size: PAGE_SIZE, + sort: 'last_accessed' as const, + order: 'desc' as const, + })) + + const { data, isPending, isFetching } = useQuery({ + queryKey: computed(() => + queryKeys.enrichment.screenshots.list(params.value), + ), + queryFn: () => enrichmentApi.screenshots.list(params.value), + placeholderData: (prev) => prev, + staleTime: 30_000, + }) + + const rows = computed(() => data.value?.data ?? []) + const pageCount = computed(() => data.value?.pagination.totalPage ?? 1) + const total = computed(() => data.value?.pagination.total ?? 0) + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.enrichment.screenshots.all(), + }) + + const deleteMutation = useMutation({ + mutationFn: (enrichmentId: string) => + enrichmentApi.screenshots.delete(enrichmentId), + onSuccess: () => { + toast.success('截图已删除') + invalidate() + }, + onError: (err) => toast.error(`删除失败:${(err as Error).message}`), + }) + + const recaptureMutation = useMutation({ + mutationFn: (enrichmentId: string) => + enrichmentApi.screenshots.recapture(enrichmentId), + onSuccess: () => { + toast.success('重新抓取成功') + invalidate() + }, + onError: (err) => toast.error(`重新抓取失败:${(err as Error).message}`), + }) + + const handleCopy = async (url: string) => { + try { + await navigator.clipboard.writeText(url) + toast.success('已复制截图链接') + } catch { + toast.error('复制失败') + } + } + + return () => { + if (isPending.value) { + return ( +
+ +
+ ) + } + if (rows.value.length === 0) { + return ( +
+ +
+ ) + } + return ( +
+
+ 共 {total.value} 张 + {isFetching.value && 加载中…} +
+ +
+ {rows.value.map((row) => ( + handleCopy(row.publicUrl)} + onDelete={() => deleteMutation.mutate(row.enrichmentId)} + onRecapture={() => recaptureMutation.mutate(row.enrichmentId)} + recapturing={ + recaptureMutation.isPending.value && + recaptureMutation.variables.value === row.enrichmentId + } + /> + ))} +
+
+
+ (page.value = p)} + showSizePicker={false} + /> +
+
+ ) + } + }, +}) + +const ScreenshotCard = defineComponent({ + name: 'OgScreenshotCard', + props: { + row: { + type: Object as () => import('~/models/enrichment').EnrichmentScreenshotJoinedRow, + required: true, + }, + recapturing: Boolean, + }, + emits: ['copy', 'delete', 'recapture'], + setup(props, { emit }) { + const errored = ref(false) + return () => { + const { row } = props + const hasImage = !!row.publicUrl && !errored.value + return ( +
+
+ {hasImage ? ( + (errored.value = true), + }} + /> + ) : ( +
+ +
+ )} +
+ +
+ + {row.title || row.url} + +
+ + {row.width}×{row.height} + + {formatBytes(row.bytes)} +
+
+ +
+

{row.url}

+
+ + {{ + trigger: () => ( + emit('copy')} + > + {{ icon: () => }} + + ), + default: () => '复制截图链接', + }} + + + {{ + trigger: () => ( + + + + ), + default: () => '访问原页面', + }} + + emit('recapture')}> + {{ + trigger: () => ( + + {{ + trigger: () => ( + + {{ + icon: () => , + }} + + ), + default: () => '重新抓取', + }} + + ), + default: () => + '重新抓取将打开无头浏览器请求页面并覆盖现有截图,确定吗?', + }} + + emit('delete')}> + {{ + trigger: () => ( + + {{ + trigger: () => ( + + {{ icon: () => }} + + ), + default: () => '删除截图', + }} + + ), + default: () => + `确定要删除 "${row.title || row.url}" 的截图吗?`, + }} + +
+
+
+ ) + } + }, +}) + +export const OG_SCREENSHOT_TAB_KEY = 'og-screenshot' +export const OG_SCREENSHOT_TAB_ICON = Camera +export const OG_SCREENSHOT_TAB_LABEL = 'OG 截图'