feat: densify enterprise signature management

This commit is contained in:
hectorzhao
2026-09-02 10:04:58 +08:00
parent 1f2dfb5caf
commit 9e34757d6f
14 changed files with 240 additions and 119 deletions
@@ -72,8 +72,8 @@ export class AdminSmsConfigController {
} }
@Get('enterprise-signatures') @Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('signatureSort') signatureSort?: 'asc' | 'desc', @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) }; const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, signatureSort, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query); return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query);
} }
+4 -1
View File
@@ -18,6 +18,7 @@ export class SmsSignatureService {
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
async listSignatures(queryOrTenantId?: string | SignatureListQuery) { async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
const signatureSort = query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined;
const signatures = await this.prisma.smsSignature.findMany({ const signatures = await this.prisma.smsSignature.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
@@ -50,7 +51,9 @@ export class SmsSignatureService {
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } }, reportTasks: { include: { channel: true, drainageInfo: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: signatureSort
? [{ name: signatureSort }, { id: 'asc' }]
: { createdAt: 'desc' },
...(query.page && query.pageSize ? { ...(query.page && query.pageSize ? {
skip: (query.page - 1) * query.pageSize, skip: (query.page - 1) * query.pageSize,
take: query.pageSize, take: query.pageSize,
@@ -143,6 +143,7 @@ export interface SignatureListQuery {
applicationKeyword?: string; applicationKeyword?: string;
signatureKeyword?: string; signatureKeyword?: string;
drainageKeyword?: string; drainageKeyword?: string;
signatureSort?: 'asc' | 'desc';
submittedAtFrom?: string; submittedAtFrom?: string;
submittedAtTo?: string; submittedAtTo?: string;
page?: number; page?: number;
@@ -781,7 +781,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([ await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网', signatureSort: 'asc' })).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: 'sig-1', id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }), tenant: expect.objectContaining({ name: '租户A' }),
@@ -803,6 +803,7 @@ describe('SmsConfigService', () => {
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } }, reportTasks: { include: { channel: true, drainageInfo: true } },
}, },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})); }));
}); });
@@ -57,24 +57,16 @@
}, },
{ {
"name": "SignatureFormModal", "name": "SignatureFormModal",
"canonicalSha256": "664e1cd87309c64c03f9198543171bb519d448eec4a83cf890444fee16d6dd66" "canonicalSha256": "1768695d72a0953d6d0108ee4e67c3a321e4869de37a817dabeee8c3b59859ca"
}, },
{ {
"name": "DrainageFormModal", "name": "DrainageFormModal",
"canonicalSha256": "0e63725f10e8021e0a7beb5a71f52773ce271faea137735d3fc184f07a39c6d6" "canonicalSha256": "30daa4dfc5c8dc83f17ef901143f61490d13d081aa8b30e4677e12f814025942"
},
{
"name": "SignatureReportModal",
"canonicalSha256": "4cfec954f564c998af70da441971bede16915f4bb5ac9c10e45da15101957de5"
}, },
{ {
"name": "ChannelReportStatusModal", "name": "ChannelReportStatusModal",
"canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb" "canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb"
}, },
{
"name": "DrainageReportModal",
"canonicalSha256": "d2b41c26eb3992bb1ff3b50ee5ef6c06e7f84d89832e3ec7446b35178520431f"
},
{ {
"name": "DrainageReportStatusModal", "name": "DrainageReportStatusModal",
"canonicalSha256": "ef692f08d2140e82e40fc12f716b15a902e3c66948140e8512f5a317c629215c" "canonicalSha256": "ef692f08d2140e82e40fc12f716b15a902e3c66948140e8512f5a317c629215c"
@@ -84,7 +76,7 @@
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da" "canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
} }
], ],
"tableJsxSha256": "5cae4926ab10a762b26d77d0a0633b85de68fa204b7d5732ba0942e7fc276234", "tableJsxSha256": "9d8b0f90e6a375fb3d019985d2b6d3409d7b41956e1893f0654c80499c1b64b4",
"apiCalls": [ "apiCalls": [
"listEnterpriseSignaturesPage", "listEnterpriseSignaturesPage",
"listTenants", "listTenants",
@@ -100,7 +92,6 @@
"applications", "applications",
"deleteTarget", "deleteTarget",
"drainageModal", "drainageModal",
"drainageReport",
"drainageStatusTarget", "drainageStatusTarget",
"enterpriseKeyword", "enterpriseKeyword",
"appliedEnterpriseKeyword", "appliedEnterpriseKeyword",
@@ -111,9 +102,9 @@
"error", "error",
"expandedSignatureId", "expandedSignatureId",
"signatureKeyword", "signatureKeyword",
"signatureSort",
"appliedSignatureKeyword", "appliedSignatureKeyword",
"signatureModal", "signatureModal",
"signatureReport",
"reportStatusTarget", "reportStatusTarget",
"signatures", "signatures",
"total", "total",
+11
View File
@@ -4991,3 +4991,14 @@ npm run verify:phase8
| TC-DISK-DEDUP-002 | 打乱采集顺序、根目录有绑定路径、主路径停止采集、趋势缺失或NaN | 文件系统ID保持稳定;根盘兼容指标及趋势正确;主路径按层级/长度/字典序确定;同一文件系统只有一条趋势,不补零,不因路径改变丢失历史 | | TC-DISK-DEDUP-002 | 打乱采集顺序、根目录有绑定路径、主路径停止采集、趋势缺失或NaN | 文件系统ID保持稳定;根盘兼容指标及趋势正确;主路径按层级/长度/字典序确定;同一文件系统只有一条趋势,不补零,不因路径改变丢失历史 |
| TC-DISK-DEDUP-003 | 不同instance/device/fstype的样本容量相同,或指标不可用 | 不同文件系统不误合并;缺失指标为null/空趋势,页面显示暂无数据而非旧卡片;不得仅凭容量相同合并 | | TC-DISK-DEDUP-003 | 不同instance/device/fstype的样本容量相同,或指标不可用 | 不同文件系统不误合并;缺失指标为null/空趋势,页面显示暂无数据而非旧卡片;不得仅凭容量相同合并 |
| TC-DISK-DEDUP-004 | 核对API查询、基础规则和按当前有效阈值生成的托管规则 | 容量和inode均max by(instance,device,fstype),可用容量min聚合,不按mountpoint重复告警;阈值不变;promtool校验通过,未来发布后再核对实际加载规则、告警及真实页面;告警标签变化会影响指纹和for计时,不虚报已保留 | | TC-DISK-DEDUP-004 | 核对API查询、基础规则和按当前有效阈值生成的托管规则 | 容量和inode均max by(instance,device,fstype),可用容量min聚合,不按mountpoint重复告警;阈值不变;promtool校验通过,未来发布后再核对实际加载规则、告警及真实页面;告警标签变化会影响指纹和for计时,不虚报已保留 |
## TC-ENTERPRISE-SIGNATURE-DENSITY-20260902 企业签名高密度列表
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-ENTERPRISE-SIGNATURE-DENSITY-001 | 首次打开运营端企业签名管理 | 不请求或展示顶部全部/待审核/报备中/异常统计;短信签名Tab不显示总数;列表使用紧凑表头与行布局 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-002 | 查看签名及展开的引流信息 | 两级表头统一使用“审核状态”;移动/联通/电信仅显示已通过通道数/总通道数,不显示日期;引流信息列只显示条数,不显示异常数 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-003 | 查看签名查询和签名列 | 查询标签及表头统一为“签名”,不显示签名用途;签名统一显示完整中文黑括号,例如“【聆界科技】”,已有括号不得重复包裹 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-004 | 点击签名升序或降序按钮并翻页 | 请求携带signatureSort=asc/desc,后端先按签名及ID稳定排序再分页;切换排序回第一页,不只重排当前页,不产生重复请求 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-005 | 查看签名及引流信息操作列 | 两级操作列都只展示“报备状态、编辑、删除”;不显示“报备详情”;三个入口继续调用原真实后端流程 |
| TC-ENTERPRISE-SIGNATURE-DENSITY-006 | 1600×1000桌面视口查看并展开首条签名 | 表头与数据列对齐,操作按钮不换行,展开表格无裁切;页面无框架错误层,控制台无相关错误;窄视口由列表容器横向滚动,不挤压错列 |
+11
View File
@@ -4238,3 +4238,14 @@ git diff --check
- 本地全量API 51套584项、前端9文件48项通过;前后端TypeScript检查、Vite构建、依赖安全、部署契约、包体积门禁及`git diff --check`通过。Vite仍提示既有Chart分块超过500kB,但入口gzip约107.39KiB,符合250KiB预算。新增回归覆盖绑定路径、乱序、主路径缺失、不同主机/设备/类型、缺失样本及展开交互;更新`TC-PORTAL-008/009`,新增`TC-DISK-DEDUP-001..004` - 本地全量API 51套584项、前端9文件48项通过;前后端TypeScript检查、Vite构建、依赖安全、部署契约、包体积门禁及`git diff --check`通过。Vite仍提示既有Chart分块超过500kB,但入口gzip约107.39KiB,符合250KiB预算。新增回归覆盖绑定路径、乱序、主路径缺失、不同主机/设备/类型、缺失样本及展开交互;更新`TC-PORTAL-008/009`,新增`TC-DISK-DEDUP-001..004`
- 本轮仅本地单元/组件回归及真实采集查询核验,没有部署新API/页面;登录浏览器视觉、控制台、真实页面展开及告警加载验收留待下一次明确授权发布,不以测试替身或构建通过冒充真实页面验收。既存日报5秒事务超时不在本轮修复范围。 - 本轮仅本地单元/组件回归及真实采集查询核验,没有部署新API/页面;登录浏览器视觉、控制台、真实页面展开及告警加载验收留待下一次明确授权发布,不以测试替身或构建通过冒充真实页面验收。既存日报5秒事务超时不在本轮修复范围。
- 提交仅包含本轮监控源码、规则、测试用例和本节进度追加;其他会话的迁盘文档及进度追加不纳入。本轮没有发送、补发、重投短信,也没有修改余额、通道或客户配置;待下一版本指令后再决定推送和目标环境发布。 - 提交仅包含本轮监控源码、规则、测试用例和本节进度追加;其他会话的迁盘文档及进度追加不纳入。本轮没有发送、补发、重投短信,也没有修改余额、通道或客户配置;待下一版本指令后再决定推送和目标环境发布。
## 2026-09-02 运营端企业签名管理高密度改版(仅本地提交)
- 按用户确认的第1稿落实现有企业签名管理,不新增顶部统计接口或Tab数量;保留既有4项查询及查询/重置语义。签名列表改为紧凑表头/行布局,签名统一以中文黑括号展示且不重复包裹,不展示用途;引流信息仅显示条数,不计算或显示异常数。
- 签名及引流两级表头统一为“审核状态”。移动、联通、电信列从文字状态/日期组合收敛为真实`approved/total`通道数字,零适用通道显示“—”,完整含义保留在title及无障碍标签中;数据继续来自现有真实报备目标汇总,不使用静态业务数据替代产品实现。
- 签名列新增升/降序双按钮,前端传递`signatureSort=asc|desc`,后端按`name`再按`id`稳定排序后分页;无排序参数的既有调用继续按创建时间倒序。切换排序回第一页,已在第一页时仅发送一次请求。签名与引流操作列均只保留“报备状态、编辑、删除”,移除两个报备详情入口及对应未使用展示组件。
- 更新R4页面契约:移除已废弃的两个详情组件/状态,纳入排序状态和新表格JSX;同时将前次签名/引流字段变更后已过期但未同步的两个表单函数哈希对齐当前源码。门禁通过,仍确认8个真实API调用和24项页面状态。
- 定向回归:企业签名组件2项、SMS配置服务68项通过;全量API 51套584项、前端10文件50项通过,前后端TypeScript、定向ESLint、Vite构建、依赖安全、部署契约、R4契约及包体积门禁通过。Vite仍仅有既有Chart分块超过500kB提示,入口gzip 107.41KiB符合250KiB预算。
- Browser插件本轮不可用,常规Chrome/Edge自动化进程因本机策略启动即退出;改用工作区自带Playwright Chromium复核本地构建。1600×1000页面身份、非空、无框架错误层、控制台、截图和交互通过:展开首条后可见3条引流信息;两级各3个指定操作、0个报备详情;点击降序实际请求`/api/admin/enterprise-signatures?signatureSort=desc&page=1&pageSize=10`。视觉夹具仅用于渲染验证,不冒充真实API验收。
- 本轮不推送、不部署,不访问或修改测试/预生产业务数据;未发送、补发、重投短信,未修改余额、通道或客户配置。真实登录环境API、浏览器控制台及页面效果仍须在未来获授权发布后复核。
- 提交只暂存本轮源码、测试、R4契约、测试用例及本节进度追加;原有4份修改文档、3份未跟踪文档、未跟踪`=`文件和`docs/testing-progress.md`中另一会话26行追加继续保留,不覆盖、不夹带。
+1 -1
View File
@@ -40,7 +40,7 @@ export const adminGovernanceApi = {
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; signatureSort?: 'asc' | 'desc'; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)), request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
@@ -6,7 +6,7 @@ import { ReportMaterialImportModal } from './ReportMaterialImportModal';
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal'; import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable'; import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal'; import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportModal, DrainageReportStatusModal, SignatureReportModal } from './enterprise-signatures/SignatureReportModals'; import { ChannelReportStatusModal, ConfirmModal, DrainageReportStatusModal } from './enterprise-signatures/SignatureReportModals';
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers'; import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types'; import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
@@ -16,7 +16,6 @@ export function AdminEnterpriseSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]); const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null); const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null); const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState(''); const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
@@ -27,9 +26,9 @@ export function AdminEnterpriseSignaturesPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState(''); const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState(''); const [signatureKeyword, setSignatureKeyword] = useState('');
const [signatureSort, setSignatureSort] = useState<'asc' | 'desc'>('asc');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState(''); const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null); const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null); const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -40,10 +39,10 @@ export function AdminEnterpriseSignaturesPage() {
const pageSize = 10; const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page) { async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page, targetSort = signatureSort) {
try { try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([ const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize }), adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
adminApi.listTenants(), adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(), adminApi.listEnterpriseApplicationOptions(),
]); ]);
@@ -130,13 +129,17 @@ export function AdminEnterpriseSignaturesPage() {
loadData={loadData} loadData={loadData}
setDeleteTarget={setDeleteTarget} setDeleteTarget={setDeleteTarget}
setDrainageModal={setDrainageModal} setDrainageModal={setDrainageModal}
setDrainageReport={setDrainageReport}
setDrainageStatusTarget={setDrainageStatusTarget} setDrainageStatusTarget={setDrainageStatusTarget}
setExpandedSignatureId={setExpandedSignatureId} setExpandedSignatureId={setExpandedSignatureId}
setPage={setPage} setPage={setPage}
setSignatureSort={(nextSort) => {
setSignatureSort(nextSort);
if (page === 1) void loadData(undefined, 1, nextSort);
else setPage(1);
}}
setReportStatusTarget={setReportStatusTarget} setReportStatusTarget={setReportStatusTarget}
setSignatureModal={setSignatureModal} setSignatureModal={setSignatureModal}
setSignatureReport={setSignatureReport} signatureSort={signatureSort}
total={total} total={total}
totalPages={totalPages} totalPages={totalPages}
visibleSignatures={visibleSignatures} visibleSignatures={visibleSignatures}
@@ -159,7 +162,7 @@ export function AdminEnterpriseSignaturesPage() {
<div className="surface admin-split-filter"> <div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} /> <Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} /> <Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} /> <Input label="签名" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} /> <Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<div className="admin-split-filter__actions"> <div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { <Button icon={<Search size={16} />} onClick={() => {
@@ -214,7 +217,6 @@ export function AdminEnterpriseSignaturesPage() {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次'); setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData(); void loadData();
}} /> : null} }} /> : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null} {reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{drainageModal ? ( {drainageModal ? (
<DrainageFormModal <DrainageFormModal
@@ -224,7 +226,6 @@ export function AdminEnterpriseSignaturesPage() {
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }} onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
/> />
) : null} ) : null}
{drainageReport ? <DrainageReportModal item={drainageReport.item} onClose={() => setDrainageReport(null)} signature={drainageReport.signature} /> : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null} {drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? ( {deleteTarget ? (
<ConfirmModal <ConfirmModal
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { ClientSmsSignature } from '@/api/adminApi';
import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable';
const signature = {
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '【聆界科技】',
auditStatus: 'approved',
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T01:00:00.000Z',
tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' },
application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' },
carrierReportSummary: {
mobile: { status: 'approved', approved: 3, total: 3 },
unicom: { status: 'reporting', approved: 2, total: 3 },
telecom: { status: 'waiting_material', approved: 1, total: 3 },
},
drainageInfo: { links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }] },
drainageCarrierReportSummary: {
'drainage-1': {
mobile: { status: 'approved', approved: 3, total: 3 },
unicom: { status: 'reporting', approved: 1, total: 3 },
telecom: { status: 'waiting_material', approved: 0, total: 3 },
},
},
} as unknown as ClientSmsSignature;
function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) {
const props: Parameters<typeof EnterpriseSignaturesTable>[0] = {
appliedDrainageKeyword: '', currentPage: 1, expandedSignatureId: signature.id,
filteredSignatures: [signature], visibleSignatures: [signature], total: 1, totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined), setDeleteTarget: vi.fn(), setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(), setExpandedSignatureId: vi.fn(), setPage: vi.fn(),
setReportStatusTarget: vi.fn(), setSignatureModal: vi.fn(), setSignatureSort: vi.fn(), signatureSort: 'asc',
...overrides,
};
return { ...render(<EnterpriseSignaturesTable {...props} />), props };
}
describe('EnterpriseSignaturesTable dense presentation', () => {
it('uses compact headers, bracketed signatures, channel counts and only the requested row actions', () => {
renderTable();
expect(screen.getByText('【聆界科技】')).toBeVisible();
expect(screen.getAllByText('审核状态')).toHaveLength(2);
expect(screen.queryByText('签名名称')).not.toBeInTheDocument();
expect(screen.queryByText('签名审核')).not.toBeInTheDocument();
expect(screen.queryByText('报备详情')).not.toBeInTheDocument();
expect(screen.getAllByLabelText('已报备通过3个,总通道数3个')).toHaveLength(2);
expect(screen.getByLabelText('已报备通过2个,总通道数3个')).toHaveTextContent('2/3');
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
});
it('requests a real descending sort from the signature column control', async () => {
const setSignatureSort = vi.fn();
renderTable({ expandedSignatureId: '', setSignatureSort });
await userEvent.click(screen.getByRole('button', { name: '签名降序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
});
});
@@ -1,8 +1,8 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Plus } from 'lucide-react'; import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Edit3, Plus } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi'; import type { ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, DeleteRiskAction, Pagination } from '@/components/ui'; import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportTag, readDrainagePayload, signatureCardVisual } from './signature.helpers'; import { AuditStatusTag, CarrierReportCount, formatSignatureName, readDrainagePayload, signatureCardVisual } from './signature.helpers';
import type { DrainageInfo } from './signature.types'; import type { DrainageInfo } from './signature.types';
type EnterpriseSignaturesTableProps = { type EnterpriseSignaturesTableProps = {
@@ -13,13 +13,13 @@ type EnterpriseSignaturesTableProps = {
loadData: () => Promise<void>; loadData: () => Promise<void>;
setDeleteTarget: Dispatch<SetStateAction<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>>; setDeleteTarget: Dispatch<SetStateAction<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>>;
setDrainageModal: Dispatch<SetStateAction<{ signatureId: string; item?: DrainageInfo } | null>>; setDrainageModal: Dispatch<SetStateAction<{ signatureId: string; item?: DrainageInfo } | null>>;
setDrainageReport: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
setDrainageStatusTarget: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>; setDrainageStatusTarget: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
setExpandedSignatureId: Dispatch<SetStateAction<string>>; setExpandedSignatureId: Dispatch<SetStateAction<string>>;
setPage: Dispatch<SetStateAction<number>>; setPage: Dispatch<SetStateAction<number>>;
setSignatureSort: (sort: 'asc' | 'desc') => void;
setReportStatusTarget: Dispatch<SetStateAction<ClientSmsSignature | null>>; setReportStatusTarget: Dispatch<SetStateAction<ClientSmsSignature | null>>;
setSignatureModal: Dispatch<SetStateAction<ClientSmsSignature | 'new' | null>>; setSignatureModal: Dispatch<SetStateAction<ClientSmsSignature | 'new' | null>>;
setSignatureReport: Dispatch<SetStateAction<ClientSmsSignature | null>>; signatureSort: 'asc' | 'desc';
total: number; total: number;
totalPages: number; totalPages: number;
visibleSignatures: ClientSmsSignature[]; visibleSignatures: ClientSmsSignature[];
@@ -33,19 +33,37 @@ export function EnterpriseSignaturesTable({
loadData, loadData,
setDeleteTarget, setDeleteTarget,
setDrainageModal, setDrainageModal,
setDrainageReport,
setDrainageStatusTarget, setDrainageStatusTarget,
setExpandedSignatureId, setExpandedSignatureId,
setPage, setPage,
setSignatureSort,
setReportStatusTarget, setReportStatusTarget,
setSignatureModal, setSignatureModal,
setSignatureReport, signatureSort,
total, total,
totalPages, totalPages,
visibleSignatures, visibleSignatures,
}: EnterpriseSignaturesTableProps) { }: EnterpriseSignaturesTableProps) {
return ( return (
<div className="signature-list admin-enterprise-signature-list"> <div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
<span aria-hidden="true" />
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button aria-pressed={signatureSort === 'asc'} icon={<ArrowUp size={13} />} iconOnly onClick={() => setSignatureSort('asc')} size="sm" variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}></Button>
<Button aria-pressed={signatureSort === 'desc'} icon={<ArrowDown size={13} />} iconOnly onClick={() => setSignatureSort('desc')} size="sm" variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}></Button>
</span>
</span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => { {visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword const visibleDrainageLinks = appliedDrainageKeyword
@@ -59,16 +77,15 @@ export function EnterpriseSignaturesTable({
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button"> <button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />} {expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button> </button>
<div><span></span><strong>{signature.name}</strong></div> <div className="enterprise-signature-table__signature" data-label="签名"><strong>{formatSignatureName(signature.name)}</strong></div>
<div><span></span><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div> <div data-label="企业"><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div><span></span><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div> <div data-label="应用"><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div><span></span><AuditStatusTag status={signature.auditStatus} /></div> <div data-label="审核状态"><AuditStatusTag status={signature.auditStatus} /></div>
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div> <div data-label="移动"><CarrierReportCount summary={signature.carrierReportSummary?.mobile} /></div>
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div> <div data-label="联通"><CarrierReportCount summary={signature.carrierReportSummary?.unicom} /></div>
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div> <div data-label="电信"><CarrierReportCount summary={signature.carrierReportSummary?.telecom} /></div>
<div><span></span><strong>{payload.links.length} </strong></div> <div data-label="引流信息"><strong>{payload.links.length} </strong></div>
<div className="signature-actions"> <div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button> <Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button> <Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" /> <DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
@@ -82,9 +99,9 @@ export function EnterpriseSignaturesTable({
<div className="drainage-table__head"> <div className="drainage-table__head">
<span>url或号码</span> <span>url或号码</span>
<span></span> <span></span>
<CarrierTag carrier="mobile" /> <span></span>
<CarrierTag carrier="unicom" /> <span></span>
<CarrierTag carrier="telecom" /> <span></span>
<span></span> <span></span>
</div> </div>
{visibleDrainageLinks.map((item) => { {visibleDrainageLinks.map((item) => {
@@ -93,11 +110,10 @@ export function EnterpriseSignaturesTable({
<div className="drainage-table__row" key={item.id}> <div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>{item.url}</span> <span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} /> <AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportTag summary={summary?.mobile} /> <CarrierReportCount summary={summary?.mobile} />
<CarrierReportTag summary={summary?.unicom} /> <CarrierReportCount summary={summary?.unicom} />
<CarrierReportTag summary={summary?.telecom} /> <CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions"> <span className="drainage-row-actions">
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button> <Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button> <Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button> <Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button>
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi'; import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui'; import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
import { AuditStatusTag, carrierLabel, CarrierReportTag, formatDate } from './signature.helpers'; import { carrierLabel, CarrierReportTag } from './signature.helpers';
import type { DrainageInfo } from './signature.types'; import type { DrainageInfo } from './signature.types';
const reportStatusOptions = [ const reportStatusOptions = [
@@ -11,28 +11,6 @@ const reportStatusOptions = [
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' }, { label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
]; ];
export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情">
<div className="admin-report-detail">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.name}</strong></div>
<div><span></span><AuditStatusTag status={item.auditStatus} /></div>
<div><span></span><strong>{formatDate(item.updatedAt)}</strong></div>
</div>
<div className="admin-report-tabs">
<button className="admin-report-carrier--mobile active" type="button"><CarrierTag carrier="mobile" /><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
<button className="admin-report-carrier--unicom active" type="button"><CarrierTag carrier="unicom" /><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><CarrierTag carrier="telecom" /><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name} <CarrierTag carrier={target.carrier} /></span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</div>
</Modal>
);
}
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) { export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
const targets = item.reportTargets ?? []; const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const; const carriers = ['mobile', 'unicom', 'telecom'] as const;
@@ -56,24 +34,6 @@ export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: Cli
</Modal>; </Modal>;
} }
export function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
const summary = signature.drainageCarrierReportSummary?.[item.id];
const targets = signature.drainageReportTargets?.[item.id] ?? [];
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid">
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={summary?.mobile} /></div>
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={summary?.unicom} /></div>
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={summary?.telecom} /></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.channel.carrier)}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</Modal>
);
}
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) { export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const targets = signature.drainageReportTargets?.[item.id] ?? []; const targets = signature.drainageReportTargets?.[item.id] ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status]))); const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
@@ -15,6 +15,11 @@ export function carrierLabel(carrier?: string | null) {
return carrierLabels[carrier] ?? carrier; return carrierLabels[carrier] ?? carrier;
} }
export function formatSignatureName(name: string) {
const value = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
return value ? `${value}` : '—';
}
export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) { export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>; if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
let label = '未报备'; let label = '未报备';
@@ -27,6 +32,24 @@ export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>{summary.approved}/{summary.total}</small></span>; return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>{summary.approved}/{summary.total}</small></span>;
} }
export function CarrierReportCount({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) {
return <span className="carrier-report-count carrier-report-count--neutral" title="没有适用的目标通道"></span>;
}
const tone = summary.approved === summary.total
? 'success'
: ['failed', 'rejected'].includes(summary.status) ? 'danger' : 'warning';
return (
<span
aria-label={`已报备通过${summary.approved}个,总通道数${summary.total}`}
className={`carrier-report-count carrier-report-count--${tone}`}
title={`已报备通过 ${summary.approved} 个 / 总通道数 ${summary.total}`}
>
{summary.approved}/{summary.total}
</span>
);
}
export function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) { export function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone }; if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone }; if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };
+65 -27
View File
@@ -2007,18 +2007,18 @@
.admin-enterprise-signature-list .signature-summary { .admin-enterprise-signature-list .signature-summary {
gap: var(--space-2); gap: var(--space-2);
grid-template-columns: 22px minmax(104px, 0.85fr) minmax(132px, 1fr) minmax(104px, 0.8fr) repeat(5, minmax(76px, 0.55fr)) 184px; grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-width: 0; min-height: 64px;
min-width: 1160px;
padding-inline: var(--space-3); padding-inline: var(--space-3);
} }
.admin-enterprise-signature-list .signature-actions { .admin-enterprise-signature-list .signature-actions {
display: grid; display: flex;
gap: 6px; gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-self: end; justify-self: end;
min-width: 184px; min-width: 224px;
width: 184px; width: 224px;
} }
.admin-enterprise-signature-list .signature-actions .ui-button { .admin-enterprise-signature-list .signature-actions .ui-button {
@@ -2033,6 +2033,65 @@
font-weight: 400; font-weight: 400;
} }
.admin-enterprise-signature-list {
gap: 0;
overflow-x: auto;
}
.admin-enterprise-signature-list .signature-card {
border-radius: 0;
border-top: 0;
}
.admin-enterprise-signature-list .signature-card:last-of-type {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.enterprise-signature-table__head {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
color: var(--color-text-muted);
display: grid;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-height: 46px;
min-width: 1160px;
padding-inline: var(--space-3);
}
.enterprise-signature-table__sortable,
.enterprise-signature-table__sort-actions {
align-items: center;
display: flex;
}
.enterprise-signature-table__sortable { gap: 4px; }
.enterprise-signature-table__sort-actions { gap: 2px; }
.enterprise-signature-table__sort-actions .ui-button { min-height: 26px; min-width: 26px; padding: 4px; }
.enterprise-signature-table__signature { min-width: 0; }
.enterprise-signature-table__signature strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.carrier-report-count {
align-items: center;
border-radius: var(--radius-sm);
display: inline-flex;
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-semibold);
justify-content: center;
min-width: 40px;
padding: 4px 7px;
}
.carrier-report-count--success { background: var(--color-success-soft); color: var(--color-success); }
.carrier-report-count--warning { background: var(--color-warning-soft); color: var(--color-warning); }
.carrier-report-count--danger { background: var(--color-danger-soft); color: var(--color-danger); }
.carrier-report-count--neutral { background: var(--color-surface-subtle); color: var(--color-text-subtle); }
.signature-report-status { .signature-report-status {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
@@ -2233,13 +2292,6 @@
grid-row: 1 / span 2; grid-row: 1 / span 2;
} }
.admin-enterprise-signature-list .signature-summary {
grid-template-columns: 22px repeat(7, minmax(76px, 1fr)) 184px;
}
.admin-enterprise-signature-list .signature-summary > div:nth-of-type(3) {
display: none;
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {
@@ -2254,20 +2306,6 @@
grid-row: auto; grid-row: auto;
} }
.admin-enterprise-signature-list .signature-summary {
grid-template-columns: 22px repeat(2, minmax(0, 1fr));
}
.admin-enterprise-signature-list .signature-summary > div,
.admin-enterprise-signature-list .signature-summary > div:nth-of-type(3) {
display: grid;
}
.admin-enterprise-signature-list .signature-actions {
grid-column: 2 / -1;
justify-self: stretch;
width: 100%;
}
} }
.system-log-filters__actions { .system-log-filters__actions {