Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d526b83ab | ||
|
|
5328bb09bf | ||
|
|
c68ac7a3db |
@@ -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<T>(metatype: new () => T, value: unknown) {
|
||||
@@ -45,4 +45,21 @@ describe('strict client write DTOs', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://example.com/path',
|
||||
'example.com/path?source=sms',
|
||||
'www.example.com',
|
||||
'192.0.2.10:8080/landing',
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsBoundedJsonObject } from './bounded-json-object.validator';
|
||||
import { DRAINAGE_TARGET_ERROR, DRAINAGE_TARGET_PATTERN } from './drainage-target';
|
||||
|
||||
export class ClientCertificationSubmissionDto {
|
||||
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||
@@ -101,8 +102,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(DRAINAGE_TARGET_PATTERN, {
|
||||
message: DRAINAGE_TARGET_ERROR,
|
||||
})
|
||||
url!: string;
|
||||
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
|
||||
|
||||
export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
|
||||
|
||||
export function normalizeDrainageTarget(value?: string) {
|
||||
const target = value?.trim() ?? '';
|
||||
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined;
|
||||
}
|
||||
@@ -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 ?? '未指定应用',
|
||||
|
||||
@@ -251,9 +251,8 @@ export class ReportImportReviewService {
|
||||
|
||||
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '示例行,导入前请删除', '请在本单元格插入图片']);
|
||||
: ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
sheet.columns.forEach((column) => { column.width = 24; });
|
||||
sheet.getRow(2).height = 48;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('签名资料');
|
||||
|
||||
@@ -10,6 +10,13 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
import { DRAINAGE_TARGET_ERROR, normalizeDrainageTarget as readDrainageTarget } from '../common/drainage-target';
|
||||
|
||||
function normalizeDrainageTarget(value?: string) {
|
||||
const target = readDrainageTarget(value);
|
||||
if (!target) throw new BadRequestException(DRAINAGE_TARGET_ERROR);
|
||||
return target;
|
||||
}
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsDrainageService {
|
||||
@@ -70,7 +77,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 +85,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 +111,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 +119,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,
|
||||
|
||||
@@ -54,7 +54,7 @@ export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantI
|
||||
};
|
||||
|
||||
export interface CreateSmsDrainageInfoDto {
|
||||
siteName: string;
|
||||
siteName?: string;
|
||||
url: string;
|
||||
remark?: string;
|
||||
reportValues?: Record<string, unknown>;
|
||||
|
||||
@@ -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,13 +1014,30 @@ 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();
|
||||
});
|
||||
|
||||
it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([]);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1'))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
|
||||
|
||||
expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('resets an approved signature to pending when key content is changed', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -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 或号码”。
|
||||
|
||||
@@ -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`、`example.com/path`、`www.example.com`、`13800138000`、`0755-12345678` | 带协议URL、不带协议URL、手机和固话均通过真实客户端 API 和服务端校验,写入 PostgreSQL;兼容 `siteName` 列与 `url` 列同步保存相同目标值,进入真实待审核流程 |
|
||||
| TC-DRAINAGE-UI-004 | 提交普通文字或空值 | 前端禁止空值提交;绕过前端提交普通文字时后端返回受控参数错误,不写数据库、不创建审核记录 |
|
||||
| TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API |
|
||||
| TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 |
|
||||
| TC-DRAINAGE-UI-007 | 桌面和窄屏查看三网状态组及引流列表 | 状态单元不相互覆盖,文字不截断为不可辨认内容;窄屏沿用受控列表滚动,不产生页面级横向溢出 |
|
||||
|
||||
@@ -4136,3 +4136,25 @@ git diff --check
|
||||
- C8只新增`docs/sendchain-service-decomposition-plan-20260828.md`,按认证、长短信、Inbox Repository/Worker、消息准备/持久化、Worker生命周期、路由、限速、Submit命令、Outbox和任务进度刷新分S0~S8实施;本轮没有修改发送链运行代码。
|
||||
- 回归通过:认证相关3套26项、API全量Jest、前端34项、API/前端TypeScript、Vite生产构建、真实Redis多实例契约、ESLint、Prettier、结构质量和依赖缓解门禁。首次从仓库根目录启动Jest时误扫描`outputs/`历史副本且未加载API转换配置,随后使用`api/jest.config.cjs`明确项目范围通过;未修改受保护历史资产。
|
||||
- 本轮运行代码没有变化,测试环境仍运行已验证的`226527f1cef122e4dbd163da782485e8c0254dc5`,因此没有重复建立恢复资产或形式化重新部署。未访问预生产,未写入测试环境业务数据,未发送、补发或重投短信。
|
||||
|
||||
## 2026-08-28 签名与引流三网状态及引流字段统一发布
|
||||
|
||||
- 重新验证本地基线为`main`,实施前`HEAD=origin/main=9ac41e9`;既有`docs/code-quality-reassessment-20260828-v2.md`修改、未跟踪`docs/code-quality-reassessment-20260828-v3.md`和空文件`=`均保留且未纳入提交。本轮功能提交为`c68ac7a3db82d1bdb867260378df18f93cca0fd2`,未推送远端。
|
||||
- 客户端签名与引流列表将移动、联通、电信改为同一组三网状态卡片,签名和引流行统一显示真实汇总得到的“报备通过/暂不可用”。新增/修改弹窗移除“名称”,将“访问地址”统一为“引流 URL 或号码”;URL 保持安全外链,手机和固定电话按普通文本展示。
|
||||
- 客户端和运营端不再提交独立`siteName`。后端 DTO 与领域服务真实接受`http/https` URL、手机号码和固定电话号码,拒绝空值及任意文字;既有非空兼容列由服务端同步写入同一规范化目标。单条审核、审核详情、导入审核、报备任务、报备记录、导入映射和官方模板同步移除独立站点名称口径,没有使用 mock、静态数据或 localStorage 代替产品功能。
|
||||
- 本地门禁通过:前端 Vitest 5个文件36项、TypeScript、Vite生产构建和bundle预算通过;API Jest 51套571项、TypeScript正式构建和Prisma schema校验通过;Gateway`go test ./... -count=1`与`go vet ./...`、增量Lint/Prettier、结构质量及`git diff --check`通过。专项用例覆盖URL、手机、带格式手机、固定电话、分机号、非法文字拒绝、兼容列同步和官方模板无站点名称。
|
||||
- 应用内Browser访问测试机在导航阶段连续两次超时并重置,未据此声称登录后线上页面已验收。随后用与正式发布一致的生产构建做本地API拦截视觉夹具:桌面展开态和新增弹窗、390px窄屏均渲染正常,页面级横向溢出为false,URL为`A`、固定电话为`SPAN`,弹窗不存在“名称/访问地址”,控制台事件0。夹具只用于布局回归,不作为线上业务数据证据。
|
||||
- 部署前测试机真实基线为`226527f1cef122e4dbd163da782485e8c0254dc5`,源码与数据库均95项migration,11项相关服务active,API/Gateway/Redis健康。新恢复点`/opt/cmpp-platform-backups/drainage-carrier-ui-20260828T092840Z`包含238MB PostgreSQL custom dump、211MB完整运行目录、24KB环境/systemd/Nginx配置、原部署标记、服务/Stream状态和恢复说明;`pg_restore --list`842行、运行tar 52592项、配置tar 309项,`sha256sum -c`全部成功后才开始覆盖。
|
||||
- 精确Git归档`outputs/cmpp-drainage-carrier-ui-c68ac7a-20260828T092917Z.tar.gz`为2612752字节,本地和服务器SHA-256均为`ef39ebdef18efc5937b2e4001398d4cb4667f739c1fe6b4a2206208ff280054f`且tar可读。标准部署脚本完成两套依赖安装、安全门禁、Prisma、前端/API/Gateway构建、Nginx校验和服务重启;95项migration无待执行项,旧运行目录保留为`/opt/cmpp-platform.previous-drainage-20260828T093754Z`。
|
||||
- 测试环境最终`.deployed-commit=c68ac7a3db82d1bdb867260378df18f93cca0fd2`。11项相关服务均active,API、Gateway、Gateway Callback健康,Redis PONG;`gateway.submit.commands`、`gateway.submit.results`、`gateway.protocol.logs`全部`pending=0/lag=0`,发布窗口7项核心服务error级journal均为0。
|
||||
- 线上前端资源为`index-BLUa_d1S.js`、`index-BB9q6lcg.css`、`ClientSignaturesPage-COD8bZjo.js`和`AdminDrainageAuditPage-Dg8qCDBw.js`;服务器与工作站HTTP回读SHA-256一致。首页、客户端登录、运营端登录和API健康均HTTP 200。真实测试库当前未删除引流信息为0,因此未创建业务记录模拟页面数据,也未执行审核写操作;未发送、补发或重投短信,未修改余额、通道或客户配置。
|
||||
- 仅部署测试环境`100.93.204.60`;预生产`8.160.169.106`未访问、未部署、未覆盖、未回退或修改。
|
||||
|
||||
### 追加:不带协议的 URL 校验放宽
|
||||
|
||||
- 用户追加要求引流 URL 不一定以`http`开头。增量提交`5328bb09bf89170b4368407e896dbce9527a7b5c`将 DTO 和领域服务统一改用同一校验器:接受`https://example.com/path`、`example.com/path`、`www.example.com`、IPv4地址、手机和固定电话,继续拒绝无域名结构的普通文字;客户端和运营端提示同步改为“支持带或不带协议的 URL”。
|
||||
- 增量专项 API 2套79项及前端2项通过;完整回归为前端5个文件36项、API 51套575项,两端TypeScript和Vite生产构建、bundle预算、增量Lint/Prettier及`git diff --check`通过。视觉夹具重新加载新产物,弹窗提示包含“不带协议”,名称/访问地址仍不存在,390px窄屏正常且控制台事件0。
|
||||
- 第二次部署没有复用首轮恢复点。新恢复点`/opt/cmpp-platform-backups/drainage-schemeless-url-20260828T095954Z`以`c68ac7a3db82d1bdb867260378df18f93cca0fd2`为原标记,包含238MB PostgreSQL custom dump、211MB完整运行目录、24KB环境/systemd/Nginx配置及状态/恢复说明;`pg_restore --list`842行、运行tar 52597项、配置tar 309项,`sha256sum -c`全部成功。
|
||||
- 第二次精确包`outputs/cmpp-drainage-schemeless-5328bb0-20260828T100028Z.tar.gz`为2614570字节,本地和服务器SHA-256均为`20665da0321eeb8a82ba9ca3cc3c595c3b38db439d530b34b1f03f1578f6fe64`且tar可读。标准脚本完成无待执行的95项migration及全部构建/重启;旧运行目录保留为`/opt/cmpp-platform.previous-schemeless-20260828T100204Z`。
|
||||
- 测试环境最终`.deployed-commit=5328bb09bf89170b4368407e896dbce9527a7b5c`。11项服务active,API/Gateway/Callback健康,Redis PONG,三条Stream均`pending=0/lag=0`,发布窗口7项核心服务error级journal均为0。线上新资源为`index-C_Eutz1B.js`、`index-BB9q6lcg.css`、`ClientSignaturesPage-CFLWpVlL.js`和`AdminEnterpriseSignaturesPage-9HtQnQ7y.js`;服务器与工作站HTTP回读哈希一致,四个入口均HTTP 200。
|
||||
- 本次追加仍未写入业务引流记录、未执行审核操作、未发送短信,未修改余额、通道或客户配置;预生产未访问或修改。
|
||||
|
||||
@@ -54,9 +54,9 @@ export const adminGovernanceApi = {
|
||||
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
|
||||
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
|
||||
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
|
||||
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
|
||||
createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
|
||||
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
|
||||
updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
approveDrainageInfo: (id: string) =>
|
||||
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
|
||||
@@ -182,7 +182,7 @@ export const clientApi = {
|
||||
}),
|
||||
createDrainageInfo: (
|
||||
signatureId: string,
|
||||
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
|
||||
body: { url: string; remark?: string; reportValues?: Record<string, unknown> },
|
||||
) =>
|
||||
request<SmsDrainageInfo>(`/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<string, unknown> },
|
||||
body: { url?: string; remark?: string; reportValues?: Record<string, unknown> },
|
||||
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeDrainageInfoStatus: (id: string, status: string) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
|
||||
|
||||
@@ -73,14 +73,14 @@ function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drain
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
|
||||
<div className="channel-report-detail">
|
||||
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
|
||||
<strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
|
||||
<p><span>企业</span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
|
||||
<p><span>企业应用</span><span>{signature?.application?.name ?? '-'}</span></p>
|
||||
<p><span>提交报备时间</span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
|
||||
<p><span>报备成功时间</span><DateTime value={reportedAt} /></p>
|
||||
<p><span>上次发送成功时间</span><DateTime value={task.lastSuccessfulSentAt} /></p>
|
||||
{!drainage ? <><p><span>签名依据</span><span>{String(profile.basis ?? '-')}</span></p><p><span>公司名称</span><span>{String(profile.companyName ?? '-')}</span></p><p><span>统一社会信用代码</span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
|
||||
{drainage ? <><p><span>引流地址</span><span>{String(drainage.url ?? '-')}</span></p><p><span>备注</span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
|
||||
{drainage ? <><p><span>引流 URL 或号码</span><span>{String(drainage.url ?? '-')}</span></p><p><span>备注</span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
|
||||
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
|
||||
<section><h3>今日发送</h3><DeliveryStats task={task} /></section>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@ export function AdminChannelReportPage() {
|
||||
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
|
||||
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
|
||||
<span />
|
||||
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
|
||||
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
|
||||
<ReportStatus value={task.status} />
|
||||
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
|
||||
<DateTime value={reportedAt} />
|
||||
|
||||
@@ -30,8 +30,7 @@ function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: ()
|
||||
<div><span>应用</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>所属签名</span><strong>{item.signature?.name ?? item.signatureId}</strong></div>
|
||||
<div><span>审核状态</span><Tag tone={meta.tone}>{meta.label}</Tag></div>
|
||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||
<div><span>引流地址</span><strong>{item.url}</strong></div>
|
||||
<div className="detail-grid__wide"><span>引流 URL 或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(item.submittedAt)}</strong></div>
|
||||
<AuditReviewInfo targetId={item.id} targetType="sms_drainage_info" />
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
@@ -83,7 +82,7 @@ export function AdminDrainageAuditPage() {
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsDrainageInfo>>>(() => [
|
||||
{ key: 'siteName', title: '站名称/引流地址', render: (record) => <div className="admin-task-enterprise"><strong>{record.siteName}</strong><span>{record.url}</span></div> },
|
||||
{ key: 'url', title: '引流 URL 或号码', render: (record) => <strong>{record.url}</strong> },
|
||||
{ key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId },
|
||||
{ key: 'tenant', title: '企业/应用', render: (record) => <div className="admin-task-enterprise"><strong>{record.tenant?.name ?? record.tenantId}</strong><span>{record.application?.name ?? '-'}</span></div> },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.submittedAt) },
|
||||
@@ -95,7 +94,7 @@ export function AdminDrainageAuditPage() {
|
||||
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1>引流信息审核</h1></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Tabs items={[
|
||||
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、URL或号码" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="drainage" /> },
|
||||
]} />
|
||||
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
return;
|
||||
}
|
||||
const exists = readDrainagePayload(signature).links.some((current) => current.id === item.id);
|
||||
const body = { siteName: item.url.trim(), url: item.url.trim(), remark: item.remark, reportValues: item.reportValues };
|
||||
const body = { url: item.url.trim(), remark: item.remark, reportValues: item.reportValues };
|
||||
if (exists) await adminApi.updateDrainageInfo(item.id, body);
|
||||
else await adminApi.createDrainageInfo(signatureId, body);
|
||||
setDrainageModal(null);
|
||||
|
||||
@@ -40,7 +40,7 @@ function recordSource(record: ReportRecord) {
|
||||
|
||||
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
|
||||
const isDrainage = record.task?.reportType === 'drainage';
|
||||
const target = isDrainage ? record.task?.drainageInfo?.siteName ?? record.task?.drainageInfo?.url : record.task?.signature?.name;
|
||||
const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name;
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备记录详情</h2><p>{record.id}</p></div>}>
|
||||
<div className="report-record-detail">
|
||||
@@ -100,7 +100,7 @@ export function AdminReportRecordsPage() {
|
||||
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
|
||||
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
|
||||
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
|
||||
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
|
||||
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
|
||||
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
|
||||
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
|
||||
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)} → ${translateStatus(record.statusAfter)}`}</Tag> },
|
||||
|
||||
@@ -25,7 +25,7 @@ const actionLabels: Record<string, string> = {
|
||||
|
||||
function taskTargetLabel(task: ReportTask) {
|
||||
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
|
||||
return task.drainageInfo?.siteName || task.drainageInfo?.url || task.drainageItemId || '引流信息';
|
||||
return task.drainageInfo?.url || task.drainageItemId || '引流信息';
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
|
||||
|
||||
@@ -22,7 +22,7 @@ const itemStatusLabels: Record<string, string> = {
|
||||
|
||||
function itemName(item: ReportImportReviewItem) {
|
||||
if (item.reportType === 'signature') return String(item.payload.name ?? '-');
|
||||
return String(item.payload.siteName ?? item.payload.url ?? '-');
|
||||
return String(item.payload.url ?? '-');
|
||||
}
|
||||
|
||||
function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
|
||||
|
||||
@@ -18,7 +18,7 @@ const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾
|
||||
function coreTargets(reportType: ReportType) {
|
||||
return reportType === 'signature'
|
||||
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
|
||||
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '站点名称', value: 'siteName:site_name:string' }, { label: '引流地址', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
|
||||
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '引流 URL 或号码', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
|
||||
}
|
||||
|
||||
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
||||
|
||||
@@ -64,8 +64,9 @@ export function DrainageFormModal({ applicationId, item, onClose, onSubmit }: {
|
||||
<h3>基本信息</h3>
|
||||
<Input
|
||||
label="* 引流url或号码"
|
||||
hint="支持带或不带协议的 URL、手机号码或固定电话号码"
|
||||
onChange={(event) => update('url', event.target.value)}
|
||||
placeholder="请输入引流url或号码"
|
||||
placeholder="例如:example.com/path 或 13800138000"
|
||||
required
|
||||
value={form.url}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ClientSignaturesPage } from './ClientSignaturesPage';
|
||||
|
||||
const { clientApi } = vi.hoisted(() => ({
|
||||
clientApi: {
|
||||
listApplicationOptions: vi.fn(),
|
||||
getSignatureWorkspace: vi.fn(),
|
||||
listApplicationReportFields: vi.fn(),
|
||||
listCommonApplicationReportFields: vi.fn(),
|
||||
uploadFileObject: vi.fn(),
|
||||
createSignature: vi.fn(),
|
||||
updateSignature: vi.fn(),
|
||||
submitSignature: vi.fn(),
|
||||
createDrainageInfo: vi.fn(),
|
||||
updateDrainageInfo: vi.fn(),
|
||||
changeDrainageInfoStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Button: ({ children, icon: _icon, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => <button {...props}>{children}</button>,
|
||||
CarrierTag: ({ carrier }: { carrier: string }) => <span>{({ mobile: '移动', unicom: '联通', telecom: '电信' } as Record<string, string>)[carrier]}</span>,
|
||||
DeleteRiskAction: () => <button type="button">删除签名</button>,
|
||||
FileActions: () => null,
|
||||
Input: ({ hint, label, prefix: _prefix, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hint?: string; label?: string; prefix?: React.ReactNode }) => <label>{label}<input aria-label={label} {...props} />{hint ? <small>{hint}</small> : null}</label>,
|
||||
Modal: ({ children, footer, open, title }: { children: React.ReactNode; footer: React.ReactNode; open: boolean; title: string }) => open ? <div aria-label={title} role="dialog">{children}{footer}</div> : null,
|
||||
Pagination: () => null,
|
||||
Select: ({ label, options, ...props }: React.SelectHTMLAttributes<HTMLSelectElement> & { label?: string; options: Array<{ label: string; value: string }> }) => <label>{label}<select aria-label={label} {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>,
|
||||
Textarea: ({ label, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) => <label>{label}<textarea aria-label={label} {...props} /></label>,
|
||||
}));
|
||||
|
||||
const signature = {
|
||||
id: 'signature-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '【测试签名】',
|
||||
auditStatus: 'approved',
|
||||
createdAt: '2026-08-28T00:00:00.000Z',
|
||||
updatedAt: '2026-08-28T01:00:00.000Z',
|
||||
application: { id: 'app-1', name: '测试应用', status: 'active' },
|
||||
submittedMaterialCount: 0,
|
||||
reportValues: {},
|
||||
carrierReportSummary: {
|
||||
mobile: { status: 'approved', approved: 1, total: 1 },
|
||||
unicom: { status: 'pending', approved: 0, total: 1 },
|
||||
telecom: { status: 'partial_success', approved: 1, total: 2 },
|
||||
},
|
||||
drainageCarrierReportSummary: {},
|
||||
drainageInfo: { links: [] },
|
||||
};
|
||||
|
||||
describe('ClientSignaturesPage drainage presentation', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
||||
clientApi.listApplicationOptions.mockResolvedValue([{ id: 'app-1', name: '测试应用', status: 'active' }]);
|
||||
clientApi.getSignatureWorkspace.mockResolvedValue({ items: [signature], summary: { total: 1, pending: 0, approved: 1, rejected: 0, draft: 0 }, total: 1, page: 1, pageSize: 10 });
|
||||
clientApi.listApplicationReportFields.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('groups the three real carrier summaries into a readable status area', async () => {
|
||||
render(<ClientSignaturesPage />);
|
||||
expect(await screen.findByText('【测试签名】')).toBeVisible();
|
||||
expect(screen.getByText('三网报备状态')).toBeVisible();
|
||||
const row = screen.getByText('【测试签名】').closest('.client-signature-list-row');
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row as HTMLElement).getByText('移动')).toBeVisible();
|
||||
expect(within(row as HTMLElement).getByText('联通')).toBeVisible();
|
||||
expect(within(row as HTMLElement).getByText('电信')).toBeVisible();
|
||||
expect(within(row as HTMLElement).getAllByText('报备通过')).toHaveLength(2);
|
||||
expect(within(row as HTMLElement).getByText('暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses one URL-or-number field and removes the redundant name field', async () => {
|
||||
render(<ClientSignaturesPage />);
|
||||
await screen.findByText('【测试签名】');
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开引流信息' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增引流信息' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新增引流信息' });
|
||||
expect(within(dialog).getByLabelText('引流 URL 或号码')).toBeVisible();
|
||||
expect(within(dialog).queryByLabelText('名称')).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByLabelText('访问地址')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Button, CarrierTag, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Textarea } from '@/components/ui';
|
||||
import {
|
||||
clientApi,
|
||||
type ClientApplicationReportField,
|
||||
@@ -22,9 +22,19 @@ const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
|
||||
type ClientDrainageInfo = ClientSmsSignatureView['drainageInfo']['links'][number];
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
function CarrierAvailability({ summary }: { summary?: { status: string; approved: number; total: number } }) {
|
||||
function CarrierAvailability({ carrier, summary }: { carrier: Carrier; summary?: { status: string; approved: number; total: number } }) {
|
||||
const available = Boolean(summary && (summary.approved > 0 || ['approved', 'partial_success'].includes(summary.status)));
|
||||
return <Tag tone={available ? 'success' : 'neutral'}>{available ? '报备通过' : '暂不可用'}</Tag>;
|
||||
return <div className={`client-carrier-status client-carrier-status--${available ? 'available' : 'unavailable'}`}>
|
||||
<CarrierTag carrier={carrier} />
|
||||
<span aria-hidden className="client-carrier-status__dot" />
|
||||
<strong>{available ? '报备通过' : '暂不可用'}</strong>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DrainageTarget({ value }: { value: string }) {
|
||||
return /^https?:\/\//i.test(value)
|
||||
? <a className="client-drainage-target" href={value} rel="noreferrer" target="_blank" title={value}>{value}</a>
|
||||
: <span className="client-drainage-target" title={value}>{value}</span>;
|
||||
}
|
||||
|
||||
function reportFileRef(value: unknown): FileRef | null {
|
||||
@@ -189,7 +199,6 @@ function SignatureModal({
|
||||
|
||||
function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDrainageInfo; signature: ClientSmsSignatureView; onClose: () => void; onSaved: () => void }) {
|
||||
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
|
||||
const [siteName, setSiteName] = useState(item?.siteName ?? '');
|
||||
const [url, setUrl] = useState(item?.url ?? '');
|
||||
const [remark, setRemark] = useState(item?.remark ?? '');
|
||||
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
|
||||
@@ -221,7 +230,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { siteName: siteName.trim(), url: url.trim(), remark: remark?.trim(), reportValues: values };
|
||||
const body = { url: url.trim(), remark: remark?.trim(), reportValues: values };
|
||||
if (item) await clientApi.updateDrainageInfo(item.id, body);
|
||||
else await clientApi.createDrainageInfo(signature.id, body);
|
||||
onSaved();
|
||||
@@ -234,7 +243,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
|
||||
|
||||
const missingRequired = fields.some((field) => field.required && !values[field.code]);
|
||||
return <Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
@@ -242,11 +251,10 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
|
||||
>
|
||||
<div className="signature-form">
|
||||
{item?.rejectReason ? <div className="client-signature-reason"><strong>修改说明</strong><span>{item.rejectReason}</span></div> : null}
|
||||
<Input label="名称" onChange={(event) => setSiteName(event.target.value)} placeholder="例如:品牌官网" required value={siteName} />
|
||||
<Input label="访问地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
|
||||
<Input hint="支持带或不带协议的 URL、手机号码或固定电话号码" label="引流 URL 或号码" onChange={(event) => setUrl(event.target.value)} placeholder="例如:example.com/path 或 13800138000" required value={url} />
|
||||
<Textarea label="说明" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark ?? ''} />
|
||||
<section className="client-signature-form-section">
|
||||
<div><h3>审核资料</h3><p>请补充此链接对应的主体或页面证明。</p></div>
|
||||
<div><h3>审核资料</h3><p>请补充此 URL 或号码对应的主体证明。</p></div>
|
||||
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
|
||||
</section>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -329,7 +337,7 @@ export function ClientSignaturesPage() {
|
||||
<header className="client-signature-heading">
|
||||
<div className="client-signature-title">
|
||||
<span className="client-signature-title__icon"><FileCheck2 size={23} /></span>
|
||||
<div><h1>签名与引流信息</h1><p>集中管理短信签名及短信中使用的网站、应用页面等引流信息。</p></div>
|
||||
<div><h1>签名与引流信息</h1><p>集中管理短信签名及短信中使用的 URL、手机号码和固定电话号码。</p></div>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal('new')}>新增签名</Button>
|
||||
</header>
|
||||
@@ -342,7 +350,7 @@ export function ClientSignaturesPage() {
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<section className="client-signature-list-shell">
|
||||
<div className="client-signature-list-head"><span /><span>签名名称</span><span>所属应用</span><span>移动</span><span>联通</span><span>电信</span><span>更新时间</span><span>操作</span></div>
|
||||
<div className="client-signature-list-head"><span /><span>签名名称</span><span>所属应用</span><span>三网报备状态</span><span>更新时间</span><span>操作</span></div>
|
||||
{loading ? <p className="client-signature-list-empty">正在加载...</p> : null}
|
||||
{!loading && !visibleItems.length ? <p className="client-signature-list-empty">没有符合条件的签名记录。</p> : null}
|
||||
{visibleItems.map((signature) => {
|
||||
@@ -354,7 +362,7 @@ export function ClientSignaturesPage() {
|
||||
<button aria-label={expanded ? '收起引流信息' : '展开引流信息'} className="client-signature-expand" onClick={() => toggleExpanded(signature.id)} type="button">{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button>
|
||||
<strong>{signature.name}</strong>
|
||||
<span>{signature.application?.name ?? '未绑定'}</span>
|
||||
{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}
|
||||
<div className="client-carrier-status-group">{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability carrier={carrier} key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}</div>
|
||||
<span>{formatDate(signature.updatedAt)}</span>
|
||||
<div className="table-actions">
|
||||
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">修改</Button>
|
||||
@@ -362,12 +370,12 @@ export function ClientSignaturesPage() {
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? <div className="client-drainage-panel">
|
||||
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> 关联引流信息</h3><p>管理短信内容中可能使用的网站或页面地址。</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost">新增引流信息</Button></div>
|
||||
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> 关联引流信息</h3><p>管理短信内容中可能使用的 URL、手机号码或固定电话号码。</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost">新增引流信息</Button></div>
|
||||
{links.length ? <div className="client-drainage-table">
|
||||
<div className="client-drainage-table__head"><span>名称</span><span>访问地址</span><span>移动</span><span>联通</span><span>电信</span><span>更新时间</span><span>操作</span></div>
|
||||
<div className="client-drainage-table__head"><span>引流 URL 或号码</span><span>三网报备状态</span><span>更新时间</span><span>操作</span></div>
|
||||
{links.map((item) => <div className="client-drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.drainageCarrierReportSummary?.[item.id]?.[carrier]} />)}<span>{formatDate(item.updatedAt)}</span>
|
||||
<div className="table-actions"><Button disabled={item.auditStatus === 'pending'} icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost">修改</Button><Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'drainage', id: item.id, name: item.siteName })} size="sm" variant="danger">删除</Button></div>
|
||||
<DrainageTarget value={item.url} /><div className="client-carrier-status-group">{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability carrier={carrier} key={carrier} summary={signature.drainageCarrierReportSummary?.[item.id]?.[carrier]} />)}</div><span>{formatDate(item.updatedAt)}</span>
|
||||
<div className="table-actions"><Button disabled={item.auditStatus === 'pending'} icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost">修改</Button><Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'drainage', id: item.id, name: item.url })} size="sm" variant="danger">删除</Button></div>
|
||||
</div>)}
|
||||
</div> : <p className="client-signature-empty-hint">暂无引流信息。签名审核通过后可在这里新增。</p>}
|
||||
</div> : null}
|
||||
|
||||
+54
-5
@@ -8745,8 +8745,8 @@
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 24px minmax(120px, 1.1fr) minmax(110px, 1fr) 92px 92px 92px 142px minmax(144px, auto);
|
||||
min-width: 1050px;
|
||||
grid-template-columns: 24px minmax(120px, 1.05fr) minmax(110px, .85fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
|
||||
min-width: 1030px;
|
||||
}
|
||||
|
||||
.client-signature-list-head {
|
||||
@@ -8804,6 +8804,54 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.client-carrier-status-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-carrier-status {
|
||||
align-items: center;
|
||||
background: #f8faf9;
|
||||
border: 1px solid #e3e9e6;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: auto 7px minmax(0, 1fr);
|
||||
min-height: 34px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.client-carrier-status strong {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-carrier-status__dot {
|
||||
background: #b5c0bb;
|
||||
border-radius: 50%;
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.client-carrier-status--available {
|
||||
background: #f1faf6;
|
||||
border-color: #cfe8dc;
|
||||
}
|
||||
|
||||
.client-carrier-status--available strong {
|
||||
color: #176b49;
|
||||
}
|
||||
|
||||
.client-carrier-status--available .client-carrier-status__dot {
|
||||
background: #24a06b;
|
||||
box-shadow: 0 0 0 3px rgba(36, 160, 107, .12);
|
||||
}
|
||||
|
||||
.client-signature-inline-reason,
|
||||
.client-signature-reason,
|
||||
.client-drainage-reason {
|
||||
@@ -8855,8 +8903,8 @@
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(200px, 1.7fr) 92px 92px 92px 142px minmax(144px, auto);
|
||||
min-width: 980px;
|
||||
grid-template-columns: minmax(220px, 1.35fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
|
||||
min-width: 940px;
|
||||
}
|
||||
|
||||
.client-drainage-table__head {
|
||||
@@ -8873,8 +8921,9 @@
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.client-drainage-table__row > a {
|
||||
.client-drainage-target {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
Reference in New Issue
Block a user