From c68ac7a3db82d1bdb867260378df18f93cca0fd2 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 28 Aug 2026 17:24:01 +0800 Subject: [PATCH] feat: unify drainage targets and carrier status UI --- api/src/common/client-write.dto.spec.ts | 16 +++- api/src/common/client-write.dto.ts | 10 ++- .../batch-generation.service.ts | 2 +- .../report-materials/import-review.service.ts | 9 +- .../official-export.service.ts | 4 +- .../report-materials/pending-query.service.ts | 2 +- .../report-materials.helpers.ts | 4 +- .../report-materials.service.spec.ts | 11 +++ api/src/sms-config/drainage.service.ts | 23 +++-- api/src/sms-config/sms-config.contracts.ts | 2 +- api/src/sms-config/sms-config.service.spec.ts | 7 +- .../first-version-development-requirements.md | 11 ++- docs/system-functional-test-cases.md | 12 +++ src/api/admin/governance.api.ts | 4 +- src/api/client/client.api.ts | 4 +- src/apps/admin/AdminChannelReportPage.tsx | 6 +- src/apps/admin/AdminDrainageAuditPage.tsx | 7 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 2 +- src/apps/admin/AdminReportRecordsPage.tsx | 4 +- src/apps/admin/AdminReportTasksPage.tsx | 2 +- src/apps/admin/ReportImportAuditPanel.tsx | 2 +- src/apps/admin/ReportMaterialImportModal.tsx | 2 +- src/apps/client/ClientSignaturesPage.test.tsx | 85 +++++++++++++++++++ src/apps/client/ClientSignaturesPage.tsx | 40 +++++---- src/styles/global.css | 59 +++++++++++-- 25 files changed, 265 insertions(+), 65 deletions(-) create mode 100644 src/apps/client/ClientSignaturesPage.test.tsx diff --git a/api/src/common/client-write.dto.spec.ts b/api/src/common/client-write.dto.spec.ts index f6d8bb2..1ee5126 100644 --- a/api/src/common/client-write.dto.spec.ts +++ b/api/src/common/client-write.dto.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto'; +import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientDrainageInfoDto, ClientImportConfirmDto } from './client-write.dto'; import { strictValidationPipe } from './strict-validation.pipe'; function validate(metatype: new () => T, value: unknown) { @@ -45,4 +45,18 @@ describe('strict client write DTOs', () => { }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it.each([ + 'https://example.com/path', + '13800138000', + '+86 138-0013-8000', + '0755-12345678', + '(010) 12345678-123', + ])('accepts a drainage URL or phone number without a separate name: %s', async (url) => { + await expect(validate(ClientDrainageInfoDto, { url })).resolves.toEqual(expect.objectContaining({ url })); + }); + + it('rejects arbitrary drainage text that is neither a URL nor a phone number', async () => { + await expect(validate(ClientDrainageInfoDto, { url: '品牌官网' })).rejects.toBeInstanceOf(BadRequestException); + }); }); diff --git a/api/src/common/client-write.dto.ts b/api/src/common/client-write.dto.ts index e49abc2..30237d2 100644 --- a/api/src/common/client-write.dto.ts +++ b/api/src/common/client-write.dto.ts @@ -101,8 +101,14 @@ export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureD } export class ClientDrainageInfoDto { - @IsString() @MinLength(1) @MaxLength(200) siteName!: string; - @IsUrl({ require_tld: false }) @MaxLength(2048) url!: string; + @IsOptional() @IsString() @MaxLength(200) siteName?: string; + @IsString() + @MinLength(1) + @MaxLength(2048) + @Matches(/^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i, { + message: '引流信息必须是 http/https URL、手机号码或固定电话号码', + }) + url!: string; @IsOptional() @IsString() @MaxLength(1000) remark?: string; @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record; } diff --git a/api/src/report-materials/batch-generation.service.ts b/api/src/report-materials/batch-generation.service.ts index de94ef4..4ec8efe 100644 --- a/api/src/report-materials/batch-generation.service.ts +++ b/api/src/report-materials/batch-generation.service.ts @@ -240,7 +240,7 @@ export class ReportBatchGenerationService { signatureId: signature.id, drainageItemId: drainageInfo?.id, materialVersion, - name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料', + name: selected.reportType === 'signature' ? signature.name : drainageInfo?.url ?? '引流资料', tenantName: signature.tenant.name, applicationId: signature.applicationId ?? undefined, applicationName: signature.application?.name ?? '未指定应用', diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts index c607c46..0adebd0 100644 --- a/api/src/report-materials/import-review.service.ts +++ b/api/src/report-materials/import-review.service.ts @@ -251,9 +251,8 @@ export class ReportImportReviewService { async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { const signatureName = mappedCoreValue(mappings, values, 'signatureName'); - const siteName = mappedCoreValue(mappings, values, 'siteName'); const url = mappedCoreValue(mappings, values, 'url'); - if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL'); + if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码'); const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } }); if (!signature) throw new Error(`未找到已审核签名:${signatureName}`); const remark = mappedCoreValue(mappings, values, 'remark'); @@ -262,7 +261,7 @@ export class ReportImportReviewService { return { operation: existing ? 'update' : 'create', targetId: existing?.id, - payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues }, + payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName: url, url, remark, reportValues }, originalSnapshot: existing ? { id: existing.id, siteName: existing.siteName, @@ -311,10 +310,8 @@ export class ReportImportReviewService { return targetId; } const signatureId = String(payload.signatureId ?? ''); - const siteName = String(payload.siteName ?? ''); const url = String(payload.url ?? ''); const body = { - siteName, url, remark: typeof payload.remark === 'string' ? payload.remark : undefined, reportValues: jsonRecord(payload.reportValues), @@ -336,7 +333,7 @@ export class ReportImportReviewService { targetId = created.id; } } - await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` }); + await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${url}` }); return targetId; } } diff --git a/api/src/report-materials/official-export.service.ts b/api/src/report-materials/official-export.service.ts index 7c73af5..a415376 100644 --- a/api/src/report-materials/official-export.service.ts +++ b/api/src/report-materials/official-export.service.ts @@ -20,11 +20,11 @@ export class ReportOfficialExportService { const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); const headers = reportType === 'signature' ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] - : ['短信签名', '站点名称', 'URL', '备注', '网站截图']; + : ['短信签名', '引流 URL 或号码', '备注', '主体证明']; sheet.addRow(headers); sheet.addRow(reportType === 'signature' ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] - : ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']); + : ['示例签名', 'https://example.com 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']); styleHeader(sheet.getRow(1)); sheet.columns.forEach((column) => { column.width = 24; }); sheet.getRow(2).height = 48; diff --git a/api/src/report-materials/pending-query.service.ts b/api/src/report-materials/pending-query.service.ts index 2fe2e93..8890deb 100644 --- a/api/src/report-materials/pending-query.service.ts +++ b/api/src/report-materials/pending-query.service.ts @@ -67,7 +67,7 @@ export class ReportPendingQueryService { ]); return [ ...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })), - ...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })), + ...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.url, detail: item.remark, signatureName: item.signature.name, tenant: item.tenant, application: item.application })), ].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()); } } diff --git a/api/src/report-materials/report-materials.helpers.ts b/api/src/report-materials/report-materials.helpers.ts index 4520591..97bf8d8 100644 --- a/api/src/report-materials/report-materials.helpers.ts +++ b/api/src/report-materials/report-materials.helpers.ts @@ -85,8 +85,8 @@ export function signatureCoreMapping(header: string): { code: string; kind: Impo export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; - if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true }; - if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true }; + if (/引流.*(?:url|地址|号码)|网址|url|链接|手机号码|固定电话|电话号码/.test(header)) return { code: 'url', kind: 'url', required: true }; + if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName' }; if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' }; return undefined; } diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index 1ac5ef1..c868782 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -13,6 +13,17 @@ describe('ReportMaterialsService', () => { expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) }); }); + it('builds a drainage template without a separate site-name column', async () => { + const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-drainage-template' }) }; + const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never); + const exported = await service.buildOfficialTemplate('drainage', 'operator-1'); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(exported.content as never); + const headers = workbook.worksheets[0].getRow(1).values; + expect(headers).toEqual(expect.arrayContaining(['短信签名', '引流 URL 或号码', '备注', '主体证明'])); + expect(headers).not.toEqual(expect.arrayContaining(['站点名称'])); + }); + it('rejects formula cells before storing or importing a workbook', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('签名资料'); diff --git a/api/src/sms-config/drainage.service.ts b/api/src/sms-config/drainage.service.ts index e8f01b6..5b7eb2f 100644 --- a/api/src/sms-config/drainage.service.ts +++ b/api/src/sms-config/drainage.service.ts @@ -11,6 +11,16 @@ import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; +const DRAINAGE_TARGET_PATTERN = /^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; + +function normalizeDrainageTarget(value?: string) { + const target = value?.trim() ?? ''; + if (!target || !DRAINAGE_TARGET_PATTERN.test(target)) { + throw new BadRequestException('引流信息必须是 http/https URL、手机号码或固定电话号码'); + } + return target; +} + /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsDrainageService { constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} @@ -70,7 +80,7 @@ export class SmsDrainageService { if (!signature) throw new NotFoundException('Signature not found'); if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found'); if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息'); - if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required'); + const target = normalizeDrainageTarget(data.url); await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues); const auditStatus = options.initialAuditStatus ?? 'pending'; const item = await this.prisma.smsDrainageInfo.create({ @@ -78,8 +88,8 @@ export class SmsDrainageService { tenantId: signature.tenantId, signatureId, applicationId: signature.applicationId, - siteName: data.siteName.trim(), - url: data.url.trim(), + siteName: target, + url: target, remark: data.remark, reportValues: data.reportValues as Prisma.InputJsonValue | undefined, auditStatus, @@ -104,8 +114,7 @@ export class SmsDrainageService { if (!current) throw new NotFoundException('Drainage info not found'); if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found'); if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改'); - if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required'); - if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required'); + const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url); const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined; await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {})); const auditStatus = options.initialAuditStatus ?? 'pending'; @@ -113,8 +122,8 @@ export class SmsDrainageService { where: { id: itemId }, data: { applicationId, - siteName: data.siteName?.trim(), - url: data.url?.trim(), + siteName: target, + url: target, remark: data.remark, reportValues: data.reportValues as Prisma.InputJsonValue | undefined, auditStatus, diff --git a/api/src/sms-config/sms-config.contracts.ts b/api/src/sms-config/sms-config.contracts.ts index 297642f..52c0677 100644 --- a/api/src/sms-config/sms-config.contracts.ts +++ b/api/src/sms-config/sms-config.contracts.ts @@ -54,7 +54,7 @@ export type UpdateSmsSignatureDto = Partial; diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 351b5a7..2237da0 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -967,7 +967,7 @@ describe('SmsConfigService', () => { }]); const service = new SmsConfigService(prisma as never); - await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1')) + await expect(service.createDrainageInfo('sig-1', { url: 'https://example.com', reportValues: {} }, {}, 'tenant-1')) .rejects.toThrow('引流信息缺少必填报备资料:网站主体'); expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled(); }); @@ -1014,9 +1014,12 @@ describe('SmsConfigService', () => { prisma.channelRouteRule.findMany.mockResolvedValue([]); const service = new SmsConfigService(prisma as never); - await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1')) + await expect(service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1')) .resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); + expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }), + })); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) }); expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 7db7d78..5cf3c01 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -214,7 +214,7 @@ 10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 11. 企业签名页、通道报备详情页和报备任务页均允许人工修正报备状态,但三个入口必须操作同一份 `ChannelSignatureReportTask` 通道级事实并写 `ChannelSignatureReportRecord`;企业签名页修改时必须展示应用当前通道组内的具体通道矩阵,不允许直接修改移动/联通/电信汇总标签。 12. 每次人工状态变更或回执导入后,系统必须按应用当前生效路由规则重新汇总各运营商目标通道状态和签名全局 `reportStatus`。新增目标通道但尚无任务时按未报备计入分母;移出当前配置的历史通道不参与当前汇总,但任务和记录继续保留。 -13. 报备任务和报备记录页面必须可按签名/引流信息类型筛选,并显示引流信息的站名称、地址和所属签名。报备记录查询必须关联真实任务、通道和引流实体,完整展示审核通过后创建、重置、冻结、导出、回执和人工状态变化。 +13. 报备任务和报备记录页面必须可按签名/引流信息类型筛选,并显示引流 URL 或号码及所属签名。报备记录查询必须关联真实任务、通道和引流实体,完整展示审核通过后创建、重置、冻结、导出、回执和人工状态变化。 ### 4.8 CMPP Gateway 与外部接入 @@ -1509,7 +1509,7 @@ ### 2026-07-15 签名与引流资料批量导入、通道映射及统一报备 1. 运营端在“报备任务”下提供“待报备资料”工作台。WPS 在线表格须先由用户另存为 `.xlsx`,系统读取真实工作簿、工作表、表头、单元格和内嵌图片;原始文件及拆出的图片写入 MinIO,导入批次、映射和业务资料写入 PostgreSQL,不支持用 CSV、前端静态数组或浏览器本地存储冒充图片导入。 -2. 导入分为“解析预览”和“确认入库”两步。用户可指定企业、企业应用、资料类型、表头行数、数据起始行并复用映射方案;每个源列可映射到签名名称、用途、所属签名、站点名称、URL、备注或报备字段库中的动态字段,同时配置文本/图片/文件、必填和转换规则。源文件中的签名名称同样必须使用完整中文黑括号格式 `【签名】`,不得通过导入绕过页面/API签名校验;源文件字段名称和顺序不固定,映射方案必须可持久化复用。 +2. 导入分为“解析预览”和“确认入库”两步。用户可指定企业、企业应用、资料类型、表头行数、数据起始行并复用映射方案;每个源列可映射到签名名称、用途、所属签名、引流 URL 或号码、备注或报备字段库中的动态字段,同时配置文本/图片/文件、必填和转换规则。源文件中的签名名称同样必须使用完整中文黑括号格式 `【签名】`,不得通过导入绕过页面/API签名校验;源文件字段名称和顺序不固定,映射方案必须可持久化复用。 3. 导入和业务页面的新建/修改只将已审核签名或引流信息标记为待报备,并递增材料版本;不得在每次导入后自动创建通道报备任务。运营人员可跨签名、跨引流信息勾选资料,一次创建统一报备批次。 4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。 5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。 @@ -2179,3 +2179,10 @@ - 客户端签名与引流列表不展示使用场景、已提交资料或审核状态,只展示移动、联通、电信三网可用性;全部通过和部分通过均显示“报备通过”,数据来自当前应用路由和真实通道报备任务汇总。 - 人工审核批量任务把`pending_review`独立显示为“待人工审核”,审核期间进度为0;客户端仅允许取消`scheduled`任务,并展示审核原因和驳回原因。运营审核页展示审核任务号与关联批量任务号。 - 运营端签名质量的通道活跃度热力图支持通道名搜索;短信任务进度支持后端任务状态查询。 + +## 2026-08-28 签名与引流三网状态及引流字段统一 + +- 客户端“签名与引流信息”列表将移动、联通、电信的真实报备可用性集中展示为同一组三网状态单元;每个单元同时显示运营商名称、状态指示和“报备通过/暂不可用”,签名与关联引流信息使用一致布局。状态继续来自应用当前路由、通道运营商范围和真实报备任务汇总,不新增静态兜底或前端推测。 +- 引流信息的业务主字段统一为“引流 URL 或号码”,支持 `http/https` URL、手机号码和固定电话号码。客户端新增/修改弹窗不再显示或要求“名称”,原“访问地址”统一改名为“引流 URL 或号码”。 +- API 和服务端必须真实接受上述三种内容并拒绝无效任意文本。数据库既有 `SmsDrainageInfo.siteName` 仅作为兼容列保留,新建或修改时由后端同步写入规范化后的 URL/号码;客户端不得继续提交或依赖独立名称。 +- 运营端单条审核列表、审核详情、导入审核、报备任务和报备记录统一只展示 URL/号码,不再把兼容列作为独立名称。批量导入映射和官方模板去掉“站点名称”必填列,只要求所属短信签名及“引流 URL 或号码”。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 7fb99d2..cb1857e 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4952,3 +4952,15 @@ npm run verify:phase8 | TC-UI-REVIEW-008 | 创建命中人工审核规则的批量任务 | 两端显示“待人工审核”、进度为0;客户端无终止按钮,详情显示审核原因,运营审核页可见关联批量任务号 | | TC-UI-REVIEW-009 | 审核通过或驳回批量任务 | 状态按真实后端刷新;驳回时展示原因,不重复入队或发送 | | TC-UI-REVIEW-010 | 接口对接某个日志子接口失败 | 接口概览仍可用且提示中文,不直接显示`Internal server error` | + +## TC-DRAINAGE-UI-20260828 三网状态与引流字段统一 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-DRAINAGE-UI-001 | 客户端查看签名及展开后的引流信息列表 | 移动、联通、电信在同一组三网状态单元中清晰展示;每项含运营商名称和真实“报备通过/暂不可用”状态,签名与引流行布局一致 | +| TC-DRAINAGE-UI-002 | 客户端打开新增或修改引流信息弹窗 | 仅有必填“引流 URL 或号码”,不存在“名称”和“访问地址”;提示明确支持 URL、手机号码和固定电话号码 | +| TC-DRAINAGE-UI-003 | 分别提交 `https://example.com/path`、`13800138000`、`0755-12345678` | 三种输入均通过真实客户端 API 和服务端校验,写入 PostgreSQL;兼容 `siteName` 列与 `url` 列同步保存相同目标值,进入真实待审核流程 | +| TC-DRAINAGE-UI-004 | 提交普通文字或空值 | 前端禁止空值提交;绕过前端提交普通文字时后端返回受控参数错误,不写数据库、不创建审核记录 | +| TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API | +| TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 | +| TC-DRAINAGE-UI-007 | 桌面和窄屏查看三网状态组及引流列表 | 状态单元不相互覆盖,文字不截断为不可辨认内容;窄屏沿用受控列表滚动,不产生页面级横向溢出 | diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 4f2b617..37cfc83 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -54,9 +54,9 @@ export const adminGovernanceApi = { request(withQuery('/admin/drainage-infos', query)), listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) => request(withQuery('/admin/audit-records', query)), - createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => + createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record }) => request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), - updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => + updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record }) => request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), approveDrainageInfo: (id: string) => request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), diff --git a/src/api/client/client.api.ts b/src/api/client/client.api.ts index a7b2cdf..44a5a45 100644 --- a/src/api/client/client.api.ts +++ b/src/api/client/client.api.ts @@ -182,7 +182,7 @@ export const clientApi = { }), createDrainageInfo: ( signatureId: string, - body: { siteName: string; url: string; remark?: string; reportValues?: Record }, + body: { url: string; remark?: string; reportValues?: Record }, ) => request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', @@ -190,7 +190,7 @@ export const clientApi = { }), updateDrainageInfo: ( id: string, - body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, + body: { url?: string; remark?: string; reportValues?: Record }, ) => request(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeDrainageInfoStatus: (id: string, status: string) => request(`/client/drainage-infos/${id}/status`, { diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 620d766..a619258 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -73,14 +73,14 @@ function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drain return ( 关闭} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
- {drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)} + {drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}

