From 8ea9c25386d4d71374f1fd710b8733fe2862e5c1 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Wed, 23 Sep 2026 13:47:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E4=BC=98=E5=8C=96=E5=90=8D?= =?UTF-8?q?=E7=89=87=E5=88=97=E8=A1=A8=E8=A1=A8=E6=A0=BC=E5=88=97=E5=AE=BD?= =?UTF-8?q?=E4=B8=8E=E9=98=B2=E6=8A=98=E8=A1=8C=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=A8=AA=E5=90=91=E5=B9=B3=E6=BB=91=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=B8=8E=E6=93=8D=E4=BD=9C=E6=A0=8F=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/pages/card/list/index.tsx | 181 +++++++++++++++++++++++++- admin/tests/card-list.test.ts | 190 ++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+), 6 deletions(-) diff --git a/admin/src/pages/card/list/index.tsx b/admin/src/pages/card/list/index.tsx index 1ad6de8..6d38364 100644 --- a/admin/src/pages/card/list/index.tsx +++ b/admin/src/pages/card/list/index.tsx @@ -31,6 +31,8 @@ const DEFAULT_SIZE = 10; const PAGE_API = '/api/admin/card/info/page'; const UPDATE_API = '/api/admin/card/info/update'; const SEED_API = '/api/admin/card/info/seed'; +const VCARD_EXPORT_API = '/api/admin/card/info/vcard'; +const QRCODE_SVG_API = '/api/open/card/qrcode/svg'; type CardListRow = CardProfile & { slug?: string; @@ -64,6 +66,24 @@ interface CoolUpdateResponse { message?: string; } +interface CoolVcardExportResponse { + code: number; + message?: string; + data?: { + vcard?: string; + filename?: string; + }; +} + +interface CoolQrcodeSvgResponse { + code: number; + message?: string; + data?: { + svg?: string; + url?: string; + }; +} + interface CardPageQuery { page: number; size: number; @@ -134,6 +154,11 @@ function CardListPage() { const [filters, setFilters] = useState({}); const [reloadTick, setReloadTick] = useState(0); const [seeding, setSeeding] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState>( + [] + ); + const [exporting, setExporting] = useState(false); + const [downloadingQr, setDownloadingQr] = useState(false); const refreshList = useCallback(() => { setReloadTick((tick) => tick + 1); @@ -265,6 +290,75 @@ function CardListPage() { [t] ); + const handleExportVcard = useCallback(async () => { + const selectedId = selectedRowKeys[0]; + if (selectedId === undefined || selectedId === null || selectedId === '') { + Message.warning(t['cardList.export.selectFirst']); + return; + } + setExporting(true); + try { + const { data: payload } = await axios.post( + VCARD_EXPORT_API, + { id: selectedId } + ); + if (payload.code !== 1000 || !payload.data?.vcard) { + Message.error(payload.message || t['cardList.export.fail']); + return; + } + const blob = new Blob([payload.data.vcard], { + type: 'text/vcard;charset=utf-8', + }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = payload.data.filename || `card-${String(selectedId)}.vcf`; + link.click(); + URL.revokeObjectURL(objectUrl); + Message.success(t['cardList.export.success']); + } catch (error: unknown) { + Message.error(resolveErrorMessage(error, t['cardList.export.fail'])); + } finally { + setExporting(false); + } + }, [selectedRowKeys, t]); + + const handleDownloadQrSvg = useCallback(async () => { + const selectedId = selectedRowKeys[0]; + if (selectedId === undefined || selectedId === null || selectedId === '') { + Message.warning(t['cardList.qr.selectFirst']); + return; + } + const selectedRow = data.find( + (row) => String(row.id) === String(selectedId) + ); + const slugOrId = selectedRow?.slug?.trim() || String(selectedId); + setDownloadingQr(true); + try { + const { data: payload } = await axios.get( + QRCODE_SVG_API, + { params: { slugOrId } } + ); + const svg = payload.data?.svg?.trim(); + if (payload.code !== 1000 || !svg) { + Message.error(payload.message || t['cardList.qr.fail']); + return; + } + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = `card-${slugOrId}.svg`; + link.click(); + URL.revokeObjectURL(objectUrl); + Message.success(t['cardList.qr.success']); + } catch (error: unknown) { + Message.error(resolveErrorMessage(error, t['cardList.qr.fail'])); + } finally { + setDownloadingQr(false); + } + }, [data, selectedRowKeys, t]); + const handleToggleStatus = useCallback( async (record: CardListRow) => { const nextStatus: CardStatus | null = @@ -303,39 +397,69 @@ function CardListPage() { { title: t['cardList.columns.name'], dataIndex: 'name', + width: 170, + ellipsis: true, + render: (value: string) => ( + {value || '-'} + ), }, { title: t['cardList.columns.title'], dataIndex: 'title', + width: 240, + ellipsis: true, + render: (value: string) => ( + {value || '-'} + ), }, { title: t['cardList.columns.company'], dataIndex: 'company', + width: 220, + ellipsis: true, + render: (value: string) => ( + {value || '-'} + ), }, { title: t['cardList.columns.slug'], dataIndex: 'slug', + width: 160, + ellipsis: true, + render: (value: string) => ( + {value || '-'} + ), }, { title: t['cardList.columns.status'], dataIndex: 'status', + width: 110, render: (value: CardStatus) => { const status = isCardStatus(value) ? value : 'draft'; return ( - + + + ); }, }, { title: t['cardList.columns.themeCode'], dataIndex: 'themeCode', + width: 130, + ellipsis: true, + render: (value: string) => ( + {value || '-'} + ), }, { title: t['cardList.columns.operations'], dataIndex: 'operations', + width: 340, + fixed: 'right', render: (_value: unknown, record: CardListRow) => { const canToggle = record.status === 'draft' || record.status === 'published'; @@ -348,7 +472,7 @@ function CardListPage() { ? t['cardList.unpublish.confirm'] : t['cardList.publish.confirm']; return ( - + + + {canToggle ? ( + ) => { + setSelectedRowKeys(keys); + }, + }} /> diff --git a/admin/tests/card-list.test.ts b/admin/tests/card-list.test.ts index 986bb6f..ca12a8b 100644 --- a/admin/tests/card-list.test.ts +++ b/admin/tests/card-list.test.ts @@ -64,3 +64,193 @@ test('4. list/index.tsx 必须支持复制短链与发布/撤回', () => { ); assert.ok(!/\bany\b/.test(content), '禁止使用 any'); }); + +test('5. list 必须有导出 vCard 动作:有接口则下载,无接口则禁用并 Message,禁止伪造 vcf', () => { + const content = fs.readFileSync(listPagePath, 'utf-8'); + const pageLocalePath = path.join(adminRoot, 'src/pages/card/list/locale/index.ts'); + const localeContent = fs.readFileSync(pageLocalePath, 'utf-8'); + + assert.ok( + content.includes("t['cardList.columns.exportVcard']") || + content.includes('cardList.columns.exportVcard'), + '操作列必须有导出 vCard 文案 key' + ); + assert.ok( + localeContent.includes("'cardList.columns.exportVcard'"), + 'locale 必须包含 cardList.columns.exportVcard' + ); + + // 禁止前端拼假 vcf / 假装已下载 + assert.ok(!content.includes('BEGIN:VCARD'), '禁止前端硬编码 BEGIN:VCARD'); + assert.ok(!content.includes('END:VCARD'), '禁止前端硬编码 END:VCARD'); + assert.ok(!content.includes('generateVCard'), '禁止在列表页直接调用 generateVCard 拼假内容'); + + const hasExportApi = + /\/api\/(?:admin|open)\/card\/[^'"`\s]*vcard/i.test(content) || + /card\/[^'"`\s]*\/vcard/i.test(content) || + /VCARD_EXPORT_API|EXPORT_VCARD_API|vcardExport/i.test(content); + + if (hasExportApi) { + assert.ok( + content.includes('blob') || + content.includes('responseType') || + content.includes('download') || + content.includes('createObjectURL'), + '有导出接口时应下载返回的 vcf' + ); + assert.ok( + !content.includes('disabled') || + !/exportVcard[\s\S]{0,120}disabled/i.test(content), + '有导出接口时导出按钮不得 disabled' + ); + assert.ok( + localeContent.includes("'cardList.export.selectFirst'"), + 'locale 必须包含 cardList.export.selectFirst' + ); + assert.ok( + content.includes("t['cardList.export.selectFirst']") || + content.includes('cardList.export.selectFirst'), + '未选中时必须 Message 提示先选名片' + ); + assert.ok( + content.includes('selectedRowKeys') || content.includes('rowSelection'), + '必须支持选中一行后再导出' + ); + } else { + assert.ok( + localeContent.includes("'cardList.export.unavailable'"), + 'locale 必须包含 cardList.export.unavailable' + ); + assert.ok(!content.includes('text/vcard'), '禁止前端伪造 text/vcard Blob'); + assert.ok(!content.includes('text/x-vcard'), '禁止前端伪造 text/x-vcard Blob'); + assert.ok(!/\.vcf['"`]/.test(content), '无真实接口时禁止假装下载 .vcf 文件名'); + assert.ok(!content.includes('createObjectURL'), '无真实接口时禁止 createObjectURL 伪造下载'); + assert.ok( + content.includes('disabled') || content.includes('Disabled'), + '无 vCard 导出接口时导出按钮必须禁用' + ); + assert.ok( + content.includes("t['cardList.export.unavailable']") || + content.includes('cardList.export.unavailable'), + '无接口时必须 Message 说明暂不可导出' + ); + assert.ok( + content.includes('Message.warning') || + content.includes('Message.info') || + content.includes('Message.error'), + '无接口时必须通过 Message 提示不可导出' + ); + } +}); + +test('6. list 必须支持下载选中名片 SVG 二维码:走 open/card/qrcode/svg,失败只提示不下载空文件', () => { + const content = fs.readFileSync(listPagePath, 'utf-8'); + const pageLocalePath = path.join(adminRoot, 'src/pages/card/list/locale/index.ts'); + const localeContent = fs.readFileSync(pageLocalePath, 'utf-8'); + + assert.ok( + content.includes('/open/card/qrcode/svg') || + content.includes('open/card/qrcode/svg'), + '必须请求已有 GET /open/card/qrcode/svg' + ); + assert.ok( + /QRCODE_SVG|SVG_QR|downloadQr|qrcode\/svg/i.test(content), + '必须有 SVG 二维码下载相关常量或处理逻辑' + ); + assert.ok( + content.includes("t['cardList.columns.downloadQrSvg']") || + content.includes('cardList.columns.downloadQrSvg'), + '操作列必须有下载 SVG 二维码文案 key' + ); + assert.ok( + localeContent.includes("'cardList.columns.downloadQrSvg'"), + 'locale 必须包含 cardList.columns.downloadQrSvg' + ); + assert.ok( + localeContent.includes("'cardList.qr.selectFirst'"), + 'locale 必须包含 cardList.qr.selectFirst' + ); + assert.ok( + localeContent.includes("'cardList.qr.success'"), + 'locale 必须包含 cardList.qr.success' + ); + assert.ok( + localeContent.includes("'cardList.qr.fail'"), + 'locale 必须包含 cardList.qr.fail' + ); + assert.ok( + content.includes("t['cardList.qr.selectFirst']") || + content.includes('cardList.qr.selectFirst'), + '未选中时必须 Message 提示先选名片' + ); + assert.ok( + content.includes("t['cardList.qr.fail']") || + content.includes('cardList.qr.fail'), + '接口失败或空 svg 时必须 Message 提示失败' + ); + assert.ok( + content.includes('selectedRowKeys') || content.includes('rowSelection'), + '必须支持选中一行后再下载二维码' + ); + assert.ok( + content.includes('image/svg+xml') || + content.includes('.svg') || + content.includes('createObjectURL'), + '成功时应下载接口返回的 SVG' + ); + // 空 svg / 失败时不得下载空文件:须校验 svg 内容存在 + assert.ok( + content.includes('payload.data?.svg') || + content.includes('data?.svg') || + content.includes('.svg?.trim'), + '必须读取接口返回的 svg 字段' + ); + assert.ok( + content.includes('!svg') || + content.includes('!payload.data?.svg') || + /code !== 1000[\s\S]{0,80}!svg|!svg[\s\S]{0,80}code !== 1000/.test( + content + ), + '空 svg 或非成功码时不得下载,须校验后 Message' + ); + assert.ok( + !/new Blob\(\s*\[\s*['"`]\s*['"`]\s*\]/.test(content), + '禁止用空字符串 Blob 假装下载' + ); + + // 禁止伪造二维码 / 假 PNG / 假 PDF + assert.ok(!content.includes('generateQr'), '禁止前端自行 generateQr 拼假二维码'); + assert.ok(!content.includes('qrcode.toDataURL'), '禁止前端库生成假二维码'); + assert.ok(!content.includes('QRCode.toString'), '禁止前端库生成假 SVG'); + assert.ok( + !/\/open\/card\/qrcode\/png/i.test(content) && + !/qrcode\/png/i.test(content), + '无产品需求时不要提供 PNG 下载入口' + ); + assert.ok( + !/\.pdf['"`]/i.test(content) && !/application\/pdf/i.test(content), + '无 PDF 接口时禁止提供 PDF 下载' + ); + assert.ok(!/image\/png/i.test(content), '不得伪造 PNG Blob 冒充二维码下载'); +}); + +test('7. list 表格必须包含固定列宽、横向滚动与防折行配置 (nowrap),严禁单元格折行', () => { + const content = fs.readFileSync(listPagePath, 'utf-8'); + assert.ok( + content.includes('scroll={{ x:') || content.includes('scroll={{') || content.includes('scroll='), + 'Table 必须配置 scroll 横向滚动防折行' + ); + assert.ok( + content.includes('white-space: nowrap') || content.includes("whiteSpace: 'nowrap'"), + 'Table 必须配置 white-space: nowrap 防折行' + ); + assert.ok( + content.includes("fixed: 'right'"), + '操作列必须配置 fixed: right 固定右侧' + ); + assert.ok( + content.includes('width: 170') || content.includes('width: 240'), + '各列必须显式配置合理宽度防止挤压折行' + ); +}); +