fix(admin): 优化名片列表表格列宽与防折行配置,增加横向平滑滚动与操作栏固定
这个提交包含在:
@@ -31,6 +31,8 @@ const DEFAULT_SIZE = 10;
|
|||||||
const PAGE_API = '/api/admin/card/info/page';
|
const PAGE_API = '/api/admin/card/info/page';
|
||||||
const UPDATE_API = '/api/admin/card/info/update';
|
const UPDATE_API = '/api/admin/card/info/update';
|
||||||
const SEED_API = '/api/admin/card/info/seed';
|
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 & {
|
type CardListRow = CardProfile & {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
@@ -64,6 +66,24 @@ interface CoolUpdateResponse {
|
|||||||
message?: string;
|
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 {
|
interface CardPageQuery {
|
||||||
page: number;
|
page: number;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -134,6 +154,11 @@ function CardListPage() {
|
|||||||
const [filters, setFilters] = useState<CardListFilterForm>({});
|
const [filters, setFilters] = useState<CardListFilterForm>({});
|
||||||
const [reloadTick, setReloadTick] = useState(0);
|
const [reloadTick, setReloadTick] = useState(0);
|
||||||
const [seeding, setSeeding] = useState(false);
|
const [seeding, setSeeding] = useState(false);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Array<string | number>>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [downloadingQr, setDownloadingQr] = useState(false);
|
||||||
|
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
setReloadTick((tick) => tick + 1);
|
setReloadTick((tick) => tick + 1);
|
||||||
@@ -265,6 +290,75 @@ function CardListPage() {
|
|||||||
[t]
|
[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<CoolVcardExportResponse>(
|
||||||
|
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<CoolQrcodeSvgResponse>(
|
||||||
|
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(
|
const handleToggleStatus = useCallback(
|
||||||
async (record: CardListRow) => {
|
async (record: CardListRow) => {
|
||||||
const nextStatus: CardStatus | null =
|
const nextStatus: CardStatus | null =
|
||||||
@@ -303,39 +397,69 @@ function CardListPage() {
|
|||||||
{
|
{
|
||||||
title: t['cardList.columns.name'],
|
title: t['cardList.columns.name'],
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
|
width: 170,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{value || '-'}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.title'],
|
title: t['cardList.columns.title'],
|
||||||
dataIndex: 'title',
|
dataIndex: 'title',
|
||||||
|
width: 240,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{value || '-'}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.company'],
|
title: t['cardList.columns.company'],
|
||||||
dataIndex: 'company',
|
dataIndex: 'company',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{value || '-'}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.slug'],
|
title: t['cardList.columns.slug'],
|
||||||
dataIndex: 'slug',
|
dataIndex: 'slug',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{value || '-'}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.status'],
|
title: t['cardList.columns.status'],
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
render: (value: CardStatus) => {
|
render: (value: CardStatus) => {
|
||||||
const status = isCardStatus(value) ? value : 'draft';
|
const status = isCardStatus(value) ? value : 'draft';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
color={getStatusColor(status)}
|
<Badge
|
||||||
text={t[`cardList.status.${status}`]}
|
color={getStatusColor(status)}
|
||||||
/>
|
text={t[`cardList.status.${status}`]}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.themeCode'],
|
title: t['cardList.columns.themeCode'],
|
||||||
dataIndex: 'themeCode',
|
dataIndex: 'themeCode',
|
||||||
|
width: 130,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{value || '-'}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t['cardList.columns.operations'],
|
title: t['cardList.columns.operations'],
|
||||||
dataIndex: 'operations',
|
dataIndex: 'operations',
|
||||||
|
width: 340,
|
||||||
|
fixed: 'right',
|
||||||
render: (_value: unknown, record: CardListRow) => {
|
render: (_value: unknown, record: CardListRow) => {
|
||||||
const canToggle =
|
const canToggle =
|
||||||
record.status === 'draft' || record.status === 'published';
|
record.status === 'draft' || record.status === 'published';
|
||||||
@@ -348,7 +472,7 @@ function CardListPage() {
|
|||||||
? t['cardList.unpublish.confirm']
|
? t['cardList.unpublish.confirm']
|
||||||
: t['cardList.publish.confirm'];
|
: t['cardList.publish.confirm'];
|
||||||
return (
|
return (
|
||||||
<Space size={8}>
|
<Space size={8} wrap={false} style={{ whiteSpace: 'nowrap' }}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -370,6 +494,22 @@ function CardListPage() {
|
|||||||
>
|
>
|
||||||
{t['cardList.columns.copyLink']}
|
{t['cardList.columns.copyLink']}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
loading={exporting}
|
||||||
|
onClick={handleExportVcard}
|
||||||
|
>
|
||||||
|
{t['cardList.columns.exportVcard']}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
loading={downloadingQr}
|
||||||
|
onClick={handleDownloadQrSvg}
|
||||||
|
>
|
||||||
|
{t['cardList.columns.downloadQrSvg']}
|
||||||
|
</Button>
|
||||||
{canToggle ? (
|
{canToggle ? (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
@@ -387,7 +527,17 @@ function CardListPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleCopyLink, handleOpenBuilder, handleToggleStatus, handleView, t]
|
[
|
||||||
|
downloadingQr,
|
||||||
|
exporting,
|
||||||
|
handleCopyLink,
|
||||||
|
handleDownloadQrSvg,
|
||||||
|
handleExportVcard,
|
||||||
|
handleOpenBuilder,
|
||||||
|
handleToggleStatus,
|
||||||
|
handleView,
|
||||||
|
t,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pagination: PaginationProps = {
|
const pagination: PaginationProps = {
|
||||||
@@ -462,13 +612,32 @@ function CardListPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
<ContentCard>
|
<ContentCard>
|
||||||
|
<style>{`
|
||||||
|
.card-list-table .arco-table-th,
|
||||||
|
.card-list-table .arco-table-td {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
.card-list-table .arco-table-cell {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
word-break: keep-all !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<Table
|
<Table
|
||||||
|
className="card-list-table"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data}
|
data={data}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
|
scroll={{ x: 1370 }}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
|
rowSelection={{
|
||||||
|
type: 'radio',
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys: Array<string | number>) => {
|
||||||
|
setSelectedRowKeys(keys);
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -64,3 +64,193 @@ test('4. list/index.tsx 必须支持复制短链与发布/撤回', () => {
|
|||||||
);
|
);
|
||||||
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
|
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'),
|
||||||
|
'各列必须显式配置合理宽度防止挤压折行'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户