企业{signature?.tenant?.name ?? task.tenantId}

企业应用{signature?.application?.name ?? '-'}

提交报备时间

报备成功时间

上次发送成功时间

{!drainage ? <>

签名依据{String(profile.basis ?? '-')}

公司名称{String(profile.companyName ?? '-')}

统一社会信用代码{String(profile.creditCode ?? '-')}

: null} - {drainage ? <>

引流地址{String(drainage.url ?? '-')}

备注{String(drainage.remark ?? '-')}

: null} + {drainage ? <>

引流 URL 或号码{String(drainage.url ?? '-')}

备注{String(drainage.remark ?? '-')}

: null} {Object.entries(reportValues).map(([key, value]) =>

{key}{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}

)}

今日发送

@@ -185,7 +185,7 @@ export function AdminChannelReportPage() { const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt; return
-
{drainage ? : null}{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? : '历史通道级(未拆分)'}}
+
{drainage ? : null}{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? : '历史通道级(未拆分)'}}
diff --git a/src/apps/admin/AdminDrainageAuditPage.tsx b/src/apps/admin/AdminDrainageAuditPage.tsx index 51a91af..db786cd 100644 --- a/src/apps/admin/AdminDrainageAuditPage.tsx +++ b/src/apps/admin/AdminDrainageAuditPage.tsx @@ -30,8 +30,7 @@ function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: ()
应用{item.application?.name ?? '-'}
所属签名{item.signature?.name ?? item.signatureId}
审核状态{meta.label}
-
站名称{item.siteName}
-
引流地址{item.url}
+
引流 URL 或号码{item.url}
提交时间{formatDateTime(item.submittedAt)}
备注{item.remark || '-'}
@@ -83,7 +82,7 @@ export function AdminDrainageAuditPage() { } const columns = useMemo>>(() => [ - { key: 'siteName', title: '站名称/引流地址', render: (record) =>
{record.siteName}{record.url}
}, + { key: 'url', title: '引流 URL 或号码', render: (record) => {record.url} }, { key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId }, { key: 'tenant', title: '企业/应用', render: (record) =>
{record.tenant?.name ?? record.tenantId}{record.application?.name ?? '-'}
}, { key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.submittedAt) }, @@ -95,7 +94,7 @@ export function AdminDrainageAuditPage() {

引流信息审核

{error ?

{error}

: null}
setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={} value={keyword} /> setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、URL或号码" prefix={} value={keyword} />{hint ? {hint} : null}, + Modal: ({ children, footer, open, title }: { children: React.ReactNode; footer: React.ReactNode; open: boolean; title: string }) => open ?
{children}{footer}
: null, + Pagination: () => null, + Select: ({ label, options, ...props }: React.SelectHTMLAttributes & { label?: string; options: Array<{ label: string; value: string }> }) => , + Textarea: ({ label, ...props }: React.TextareaHTMLAttributes & { label?: string }) =>