Compare commits
5
Commits
64bfb9ad3d
...
442dda711d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442dda711d | ||
|
|
1e05a643e5 | ||
|
|
839dba8d9b | ||
|
|
15a1f9d8ed | ||
|
|
ca1fc2847f |
@@ -17,7 +17,20 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Verify changed formatting and CSS ownership
|
- name: Resolve the complete change range
|
||||||
env:
|
env:
|
||||||
QUALITY_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || format('{0}^', github.sha) }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PUSH_BEFORE: ${{ github.event.before }}
|
||||||
|
run: |
|
||||||
|
if [ "$EVENT_NAME" = pull_request ]; then
|
||||||
|
base=$(git merge-base "$PR_BASE" HEAD)
|
||||||
|
elif [ "$PUSH_BEFORE" = 0000000000000000000000000000000000000000 ]; then
|
||||||
|
base=$(git hash-object -t tree /dev/null)
|
||||||
|
else
|
||||||
|
git cat-file -e "$PUSH_BEFORE^{commit}"
|
||||||
|
base=$PUSH_BEFORE
|
||||||
|
fi
|
||||||
|
echo "QUALITY_BASE_REF=$base" >> "$GITHUB_ENV"
|
||||||
|
- name: Verify changed formatting and CSS ownership
|
||||||
run: npm run format:check && npm run style:check && npm run css:verify
|
run: npm run format:check && npm run style:check && npm run css:verify
|
||||||
|
|||||||
+22
-3
@@ -12,15 +12,34 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"src/styles/*.css",
|
|
||||||
"src/styles/domains/*.css",
|
|
||||||
"src/apps/admin/channels/AdminChannelsPage.css",
|
"src/apps/admin/channels/AdminChannelsPage.css",
|
||||||
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
|
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
|
||||||
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
|
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
|
||||||
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
|
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
|
||||||
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
|
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
|
||||||
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
|
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
|
||||||
"src/apps/client/ClientUsersPage.css"
|
"src/apps/client/ClientUsersPage.css",
|
||||||
|
"src/styles/admin.css",
|
||||||
|
"src/styles/client.css",
|
||||||
|
"src/styles/components.css",
|
||||||
|
"src/styles/domains/01-operations-dashboard.css",
|
||||||
|
"src/styles/domains/02-client-sending.css",
|
||||||
|
"src/styles/domains/03-client-records.css",
|
||||||
|
"src/styles/domains/04-signatures.css",
|
||||||
|
"src/styles/domains/05-templates.css",
|
||||||
|
"src/styles/domains/06-auth-enterprise.css",
|
||||||
|
"src/styles/domains/07-admin-operations.css",
|
||||||
|
"src/styles/domains/08-reporting.css",
|
||||||
|
"src/styles/domains/09-channels.css",
|
||||||
|
"src/styles/domains/10-signature-quality.css",
|
||||||
|
"src/styles/domains/11-deliveries-reporting.css",
|
||||||
|
"src/styles/domains/12-admin-configuration.css",
|
||||||
|
"src/styles/domains/13-client-signatures.css",
|
||||||
|
"src/styles/domains/14-responsive-requeue.css",
|
||||||
|
"src/styles/domains/index.css",
|
||||||
|
"src/styles/reset.css",
|
||||||
|
"src/styles/shell.css",
|
||||||
|
"src/styles/tokens.css"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"at-rule-empty-line-before": null,
|
"at-rule-empty-line-before": null,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function createPrismaMock() {
|
|||||||
rechargeOrder: {
|
rechargeOrder: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
|
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
|
||||||
},
|
},
|
||||||
|
reportMaterialImportItem: { count: jest.fn().mockResolvedValue(0) },
|
||||||
smsTemplate: {
|
smsTemplate: {
|
||||||
count: jest.fn().mockResolvedValue(1),
|
count: jest.fn().mockResolvedValue(1),
|
||||||
},
|
},
|
||||||
@@ -542,6 +543,7 @@ describe('OperationsService', () => {
|
|||||||
enterpriseCertifications: 1,
|
enterpriseCertifications: 1,
|
||||||
smsAudits: 2,
|
smsAudits: 2,
|
||||||
signatures: 1,
|
signatures: 1,
|
||||||
|
signatureImports: 0,
|
||||||
drainageInfos: 0,
|
drainageInfos: 0,
|
||||||
templates: 1,
|
templates: 1,
|
||||||
total: 5,
|
total: 5,
|
||||||
@@ -729,6 +731,7 @@ describe('OperationsService', () => {
|
|||||||
templates: 1,
|
templates: 1,
|
||||||
signatures: 1,
|
signatures: 1,
|
||||||
drainageInfos: 0,
|
drainageInfos: 0,
|
||||||
|
signatureImports: 0,
|
||||||
total: 5,
|
total: 5,
|
||||||
});
|
});
|
||||||
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
|
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
|
||||||
@@ -740,6 +743,16 @@ describe('OperationsService', () => {
|
|||||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts pending signature import rows within the requested tenant', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.reportMaterialImportItem.count.mockResolvedValue(19);
|
||||||
|
const service = new OperationsService(prisma as never);
|
||||||
|
await expect(service.pendingAudits('tenant-1')).resolves.toMatchObject({ signatureImports: 19, total: 24 });
|
||||||
|
expect(prisma.reportMaterialImportItem.count).toHaveBeenCalledWith({
|
||||||
|
where: { reportType: 'signature', status: 'pending_review', batch: { tenantId: 'tenant-1' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid send quality dates', async () => {
|
it('rejects invalid send quality dates', async () => {
|
||||||
const service = new OperationsService(createPrismaMock() as never);
|
const service = new OperationsService(createPrismaMock() as never);
|
||||||
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
|
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
|
||||||
|
|||||||
@@ -439,13 +439,17 @@ pendingAudits(tenantId?: string) {
|
|||||||
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||||
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
||||||
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
|
this.prisma.reportMaterialImportItem.count({
|
||||||
|
where: { reportType: 'signature', status: 'pending_review', batch: { tenantId } },
|
||||||
|
}),
|
||||||
|
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits, signatureImports]) => ({
|
||||||
templates,
|
templates,
|
||||||
signatures,
|
signatures,
|
||||||
drainageInfos,
|
drainageInfos,
|
||||||
enterpriseCertifications,
|
enterpriseCertifications,
|
||||||
smsAudits,
|
smsAudits,
|
||||||
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
|
signatureImports,
|
||||||
|
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits + signatureImports,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 通道组编辑交互优化方案
|
||||||
|
|
||||||
|
日期:2026-09-05。适用通道组新建与修改,用户已授权实施、提交推送及双环境发布。沿用TC-ADMIN-004路由语义,不改变真实通道价格、发送或计费规则。
|
||||||
|
|
||||||
|
- 修正历史任意合法整数优先级回显;重复通道、归一化后重复省份及全国重复优先级明确报错,绝不删除或覆盖原行。弹窗确认只更新草稿。
|
||||||
|
- 全国顺序支持上移、下移和拖拽;移动仅交换已有优先级槽位(如10/20),保留数值集合;新增通道建议下一个空闲优先级。提供明确顺序预览及撤销最近一次排序。
|
||||||
|
- 通道选择支持名称/编号/地区搜索,展示成本及真实连接状态,禁用项说明原因;活动但暂时断连的通道仍可配置,保留历史停用通道且不误称连接正常。
|
||||||
|
- 保存前展示新增、删除、调整以及基础设置变更摘要;未保存离开、刷新或后退需提示;失败保留输入,新建第二步失败保存已创建ID以便重试。请求沿用真实API,已有组status/description和成员weight/isBackup保持,不改数据模型。
|
||||||
|
- 验收:纯函数冲突/排序/摘要测试,组件交互及前端回归、TypeScript、生产构建、CSS门禁;真实API的三尺寸浏览器查询、选择、排序、撤销及退出。禁止在验收中保存既有真实通道组或发送短信。
|
||||||
|
- 发布:测试先行;预生产已确认发布完整最新版本,包含尚未发布的WPS异步解析、1项兼容migration及新解析Worker。独立恢复点与存储保护核验后再切换,不执行会改写无关配置的全量初始化脚本。
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
- `npm run format:check`:增量Prettier现同时覆盖CSS。
|
- `npm run format:check`:增量Prettier现同时覆盖CSS。
|
||||||
- `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。
|
- `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。
|
||||||
- `npm run css:verify`:验证`global.css`不得重建、固定导入顺序、AST基线、选择器顺序、`!important`增长、宽泛业务标签规则和import所有权,并执行允许/拒绝样例测试。
|
- `npm run css:verify`:验证`global.css`不得重建、固定导入顺序、AST基线、选择器顺序、`!important`增长、宽泛业务标签规则和import所有权,并执行允许/拒绝样例测试。
|
||||||
- `.github/workflows/css-quality.yml`:PR和main推送均以完整提交范围运行格式、Stylelint和CSS治理门禁。
|
- `.github/workflows/css-quality.yml`:PR使用目标提交与HEAD的merge-base,main推送使用事件before提交;首次推送使用空树,覆盖一次推送中的全部提交。
|
||||||
- Git归档或部署候选目录没有`.git`时,门禁仍执行当前树的所有权、AST、选择器顺序和例外检查,仅跳过无法取得基线的增量文件比较。
|
- Git归档或部署候选目录没有`.git`时,门禁仍执行当前树的所有权、AST、选择器顺序和例外检查,仅跳过无法取得基线的增量文件比较。
|
||||||
|
|
||||||
## 维护边界
|
## 维护边界
|
||||||
@@ -46,3 +46,19 @@
|
|||||||
- 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。
|
- 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。
|
||||||
- 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。
|
- 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。
|
||||||
- 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主CSS产物或关键计算样式等价。
|
- 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主CSS产物或关键计算样式等价。
|
||||||
|
|
||||||
|
## 2026-09-05 门禁复核与修复
|
||||||
|
|
||||||
|
首次迁移完成的是原序分文件,14个模块仍由全局入口加载,响应式尾段仍跨业务域。页面根节点隔离和按所有者进一步收拢是后续工作,不能把文件删除或既有门禁通过等同于完整架构治理完成。本次只修复门禁,不移动、删除或改写应用CSS,不改变业务API、数据库和短信链路。
|
||||||
|
|
||||||
|
复核发现原Stylelint配置使用目录通配符,原所有权检查只搜索文件名,AST计数未覆盖声明值和媒体条件,main推送只比较最后一个提交。现改为:
|
||||||
|
|
||||||
|
- `tools/quality/css-ownership.json`逐文件登记真实import所有者,Stylelint兼容范围必须与清单中的精确路径一致。新文件不会因进入`styles/`或`domains/`自动继承兼容配置。
|
||||||
|
- TypeScript AST解析静态import/export和字面量动态import,PostCSS解析CSS import;解析实际路径并从`src/main.tsx`检查可达性。注释、文件名字符串、同名异目录文件、孤立循环或未被入口引用的所有者不能代替真实引用。当前仅支持仓库使用的相对路径和`@/`别名;新加载机制须同步扩展解析器和拒绝样例。
|
||||||
|
- 新CSS必须登记`roots`,根类必须出现在直接TSX所有者的`className`中;每个选择器首个复合选择器须含正向根类,禁止用`:not`、`:has`或根节点同级选择器冒充作用域。根类后的后代和直接子节点可用;仅通过`:is/:where`提供根类的写法暂不接受。该检查证明静态约束,无法代替实际DOM层级、Portal、动态类名和三尺寸浏览器验收。
|
||||||
|
- 历史文件登记包含声明值、声明顺序、`!important`和媒体/其他at-rule条件的AST摘要,并明确兼容原因、清理条件。注释和节点外格式不影响摘要,CRLF/LF统一;值内部换行等变化可能要求人工确认。新文件不允许通过随手补历史摘要绕过作用域;历史摘要变更必须附设计或等价证据,由代码审查确认。
|
||||||
|
- 已有、具备页面根类的`ReportMaterialImportModal.css`直接执行新规则;其他已登记历史文件保持现状,不在本次门禁修复中扩张视觉改造范围。
|
||||||
|
- 无`.git`的部署归档也执行完整清单、真实引用、根类、声明摘要、固定入口顺序及例外范围检查;仅跳过Git增量比较。
|
||||||
|
- 拒绝样例覆盖新目录文件的实际Stylelint结果、假import、不可达与缺失路径、错误所有者、未落入className的根类、无根标签/状态/同级逃逸、声明和断点篡改、未登记文件、归档模式以及CRLF/LF兼容。
|
||||||
|
|
||||||
|
后续修改已有CSS时先确认所有者:页面私有规则迁至页面目录,公共规则按消费者确定组件归属;保持原效果的迁移提供产物或计算样式等价证据,有意视觉调整提供设计依据及真实页面验收后再更新摘要。不得为了门禁变绿批量重算全部摘要。330次重复选择器出现包含有效响应式覆盖,不按重复次数直接删除。
|
||||||
|
|||||||
@@ -2186,3 +2186,16 @@
|
|||||||
- 引流信息的业务主字段统一为“引流 URL 或号码”,支持带 `http/https` 协议或不带协议的域名/URL、手机号码和固定电话号码。客户端新增/修改弹窗不再显示或要求“名称”,原“访问地址”统一改名为“引流 URL 或号码”。
|
- 引流信息的业务主字段统一为“引流 URL 或号码”,支持带 `http/https` 协议或不带协议的域名/URL、手机号码和固定电话号码。客户端新增/修改弹窗不再显示或要求“名称”,原“访问地址”统一改名为“引流 URL 或号码”。
|
||||||
- API 和服务端必须真实接受上述三种内容并拒绝无效任意文本。数据库既有 `SmsDrainageInfo.siteName` 仅作为兼容列保留,新建或修改时由后端同步写入规范化后的 URL/号码;客户端不得继续提交或依赖独立名称。
|
- API 和服务端必须真实接受上述三种内容并拒绝无效任意文本。数据库既有 `SmsDrainageInfo.siteName` 仅作为兼容列保留,新建或修改时由后端同步写入规范化后的 URL/号码;客户端不得继续提交或依赖独立名称。
|
||||||
- 运营端单条审核列表、审核详情、导入审核、报备任务和报备记录统一只展示 URL/号码,不再把兼容列作为独立名称。批量导入映射和官方模板去掉“站点名称”必填列,只要求所属短信签名及“引流 URL 或号码”。
|
- 运营端单条审核列表、审核详情、导入审核、报备任务和报备记录统一只展示 URL/号码,不再把兼容列作为独立名称。批量导入映射和官方模板去掉“站点名称”必填列,只要求所属短信签名及“引流 URL 或号码”。
|
||||||
|
|
||||||
|
### 2026-09-05 签名导入待审通知与通道成本展示
|
||||||
|
|
||||||
|
- 运营端右上角新增“签名导入待审”,按签名导入资料中 pending_review 的明细条数计入待审核总数,独立于正式签名待审;点击进入签名审核的导入页签。统计遵守批次tenantId范围,invalid/approved/rejected明细不计入;导入提交及审核成功后立即刷新,不预先改计数。
|
||||||
|
- 短信通道组新建/编辑的省网、全国列表展示成本价格(元/条),使用通道现有unitPrice及10000单位/元换算,保留4位小数;零成本显示0.0000,缺失通道/价格显示“—”。本次不改变通道选择、路由优先级和保存行为。
|
||||||
|
|
||||||
|
### 2026-09-05 通道组编辑交互优化
|
||||||
|
|
||||||
|
- 新建和修改通道组均使用草稿编辑:添加、编辑、删除、排序只改变当前页面草稿,最终确认保存时才调用真实接口。保存前展示基础设置、成员新增/删除和顺序调整摘要;保存失败保留输入。新建时若组已创建但成员保存失败,重试不得重复创建通道组。
|
||||||
|
- 全国通道支持上移、下移和拖拽,移动时交换已有合法优先级槽位并保留历史优先级集合;支持撤销最近一次排序。历史任意 PostgreSQL `integer` 范围内优先级均可回显和编辑,新增项使用下一个未占用优先级。
|
||||||
|
- 重复通道、归一化后重复省份及全国重复优先级必须明确阻止,不得静默覆盖已有成员。保存时保留已有组的状态、描述以及成员权重和主备属性。
|
||||||
|
- 通道选择支持按名称、编号和地区搜索,直接展示成本价格、适用地区、运营商和真实连接回写。已删除、运营商不匹配、地区不匹配或已在本组配置的通道不可选择并说明原因;活动但暂时断连的通道允许配置。
|
||||||
|
- 页面加载失败时禁止编辑和保存;存在未保存修改时,返回、路由切换、浏览器后退或刷新须提示。桌面、常用笔记本和 390px 窄屏均应可完成上述交互,且不得产生页面级横向溢出。
|
||||||
|
|||||||
@@ -5063,6 +5063,12 @@ npm run verify:phase8
|
|||||||
| TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入Prettier;Stylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 |
|
| TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入Prettier;Stylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 |
|
||||||
| TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 |
|
| TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 |
|
||||||
| TC-CSS-MOD-006 | 在1600×1000、1366×768和390×844检查核心页面 | 页面无新增横向溢出、遮挡、错位或层级问题,窄屏交互仍可访问 |
|
| TC-CSS-MOD-006 | 在1600×1000、1366×768和390×844检查核心页面 | 页面无新增横向溢出、遮挡、错位或层级问题,窄屏交互仍可访问 |
|
||||||
|
| TC-CSS-MOD-007 | 在styles和domains目录新增未登记CSS,或给Stylelint兼容清单添加通配符 | 新文件保持严格Stylelint规则;所有权门禁拒绝未登记文件及兼容范围扩张 |
|
||||||
|
| TC-CSS-MOD-008 | 仅在注释/字符串中写CSS文件名,引用同名错误路径,或从不可达TSX导入CSS | 均被拒绝;正确相对路径、@/别名、静态/字面量动态import和入口可达关系通过 |
|
||||||
|
| TC-CSS-MOD-009 | 新CSS登记根类后使用无关类、body button、* button、h1、独立selected、:has/:not或根类同级选择器 | 门禁拒绝;直接TSX所有者包含根className且选择器约束在根本身、后代或直接子节点时通过 |
|
||||||
|
| TC-CSS-MOD-010 | 不改变规则和声明计数,仅修改历史CSS颜色、断点、声明顺序或important | AST内容摘要检查失败;单纯CRLF/LF转换通过,避免Windows和Linux归档差异 |
|
||||||
|
| TC-CSS-MOD-011 | 在无.git归档执行门禁,并构造错误import、声明变化、新important和未登记CSS | 完整静态检查仍拒绝违规,不能因为跳过Git增量比较而放行 |
|
||||||
|
| TC-CSS-MOD-012 | 一次推送包含多个提交,违规或格式问题位于非最后提交 | CI使用push事件before至HEAD完整范围;PR使用merge-base;首次推送使用空树 |
|
||||||
|
|
||||||
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
|
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
|
||||||
|
|
||||||
@@ -5087,3 +5093,25 @@ npm run verify:phase8
|
|||||||
| TC-REPORT-MATERIAL-DETAIL-001 | 查看包含当前图片字段、未删除历史字段及旧图片引用的报备资料 | 当前字段展示名称、代码、导出名及图片预览;未删除历史字段继续展示且同时显示字段名称和代码;图片可内联查看并保留下载入口,缺失内容显示明确占位 |
|
| TC-REPORT-MATERIAL-DETAIL-001 | 查看包含当前图片字段、未删除历史字段及旧图片引用的报备资料 | 当前字段展示名称、代码、导出名及图片预览;未删除历史字段继续展示且同时显示字段名称和代码;图片可内联查看并保留下载入口,缺失内容显示明确占位 |
|
||||||
| TC-REPORT-CHANNEL-IDENTITY-001 | 打开短信通道管理的报备详情 | 页面同时明确展示“通道名称”和“通道编号”,列表、筛选、状态修改和窄屏布局不受影响 |
|
| TC-REPORT-CHANNEL-IDENTITY-001 | 打开短信通道管理的报备详情 | 页面同时明确展示“通道名称”和“通道编号”,列表、筛选、状态修改和窄屏布局不受影响 |
|
||||||
| TC-DASHBOARD-METRIC-ORDER-001 | 打开运营看板并按从左到右、从上到下读取指标 | 顺序为发送总量、消息分片数、总体成功率、到达率、活跃签名、消费金额、返还金额、计收金额、利润、利润率;所有数值继续来自真实API口径 |
|
| TC-DASHBOARD-METRIC-ORDER-001 | 打开运营看板并按从左到右、从上到下读取指标 | 顺序为发送总量、消息分片数、总体成功率、到达率、活跃签名、消费金额、返还金额、计收金额、利润、利润率;所有数值继续来自真实API口径 |
|
||||||
|
|
||||||
|
## 2026-09-05 签名导入通知与通道组成本回归
|
||||||
|
|
||||||
|
| 编号 | 操作 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-IMPORT-NOTICE-001 | 提交签名批量导入,打开右上角待审核任务并点击签名导入待审 | 导入成功后即时重新请求真实统计;pending_review明细逐条计数,跳转签名审核tab=import;失败不发送成功刷新事件 |
|
||||||
|
| TC-IMPORT-NOTICE-002 | 部分通过/驳回导入资料,并按租户查询统计 | 完成明细不计数,剩余待审保留,成功审核即时刷新;租户隔离,空租户结果0,不把无效行算作任务 |
|
||||||
|
| TC-CHANNEL-COST-001 | 新建/编辑通道组,查看省网和全国列表,分别使用非零、零及缺失价格 | 成本价格按现有单位换算为元/条并保留4位;零显示0.0000,缺失显示—;三尺寸表格内部滚动,无整页溢出;不修改价格和路由 |
|
||||||
|
|
||||||
|
上述通知口径增加签名导入这一类审核任务,替代TC-ADMIN-NOTICE-0715-09中“仅五类”的数量限定;下游投递告警仍不计入待审核任务。
|
||||||
|
|
||||||
|
## TC-CHANNEL-GROUP-EDITOR-20260905 通道组编辑交互
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-001 | 打开含历史优先级10/20、权重和主备设置的通道组并上移、下移或拖拽全国通道 | 页面交换现有优先级槽位、顺序预览同步变化;撤销恢复原顺序,权重和主备属性保持 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-002 | 新增或编辑时选择重复通道、重复省份或重复全国优先级 | 页面明确显示冲突并保持原有行,不静默替换、删除或覆盖 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-003 | 在通道选择弹窗按名称、编号或地区搜索 | 结果来自真实通道API并展示成本、地区、运营商和连接回写;不可选项显示具体原因,活动但暂时断连的通道仍可选择 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-004 | 修改基础设置、成员或顺序后点击保存 | 保存前确认弹窗展示新增、删除、调整及基础设置摘要;取消不调用写接口,确认后才按草稿统一保存 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-005 | 新建通道组时创建成功但成员保存失败,随后重试 | 页面保留草稿和已创建组ID,只重试后续保存,不重复创建通道组 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-006 | 加载接口失败,或有未保存修改时返回、跨路由、后退和刷新 | 加载失败时编辑与保存不可用并显示真实错误;未保存离开均提示,取消后保留草稿 |
|
||||||
|
| TC-CHANNEL-GROUP-EDITOR-007 | 在1600×1000、1366×768和390×844执行排序、撤销、搜索、冲突校验和保存预览 | 三尺寸均可操作且无页面级横向溢出;控制台无新增错误;验收不保存真实通道组、不修改通道配置、不发送短信 |
|
||||||
|
|||||||
@@ -4530,3 +4530,55 @@ git diff --check
|
|||||||
- 三条短信相关Redis Stream发布前后逐项一致:`gateway.submit.commands`为`last-delivered-id=1787806802603-0 / entries-read=130918`,`gateway.submit.results`为`1787806917609-0 / 191696`,`gateway.protocol.logs`为`1787806802967-0 / 40472`;全部保持`pending=0 / lag=0`。本轮没有发送、补发、重投或重新入队短信,也没有修改余额、通道、客户或签名业务资料。
|
- 三条短信相关Redis Stream发布前后逐项一致:`gateway.submit.commands`为`last-delivered-id=1787806802603-0 / entries-read=130918`,`gateway.submit.results`为`1787806917609-0 / 191696`,`gateway.protocol.logs`为`1787806802967-0 / 40472`;全部保持`pending=0 / lag=0`。本轮没有发送、补发、重投或重新入队短信,也没有修改余额、通道、客户或签名业务资料。
|
||||||
- 测试环境真实Chrome回归使用真实API和PostgreSQL数据:运营端13个核心路由、客户端10个核心路由分别在1600×1000、1366×768和390×844检查,共69个页面状态均已登录、非空、无框架错误层及页面级横向溢出;控制台warning/error为0,失败API或静态资源响应为0。外部入口API健康HTTP 200,实际下载CSS`index-CkSy6WDi.css`和JS`index-BLtRK0rD.js`的SHA-256与服务器产物一致;CSS摘要仍为迁移前后的`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`。
|
- 测试环境真实Chrome回归使用真实API和PostgreSQL数据:运营端13个核心路由、客户端10个核心路由分别在1600×1000、1366×768和390×844检查,共69个页面状态均已登录、非空、无框架错误层及页面级横向溢出;控制台warning/error为0,失败API或静态资源响应为0。外部入口API健康HTTP 200,实际下载CSS`index-CkSy6WDi.css`和JS`index-BLtRK0rD.js`的SHA-256与服务器产物一致;CSS摘要仍为迁移前后的`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`。
|
||||||
- 两个临时验收账号已精确删除,数据库剩余数为0,并删除其5个Redis会话;本地认证状态文件和测试机临时发布包也已清理。浏览器自动化最初复用仅含运营端会话的状态文件时,客户端正确返回401并跳转登录;修正验收脚本为客户端真实登录后重新执行,最终30个客户端页面及全部网络检查通过,该测试夹具问题不属于平台回归缺陷。预生产环境未访问或修改。
|
- 两个临时验收账号已精确删除,数据库剩余数为0,并删除其5个Redis会话;本地认证状态文件和测试机临时发布包也已清理。浏览器自动化最初复用仅含运营端会话的状态文件时,客户端正确返回401并跳转登录;修正验收脚本为客户端真实登录后重新执行,最终30个客户端页面及全部网络检查通过,该测试夹具问题不属于平台回归缺陷。预生产环境未访问或修改。
|
||||||
|
|
||||||
|
## 2026-09-05 CSS门禁缺口修复(本地验证与测试发布准备)
|
||||||
|
|
||||||
|
- 用户授权修复、代码提交和测试环境发布,未授权推送或预生产访问。开始核验main/HEAD及origin/main为`64bfb9a`;工作区已有7份规范修改、未跟踪设计开发规范及报备补救方案,均保护不动,测试进度仅提交本轮精确新增段落。
|
||||||
|
- 根因:Stylelint历史兼容使用目录通配符,所有权仅搜索文件名,宽泛选择器识别不完整,AST计数不检查声明值/媒体条件,CI的main推送仅比较最后一个提交。详见`css-modularization-result-20260905.md`新增复核章节,用例`TC-CSS-MOD-007`至`012`。
|
||||||
|
- 新增逐文件所有权和历史AST内容摘要、真实TS/CSS import解析与入口可达性、新文件根className/选择器约束;Stylelint改为精确路径,CI覆盖整次推送及PR merge-base,归档执行完整静态检查。直接声明当前已锁定的PostCSS及selector-parser开发依赖,未升级版本;应用TSX、全部CSS、入口顺序、API、数据库和短信链路均未修改。
|
||||||
|
- 本地验证:门禁15项、前端13套65项、API55套619项通过;前端TypeScript/Vite和API正式构建通过,format/style/css、增量ESLint、结构、安全、部署契约和包体积门禁通过。主CSS仍为`index-CkSy6WDi.css`,SHA-256为`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`,入口gzip 107.85KiB。构建保留既有Chart超过500kB及耗时提示;API测试中的异常日志来自测试替身的失败场景,不是线上短信操作。
|
||||||
|
- 2026-09-05测试机SSH实时标记为`64bfb9ad3d6a4e6f57770839172ea50eee984a18`,API/Gateway正常,三条短信Stream均pending=0/lag=0。测试机实际使用系统盘,不套用预生产UUID/迁盘规则;发布恢复点`/opt/cmpp-platform-backups/css-gates-20260905T013053Z-before-64bfb9a`约451MiB,包含PostgreSQL custom dump、完整运行目录、配置、服务/Stream/挂载与资源摘要基线;pg_restore目录、tar可读性及三项SHA均通过。
|
||||||
|
- Browser技能不在当前会话,按前端测试技能使用现有Playwright/Edge。测试环境真实运营端和客户端登录页在1600×1000、1366×768、390×844完成首次进入、刷新、跨入口导航,共6个状态,页面非空、无横向溢出,控制台/页面异常及失败HTTP响应均为0。证据位于本机临时目录`cmpp-css-gates-20260905/browser-smoke.json`及截图。未提交登录、未创建账号,登录后业务交互未重跑,不用登录页或单元测试冒充真实业务验收。
|
||||||
|
- 14个模块仍是历史全局兼容分区,进一步按业务收拢响应式和页面隔离未在本轮实施;静态根类检查不能证明实际DOM/Portal层级。发布采用明确提交归档,候选门禁及资源一致性检查通过后仅更新治理文件和文档,不运行全量初始化/迁移/重启脚本。测试发布结果另行追加。
|
||||||
|
|
||||||
|
## 2026-09-05 CSS门禁缺口修复(测试环境发布完成)
|
||||||
|
|
||||||
|
- 功能提交`ca1fc2847fc90b85861e63fd09a3a46cf1d4b4be`已本地提交并发布测试环境`100.93.204.60`;没有推送远端,没有访问预生产。11个提交文件仅包含治理配置、脚本、清单、直接开发依赖声明及本轮文档;其他会话7份规范修改及2份未跟踪文档完整保留,测试进度使用Git blob精确暂存本轮段落。
|
||||||
|
- 精确提交归档SHA-256为`b9747c3ec71d433b9bfe8deb651992ad34a62847d1683353480f566cc8adfb5f`,本机、测试机及恢复点副本一致。候选目录复用独立复制的现有node_modules,验证锁文件实际包图完全一致;无.git场景的Stylelint、完整CSS门禁15项、TypeScript/Vite生产构建和包体积检查全部通过。
|
||||||
|
- 发布前旧源文件和候选源文件分别与原提交及功能提交核对。Windows归档带CRLF,统一行尾后11项内容全部匹配;同时保存原始字节摘要并在写入前再次校验,未把真实额外修改当换行差异覆盖。候选与线上96个前端文件逐文件SHA一致;首次比较清单漏列3个Logo文件造成停止,补齐完整资源集合后通过,没有改动资源绕过比较。
|
||||||
|
- 恢复点沿用本轮独立的`/opt/cmpp-platform-backups/css-gates-20260905T013053Z-before-64bfb9a`,切换前再次验证数据库、运行目录及配置SHA。仅按提交清单逐文件替换治理文件和文档,实际运行树再次通过CSS及Stylelint门禁后更新部署标记;无服务重启、数据库迁移、Nginx/systemd改写或业务配置变更。
|
||||||
|
- 发布后12项服务ActiveState/MainPID/NRestarts与发布前完全一致;API、独立Callback、Gateway及MinIO健康正常。PostgreSQL只读查询确认96项已完成migration、未完成0项;三条短信Stream组状态逐字一致,pending/lag均为0。发布窗口warning及以上journal为0行,相对恢复归档日志末尾的新增应用错误匹配为0;所有既有dist文件摘要校验通过。
|
||||||
|
- 工作站从真实HTTP入口回读index.html、主CSS和主JS:CSS/JS与本地构建字节摘要一致;index.html与服务器候选字节一致,相对本机构建仅有CR字符和末尾空白差异,去除CR并裁剪末尾空白后全文相同。发布后Playwright/Edge再次检查两端登录页三尺寸的首次进入、刷新和跨入口导航,共6个状态均非空、无横向溢出、无控制台/页面异常和失败HTTP响应。仅做未登录入口回归,未创建账号或绕过验证码,登录后业务交互仍未重跑;未以此冒充完整业务验收。
|
||||||
|
- 发布证据包含恢复点中的`release-manifest-verified.json`、候选及实际门禁日志、完整资源摘要、服务/Stream前后快照及journal,以及本机临时目录中的`browser-smoke.json`、6张截图和`http-after.json`。本段验收文档单独本地提交并同步测试机;不推送远端,不发送、补发、重投或重新入队短信,不修改余额、通道或客户配置。
|
||||||
|
|
||||||
|
## 2026-09-05 签名导入待审通知与通道组成本(本地提交)
|
||||||
|
|
||||||
|
- 当前main基于15a1f9d,已有文档改动和未跟踪方案保留。本次仅授权修改并本地提交,不推送、不部署。需求见first-version-development-requirements.md末节,用例TC-IMPORT-NOTICE-001/002、TC-CHANNEL-COST-001。
|
||||||
|
- 根因:导入资料先进入ReportMaterialImportItem,原pendingAudits仅统计正式签名等5类表,漏计导入待审;提交导入和审核完成未触发顶部刷新。真实测试库signature pending_review=19、approved=20、invalid=27、rejected=19,正式签名pending=0,解释原通知缺失。
|
||||||
|
- 修复:新增signatureImports可兼容旧响应的计数字段,按reportType=signature/status=pending_review并经batch.tenantId限定计数,计入total;右上角新增“签名导入待审”直达/admin/signatures?tab=import。导入及审核成功时触发既有刷新事件;不改正式审核流程、不重复创建任务。通道组省网/全国列表展示unitPrice/10000、四位小数成本,缺失显示—,零显示0.0000;无CSS、数据库结构、队列、路由或价格写入改动。
|
||||||
|
- 自动验证:前端13套65项通过,补充导入成功刷新事件断言后该2项定向测试通过;API全量55套执行,新增测试后619项通过、1项因既有dashboard响应断言未包含新字段而失败,更新该断言后operations定向32项全部通过(累计620项通过)。前端TypeScript/Vite和API构建通过;Stylelint、CSS治理15项通过。Chart既有包体告警仍在。
|
||||||
|
- 质量门禁:format:check和lint已执行,未宣称全绿。HEAD与本轮逐文件ESLint对照确认dashboard.queries.ts原有39条unused等错误、通道组表单原有1条set-state-in-effect错误,另有2条既有依赖警告,本轮相同。Prettier基线证明API查询、API测试、通道组表单3文件原已不通过;AdminLayout新增格式已修正。为保持最小范围未夹带整文件格式化或历史Hooks重构。git diff --check通过。
|
||||||
|
- 真实验证:从候选构建提取实际pendingAudits函数,在测试机用真实Prisma/PostgreSQL只读执行;全平台和所选QA租户signatureImports=19/total=21,不存在租户全0。没有替换服务器应用文件或启动新业务服务。
|
||||||
|
- 浏览器:本地生产预览http://127.0.0.1:4173代理测试环境真实API,正常算术验证码登录临时运营账号。1600×1000、1366×768、390×844进入及刷新通道组编辑页,两个成本列存在、真实零成本显示0.0000、无整页溢出;右上角新入口在三尺寸均进入导入审核页签;pageerror和console error均0。Browser技能不可用,按前端测试技能用Playwright/Edge。证据在本机临时目录cmpp-notice-cost-qa/browser.json、截图和lint-baseline.json。
|
||||||
|
- 验收边界:测试机API仍是15a1f9d,故候选页面上的新计数按旧响应兼容显示0;新统计用真实数据库函数单独验证,未把分段证据冒充新版本端到端部署验收。未执行新的文件上传/审核写入、通道组保存、非零/缺失价格真实夹具及失败注入;不发送短信,不改余额或通道配置。临时用户精确删除后0残留,logout后无剩余Redis会话,本机凭据/storageState及服务器凭据已清理。
|
||||||
|
- 后续交互建议(未实施):现有历史优先级10/20与编辑下拉1—5不兼容;同优先级/同省份会过滤原行。建议先修正历史值回显和冲突处理,再加入全国路由上下移动/拖拽、明确顺序预览、可搜索通道选择及未保存提示;与本次成本展示分开实施。
|
||||||
|
- 提交前补充:签名审核页页签由URL直接派生,修正已停留在该页时通知只改query而未切换页签的问题;新增同路由query切换回归测试通过。该文件另有HEAD已存在的1条preserve-manual-memoization错误和1条依赖警告,未扩展整改。
|
||||||
|
- 最终前端全量14套66项通过,最终TypeScript/Vite生产构建通过;本轮共13个目标文件精确暂存,已有文档段落保持未暂存。
|
||||||
|
|
||||||
|
## 2026-09-05 通道组编辑交互优化(发布前验证)
|
||||||
|
|
||||||
|
- 按用户确认方案完成通道组新建/修改交互:草稿编辑、历史优先级兼容、显式冲突校验、全国通道上移/下移/拖拽与撤销、顺序预览、可搜索通道选择、成本/地区/运营商/真实连接回写、不可选原因、保存摘要和未保存离开保护。保存继续使用真实API,保留既有组状态/描述及成员权重/主备属性;新建部分失败后以已创建ID重试,避免重复创建。
|
||||||
|
- 新增页面及弹窗私有CSS,由各自TSX直接导入并登记所有权;未重建global.css、未向14个历史兼容模块追加规则、未增加`!important`或宽泛标签选择器,也未改变domains/index.css顺序。
|
||||||
|
- 自动验证:通道组纯函数、选择弹窗和整页交互定向3套24项通过;前端全量17套90项、API全量55套620项通过。format:check、Stylelint、CSS治理15项、TypeScript/Vite生产构建、API构建、安全、部署契约和包体积门禁通过;既有Chart包体提示不影响构建。
|
||||||
|
- 候选页面通过测试环境真实API/PostgreSQL只读验收,所有业务写请求由浏览器路由拦截。1600×1000、1366×768、390×844均完成排序/撤销、重复优先级拦截、搜索空态、保存前预览和离开取消;无页面级横向溢出,pageerror和console error为0。验收未保存真实通道组、未修改成本或通道配置、未发送/补发/重投/重新入队短信。
|
||||||
|
- 测试与预生产发布前已分别建立独立恢复点并核验运行版本、服务、数据库迁移及存储保护。预生产从旧版本发布完整最新版本,预计新增1项向后兼容报备解析迁移及独立解析Worker;实际提交、推送、双环境切换和发布后真实验收结果在完成后追加。
|
||||||
|
|
||||||
|
## 2026-09-05 通道组编辑及完整最新版本双环境发布
|
||||||
|
|
||||||
|
- 功能提交`1e05a643e5398973bd32953fc95ed5c3aa2b0daf`包含通道组交互优化,并连同此前未发布的WPS异步解析、签名导入待审通知、通道组成本展示和CSS治理完整版本发布。精确Git归档SHA-256为`f600dc90cc69264e8ee3d351b6559cea507d7f4d76bd0edc0f96fc0d3268d138`,前端归档为`098f12c124eb4979d2888f4002bee25016c6f6ed9e19d247ecf2114ddf98e888`;工作站、测试机和预生产逐一一致。远端Git首次推送因现有认证失效未完成,服务器使用本地精确提交归档发布,不把部署误记为推送成功。
|
||||||
|
- 测试环境从`15a1f9d8eddd4f7233987fbf676a91f4e0bfab08`切换,恢复点`/opt/cmpp-platform-backups/channel-groups-20260905T1430`的数据库、运行目录和配置摘要均通过。96项迁移已齐全,无数据库迁移;API和报备解析Worker重启,Gateway未重启。API、Gateway、Worker、Nginx、PostgreSQL、Redis和MinIO均active,应用进程`NRestarts=0/Result=success`,发布窗口warning及以上journal为空。
|
||||||
|
- 测试环境短信记录发布前后均119509,三条短信Stream的XINFO GROUPS逐字一致。真实API登录后,签名导入待审为19;1600×1000、1366×768和390×844完成通知跳转、排序/撤销、冲突校验、搜索空态及保存预览,均无页面级横向溢出、控制台错误或失败响应。所有业务写请求均被浏览器拦截;临时管理员删除后0残留,精确删除3个Redis会话并保留操作审计。
|
||||||
|
- 预生产从`aaf96db2d018cfcea79b6cdbf553cee9cb982fa2`切换。发布前再次验证独立恢复点、PostgreSQL/Redis/MinIO数据盘UUID和备份盘保护;执行唯一待处理的`20260904090000_report_material_async_analysis`向后兼容迁移,迁移总数由95变为96。新增独立`cmpp-report-material-worker`,数据库池上限4;API重启,Gateway及其他Worker未重启。
|
||||||
|
- 预生产API、Gateway、新Worker、Nginx、PostgreSQL、Redis和MinIO均active,三项应用进程`NRestarts=0/Result=success`;短信记录发布前后均86692,通道连接状态仍为connected 6、disconnected 7、failed 5,报备批次状态计数不变,三条短信Stream逐字一致,发布窗口warning及以上journal为空。数据盘和备份盘保护在发布后再次通过。
|
||||||
|
- 公网`https://sms.lisglo.com/api/health`返回ok;实际下载的主JS`index-Cf0GV6WF.js`和主CSS`index-CkSy6WDi.css`与本地候选SHA-256一致。三尺寸登录页首次进入及刷新均无整页溢出、控制台错误或失败响应。服务器两份历史管理员凭据对应账号当前均为deleted,正常登录返回401;未重置账号或新增预生产管理员,因此登录后通道组交互由同提交的测试环境真实验收和预生产API/数据库/资源证据覆盖,不宣称预生产登录后页面已验收。
|
||||||
|
- 两套环境均保留原运行目录及独立恢复点;未执行全量初始化脚本,未修改余额、通道、客户配置、fstab、UUID、数据盘挂载、存储保护脚本或systemd存储drop-in。未发送、补发、重投或重新入队短信。
|
||||||
|
|||||||
Generated
+2
@@ -34,6 +34,8 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
|
"postcss": "8.5.23",
|
||||||
|
"postcss-selector-parser": "7.1.6",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"stylelint": "^16.23.1",
|
"stylelint": "^16.23.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
|
"postcss": "8.5.23",
|
||||||
|
"postcss-selector-parser": "7.1.6",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"stylelint": "^16.23.1",
|
"stylelint": "^16.23.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export type UserPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PendingAuditCounts = {
|
export type PendingAuditCounts = {
|
||||||
|
signatureImports?: number;
|
||||||
enterpriseCertifications: number;
|
enterpriseCertifications: number;
|
||||||
smsAudits: number;
|
smsAudits: number;
|
||||||
templates: number;
|
templates: number;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.channel-group-editor .channel-group-editor__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-group-editor .channel-group-editor__order {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-group-editor .channel-group-editor__preview {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-group-editor .channel-group-editor__summary {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||||
|
import { AdminChannelGroupFormPage } from './AdminChannelGroupFormPage';
|
||||||
|
|
||||||
|
vi.mock('@/api/adminApi', () => ({
|
||||||
|
adminApi: {
|
||||||
|
listChannels: vi.fn(),
|
||||||
|
listChannelGroups: vi.fn(),
|
||||||
|
createChannelGroup: vi.fn(),
|
||||||
|
updateChannelGroup: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const channels: AdminChannel[] = ['A', 'B', 'C'].map((name) => ({
|
||||||
|
id: `channel-${name}`,
|
||||||
|
code: `CH-${name}`,
|
||||||
|
name: `通道${name}`,
|
||||||
|
carrier: 'mobile',
|
||||||
|
carriers: ['mobile'],
|
||||||
|
sendRegion: '全国',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
account: 'test-account',
|
||||||
|
srcId: '10690000',
|
||||||
|
rateLimitPerSecond: 10,
|
||||||
|
unitPrice: 321,
|
||||||
|
status: 'active',
|
||||||
|
connectionStates: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const group: ChannelGroup = {
|
||||||
|
id: 'group-existing',
|
||||||
|
code: 'CG-EXISTING',
|
||||||
|
name: '验收通道组',
|
||||||
|
carrier: 'mobile',
|
||||||
|
status: 'disabled',
|
||||||
|
retryEnabled: true,
|
||||||
|
retryTimeLimitMinutes: 750,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'route-A',
|
||||||
|
groupId: 'group-existing',
|
||||||
|
channelId: 'channel-A',
|
||||||
|
carrier: 'mobile',
|
||||||
|
priority: 10,
|
||||||
|
weight: 3,
|
||||||
|
isBackup: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'route-B',
|
||||||
|
groupId: 'group-existing',
|
||||||
|
channelId: 'channel-B',
|
||||||
|
carrier: 'mobile',
|
||||||
|
priority: 20,
|
||||||
|
weight: 2,
|
||||||
|
isBackup: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(adminApi.listChannels).mockResolvedValue(structuredClone(channels));
|
||||||
|
vi.mocked(adminApi.listChannelGroups).mockResolvedValue([structuredClone(group)]);
|
||||||
|
vi.mocked(adminApi.updateChannelGroup).mockResolvedValue(structuredClone(group));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function renderEditor(id = group.id) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[`/admin/channel-groups/${id}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/channel-groups/:groupId" element={<AdminChannelGroupFormPage />} />
|
||||||
|
<Route path="/admin/channel-groups" element={<h1>通道组列表</h1>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nationalRows() {
|
||||||
|
return within(screen.getAllByRole('table')[1]).getAllByRole('row').slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSave(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存修改' }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: '确认保存通道组' });
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '确认保存' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('channel group editor interactions', () => {
|
||||||
|
it('keeps historical priority slots when moving routes and can undo before saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditor();
|
||||||
|
await screen.findByDisplayValue('验收通道组');
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('1 / 10');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
|
||||||
|
expect(screen.getByRole('button', { name: '上移通道A' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '下移通道B' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '上移通道B' }));
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('通道B');
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('1 / 10');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('通道A');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('调整 2');
|
||||||
|
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '撤销排序' }));
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('通道A');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('通道B');
|
||||||
|
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows historical priority in the editor and rejects a duplicate without replacing either route', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditor();
|
||||||
|
await screen.findByDisplayValue('验收通道组');
|
||||||
|
await user.click(within(nationalRows()[0]).getByRole('button', { name: '编辑' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '编辑通道' });
|
||||||
|
const priority = within(dialog).getByRole('spinbutton', { name: /优先级/ });
|
||||||
|
expect(priority).toHaveValue(10);
|
||||||
|
await user.clear(priority);
|
||||||
|
await user.type(priority, '20');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '确认' }));
|
||||||
|
expect(within(dialog).getByRole('alert')).toHaveTextContent('全国通道优先级不能重复');
|
||||||
|
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.clear(priority);
|
||||||
|
await user.type(priority, '30');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '确认' }));
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(nationalRows()).toHaveLength(2);
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('通道B');
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('1 / 20');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('通道A');
|
||||||
|
expect(nationalRows()[1]).toHaveTextContent('2 / 30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps changed order and input after a failed save and preserves historical metadata on retry', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(adminApi.updateChannelGroup).mockRejectedValueOnce(new Error('保存服务暂不可用'));
|
||||||
|
renderEditor();
|
||||||
|
await screen.findByDisplayValue('验收通道组');
|
||||||
|
const name = screen.getByRole('textbox', { name: /通道组名称/ });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, '保留本次输入');
|
||||||
|
await user.click(screen.getByRole('button', { name: '上移通道B' }));
|
||||||
|
await submitSave(user);
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('保存服务暂不可用');
|
||||||
|
expect(name).toHaveValue('保留本次输入');
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('通道B');
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('有未保存修改');
|
||||||
|
expect(screen.queryByRole('heading', { name: '通道组列表' })).not.toBeInTheDocument();
|
||||||
|
expect(adminApi.updateChannelGroup).toHaveBeenLastCalledWith(
|
||||||
|
'group-existing',
|
||||||
|
expect.objectContaining({
|
||||||
|
name: '保留本次输入',
|
||||||
|
status: 'disabled',
|
||||||
|
retryTimeLimitMinutes: 750,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({ channelId: 'channel-B', priority: 10, weight: 2, isBackup: true }),
|
||||||
|
expect.objectContaining({ channelId: 'channel-A', priority: 20, weight: 3, isBackup: false }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await submitSave(user);
|
||||||
|
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
|
||||||
|
expect(adminApi.updateChannelGroup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(adminApi.createChannelGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a partially created group without creating a second empty group', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(adminApi.createChannelGroup).mockResolvedValue({ ...group, id: 'created-once', items: [] });
|
||||||
|
vi.mocked(adminApi.updateChannelGroup).mockRejectedValueOnce(new Error('成员保存失败'));
|
||||||
|
renderEditor('new');
|
||||||
|
const name = screen.getByRole('textbox', { name: /通道组名称/ });
|
||||||
|
await waitFor(() => expect(name).toBeEnabled());
|
||||||
|
await user.type(name, '新增草稿');
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加全国通道' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '添加通道' });
|
||||||
|
await user.click(within(dialog).getByRole('radio', { name: '选择通道 通道C(CH-C)' }));
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '确认' }));
|
||||||
|
await submitSave(user);
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('成员保存失败');
|
||||||
|
expect(name).toHaveValue('新增草稿');
|
||||||
|
expect(nationalRows()[0]).toHaveTextContent('通道C');
|
||||||
|
await submitSave(user);
|
||||||
|
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
|
||||||
|
expect(adminApi.createChannelGroup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(adminApi.updateChannelGroup).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of vi.mocked(adminApi.updateChannelGroup).mock.calls) {
|
||||||
|
expect(call[0]).toBe('created-once');
|
||||||
|
expect(call[1].items).toEqual([expect.objectContaining({ channelId: 'channel-C', priority: 10 })]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables editing and saving after a load error until real data is successfully reloaded', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(adminApi.listChannels).mockRejectedValueOnce(new Error('通道读取失败'));
|
||||||
|
renderEditor();
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('通道读取失败');
|
||||||
|
expect(screen.getByRole('textbox', { name: /通道组名称/ })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '添加全国通道' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
|
||||||
|
expect(adminApi.createChannelGroup).not.toHaveBeenCalled();
|
||||||
|
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole('button', { name: '重新加载' }));
|
||||||
|
expect(await screen.findByDisplayValue('验收通道组')).toBeEnabled();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
expect(nationalRows()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an old draft save into a new group while a cross-route load is pending', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const otherGroup = { ...group, id: 'group-other', name: '另一个通道组', items: [] };
|
||||||
|
let resolveChannels!: (value: AdminChannel[]) => void;
|
||||||
|
const pendingChannels = new Promise<AdminChannel[]>((resolve) => {
|
||||||
|
resolveChannels = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(adminApi.listChannels)
|
||||||
|
.mockResolvedValueOnce(structuredClone(channels))
|
||||||
|
.mockReturnValueOnce(pendingChannels);
|
||||||
|
vi.mocked(adminApi.listChannelGroups).mockResolvedValue([group, otherGroup]);
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/admin/channel-groups/group-existing']}>
|
||||||
|
<Link to="/admin/channel-groups/group-other">切换通道组</Link>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/channel-groups/:groupId" element={<AdminChannelGroupFormPage />} />
|
||||||
|
<Route path="/admin/channel-groups" element={<h1>通道组列表</h1>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const oldName = await screen.findByDisplayValue('验收通道组');
|
||||||
|
await user.clear(oldName);
|
||||||
|
await user.type(oldName, '旧组未保存草稿');
|
||||||
|
await user.click(screen.getByRole('link', { name: '切换通道组' }));
|
||||||
|
expect(await screen.findByText('正在加载真实通道组配置...')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('textbox', { name: /通道组名称/ })).toBeDisabled();
|
||||||
|
expect(screen.queryByDisplayValue('旧组未保存草稿')).not.toBeInTheDocument();
|
||||||
|
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => resolveChannels(structuredClone(channels)));
|
||||||
|
const newName = await screen.findByDisplayValue('另一个通道组');
|
||||||
|
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
|
||||||
|
await user.type(newName, '已修改');
|
||||||
|
await submitSave(user);
|
||||||
|
expect(adminApi.updateChannelGroup).toHaveBeenCalledWith(
|
||||||
|
'group-other',
|
||||||
|
expect.objectContaining({ name: '另一个通道组已修改', items: [] }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,489 +1,542 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, GripVertical, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
|
||||||
|
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { RouteConfigModal, type RouteModalState } from './channel-groups/RouteConfigModal';
|
||||||
|
import {
|
||||||
|
validateRoutes,
|
||||||
|
validateRouteCandidate,
|
||||||
|
moveNationalRoute,
|
||||||
|
nextAvailablePriority,
|
||||||
|
summarizeRouteChanges,
|
||||||
|
type ProvinceRoute,
|
||||||
|
type NationalRoute,
|
||||||
|
} from './channel-groups/model';
|
||||||
|
import { useUnsavedChanges } from './channel-groups/useUnsavedChanges';
|
||||||
|
import './AdminChannelGroupFormPage.css';
|
||||||
|
|
||||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||||
type ChannelStatus = 'normal' | 'stopped';
|
type Draft = {
|
||||||
type ProvinceRoute = {
|
name: string;
|
||||||
id: string;
|
|
||||||
province: string;
|
|
||||||
channelId: string;
|
|
||||||
status: ChannelStatus;
|
|
||||||
};
|
|
||||||
type NationalRoute = {
|
|
||||||
id: string;
|
|
||||||
priority: number;
|
|
||||||
channelId: string;
|
|
||||||
status: ChannelStatus;
|
|
||||||
};
|
|
||||||
type RouteModalState = {
|
|
||||||
type: 'province' | 'national';
|
|
||||||
mode: 'create' | 'edit';
|
|
||||||
route?: ProvinceRoute | NationalRoute;
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityOptions = [
|
|
||||||
{ label: '请选择', value: '' },
|
|
||||||
{ label: '1', value: '1' },
|
|
||||||
{ label: '2', value: '2' },
|
|
||||||
{ label: '3', value: '3' },
|
|
||||||
{ label: '4', value: '4' },
|
|
||||||
{ label: '5', value: '5' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const carrierLabels: Record<Carrier, string> = {
|
|
||||||
mobile: '移动',
|
|
||||||
unicom: '联通',
|
|
||||||
telecom: '电信',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<ChannelStatus, string> = {
|
|
||||||
normal: '链接正常',
|
|
||||||
stopped: '通道停用',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusTones: Record<ChannelStatus, 'success' | 'neutral'> = {
|
|
||||||
normal: 'success',
|
|
||||||
stopped: 'neutral',
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeRegion(region?: string | null) {
|
|
||||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
|
|
||||||
return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
|
|
||||||
if (channel?.status !== 'active') return 'stopped';
|
|
||||||
return (channel.connectionStates ?? []).some((connection) =>
|
|
||||||
connection.status === 'connected'
|
|
||||||
&& connection.desiredConnections > 0
|
|
||||||
&& connection.currentConnections > 0
|
|
||||||
) ? 'normal' : 'stopped';
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusTag({ status }: { status: ChannelStatus }) {
|
|
||||||
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteConfigModal({
|
|
||||||
channels,
|
|
||||||
carrier,
|
|
||||||
modal,
|
|
||||||
occupiedChannelIds,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
channels: AdminChannel[];
|
|
||||||
carrier: Carrier;
|
carrier: Carrier;
|
||||||
modal: RouteModalState;
|
retryEnabled: boolean;
|
||||||
occupiedChannelIds: string[];
|
hours: string;
|
||||||
onClose: () => void;
|
minutes: string;
|
||||||
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
|
provinceRoutes: ProvinceRoute[];
|
||||||
}) {
|
nationalRoutes: NationalRoute[];
|
||||||
const provinceRoute = modal.type === 'province' ? modal.route as ProvinceRoute | undefined : undefined;
|
};
|
||||||
const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined;
|
const emptyDraft: Draft = {
|
||||||
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
name: '',
|
||||||
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
|
carrier: 'mobile',
|
||||||
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
|
retryEnabled: true,
|
||||||
const [error, setError] = useState('');
|
hours: '12',
|
||||||
|
minutes: '0',
|
||||||
const provinceOptions = [
|
provinceRoutes: [],
|
||||||
{ label: '请选择省份', value: '' },
|
nationalRoutes: [],
|
||||||
...Array.from(new Set(channels
|
};
|
||||||
.filter((channel) => isCarrierCompatible(channel, carrier))
|
const carrierLabels: Record<Carrier, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||||
.map((channel) => channel.sendRegion)
|
function channelStatus(channel?: AdminChannel) {
|
||||||
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')),
|
if (channel?.status !== 'active') return '通道停用';
|
||||||
)).sort().map((region) => ({ label: region, value: region })),
|
return (channel.connectionStates ?? []).some(
|
||||||
];
|
(x) => x.status === 'connected' && x.currentConnections > 0 && x.desiredConnections > 0,
|
||||||
|
)
|
||||||
const selectableChannels = channels.filter((channel) => {
|
? '连接正常'
|
||||||
if (!isCarrierCompatible(channel, carrier)) return false;
|
: '暂未连接';
|
||||||
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
|
}
|
||||||
if (modal.type === 'province' && province) {
|
function fromGroup(group?: ChannelGroup): Draft {
|
||||||
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
|
const minutes = group?.retryTimeLimitMinutes ?? (group?.retryTimeLimitHours ?? 12) * 60;
|
||||||
}
|
return {
|
||||||
return true;
|
name: group?.name ?? '',
|
||||||
});
|
carrier: group?.carrier ?? 'mobile',
|
||||||
const channelOptions = [
|
retryEnabled: group?.retryEnabled ?? true,
|
||||||
{ label: '请选择', value: '' },
|
hours: String(Math.floor(minutes / 60)),
|
||||||
...selectableChannels.map((channel) => ({
|
minutes: String(minutes % 60),
|
||||||
label: `${channel.name}(${channel.code}) / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`,
|
provinceRoutes: (group?.items ?? [])
|
||||||
value: channel.id,
|
.filter((x) => x.province)
|
||||||
})),
|
.map((x) => ({ id: x.id, channelId: x.channelId, province: x.province! })),
|
||||||
];
|
nationalRoutes: (group?.items ?? [])
|
||||||
|
.filter((x) => !x.province)
|
||||||
function submit() {
|
.map((x) => ({ id: x.id, channelId: x.channelId, priority: x.priority }))
|
||||||
if (!channelId) {
|
.sort((a, b) => a.priority - b.priority),
|
||||||
setError('请选择可用通道');
|
};
|
||||||
return;
|
}
|
||||||
}
|
export function AdminChannelGroupFormPage() {
|
||||||
const channel = channels.find((item) => item.id === channelId);
|
const { groupId } = useParams();
|
||||||
if (modal.type === 'province') {
|
return <ChannelGroupEditor key={groupId ?? 'new'} />;
|
||||||
if (!province) {
|
|
||||||
setError('请选择省份');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({
|
|
||||||
id: provinceRoute?.id ?? `p-${Date.now()}`,
|
|
||||||
province,
|
|
||||||
channelId,
|
|
||||||
status: getChannelStatus(channel),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!priority) {
|
|
||||||
setError('请选择优先级');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({
|
|
||||||
id: nationalRoute?.id ?? `n-${Date.now()}`,
|
|
||||||
priority: Number(priority),
|
|
||||||
channelId,
|
|
||||||
status: getChannelStatus(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
footer={(
|
|
||||||
<>
|
|
||||||
<Button onClick={submit}>确认</Button>
|
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
onClose={onClose}
|
|
||||||
open
|
|
||||||
size="xl"
|
|
||||||
title={(
|
|
||||||
<div className="channel-route-modal__title">
|
|
||||||
<span><RadioTower size={20} /></span>
|
|
||||||
<div><h2>{modal.mode === 'edit' ? '编辑通道' : '添加通道'}</h2><p>{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}</p></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="channel-route-modal">
|
|
||||||
{modal.type === 'province' ? (
|
|
||||||
<Select label="* 选择省份" onChange={(event) => { setProvince(event.target.value); setChannelId(''); }} options={provinceOptions} value={province} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select label="* 优先级" onChange={(event) => setPriority(event.target.value)} options={priorityOptions} value={priority} />
|
|
||||||
<div className="channel-route-modal__note">
|
|
||||||
<Info size={17} />
|
|
||||||
<span>失败补发会跳往下一优先级的通道,不会在同优先级的通道去补发</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
|
||||||
{selectableChannels.length === 0 ? <p className="channel-route-modal__empty">暂无符合运营商、地区和未重复绑定条件的通道</p> : null}
|
|
||||||
{channelId ? (() => {
|
|
||||||
const selected = channels.find((channel) => channel.id === channelId);
|
|
||||||
if (!selected) return null;
|
|
||||||
const status = getChannelStatus(selected);
|
|
||||||
return (
|
|
||||||
<div className="channel-route-modal__selected">
|
|
||||||
<CheckCircle2 size={18} />
|
|
||||||
<div>
|
|
||||||
<strong>{selected.name}</strong>
|
|
||||||
<span>{selected.code} · {selected.sendRegion ?? '全国'} · {statusLabels[status]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : null}
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminChannelGroupFormPage() {
|
function ChannelGroupEditor() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const editing = Boolean(groupId && groupId !== 'new');
|
const editing = Boolean(groupId && groupId !== 'new');
|
||||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||||
const [groupName, setGroupName] = useState('');
|
const [loadedGroup, setLoadedGroup] = useState<ChannelGroup>();
|
||||||
const [carrier, setCarrier] = useState<Carrier>('mobile');
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||||
const [retryEnabled, setRetryEnabled] = useState(true);
|
const [baseline, setBaseline] = useState<Draft>();
|
||||||
const [retryLimitHours, setRetryLimitHours] = useState('12');
|
|
||||||
const [retryLimitMinutes, setRetryLimitMinutes] = useState('0');
|
|
||||||
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
|
|
||||||
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
|
|
||||||
const [modal, setModal] = useState<RouteModalState | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loadVersion, setLoadVersion] = useState(0);
|
||||||
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
|
const [modal, setModal] = useState<RouteModalState | null>(null);
|
||||||
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
|
const [confirmSave, setConfirmSave] = useState(false);
|
||||||
{ key: 'province', title: '省份', width: '160px', render: (route) => route.province },
|
const [undoOrder, setUndoOrder] = useState<NationalRoute[] | null>(null);
|
||||||
{
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
key: 'channel',
|
const createdId = useRef<string | null>(null);
|
||||||
title: '通道名称',
|
const bypassLeave = useRef(false);
|
||||||
width: '280px',
|
const dirty = Boolean(baseline && JSON.stringify(draft) !== JSON.stringify(baseline));
|
||||||
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
|
useUnsavedChanges(dirty, bypassLeave);
|
||||||
},
|
const channelById = useMemo(() => new Map(channels.map((x) => [x.id, x])), [channels]);
|
||||||
{
|
const changes = summarizeRouteChanges(baseline ?? emptyDraft, draft);
|
||||||
key: 'status',
|
const baseChanged =
|
||||||
title: '通道状态',
|
baseline &&
|
||||||
width: '140px',
|
['name', 'carrier', 'retryEnabled', 'hours', 'minutes'].some(
|
||||||
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
|
(k) => draft[k as keyof Draft] !== baseline[k as keyof Draft],
|
||||||
},
|
);
|
||||||
{
|
function change(patch: Partial<Draft>) {
|
||||||
key: 'actions',
|
setDraft((current) => ({ ...current, ...patch }));
|
||||||
title: '操作',
|
|
||||||
width: '160px',
|
|
||||||
render: (route) => (
|
|
||||||
<div className="channel-group-row-actions">
|
|
||||||
<button onClick={() => setModal({ type: 'province', mode: 'edit', route })} type="button"><Pencil size={15} />编辑</button>
|
|
||||||
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} />删除</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
], [channelById]);
|
|
||||||
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
|
|
||||||
{ key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority },
|
|
||||||
{
|
|
||||||
key: 'channel',
|
|
||||||
title: '通道名称',
|
|
||||||
width: '280px',
|
|
||||||
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: '通道状态',
|
|
||||||
width: '140px',
|
|
||||||
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
title: '操作',
|
|
||||||
width: '160px',
|
|
||||||
render: (route) => (
|
|
||||||
<div className="channel-group-row-actions">
|
|
||||||
<button onClick={() => setModal({ type: 'national', mode: 'edit', route })} type="button"><Pencil size={15} />编辑</button>
|
|
||||||
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} />删除</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
], [channelById]);
|
|
||||||
|
|
||||||
function applyGroup(group: ChannelGroup) {
|
|
||||||
setGroupName(group.name);
|
|
||||||
setCarrier(group.carrier);
|
|
||||||
setRetryEnabled(group.retryEnabled ?? true);
|
|
||||||
const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
|
|
||||||
setRetryLimitHours(String(Math.floor(retryMinutes / 60)));
|
|
||||||
setRetryLimitMinutes(String(retryMinutes % 60));
|
|
||||||
setProvinceRoutes((group.items ?? [])
|
|
||||||
.filter((item) => item.province)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
province: item.province ?? '',
|
|
||||||
channelId: item.channelId,
|
|
||||||
status: getChannelStatus(item.channel),
|
|
||||||
})));
|
|
||||||
setNationalRoutes((group.items ?? [])
|
|
||||||
.filter((item) => !item.province)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
priority: item.priority,
|
|
||||||
channelId: item.channelId,
|
|
||||||
status: getChannelStatus(item.channel),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.priority - b.priority));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadData() {
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
|
|
||||||
.then(([channelItems, groups]) => {
|
|
||||||
setChannels(channelItems.filter((channel) => channel.status !== 'deleted'));
|
|
||||||
if (editing && groupId) {
|
|
||||||
const group = groups.find((item) => item.id === groupId);
|
|
||||||
if (!group) throw new Error('通道组不存在或已被删除');
|
|
||||||
applyGroup(group);
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch((reason: Error) => setError(reason.message || '通道组表单加载失败'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
let cancelled = false;
|
||||||
}, [groupId]);
|
Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
|
||||||
|
.then(([items, groups]) => {
|
||||||
function saveRoute(route: ProvinceRoute | NationalRoute) {
|
if (cancelled) return;
|
||||||
if (modal?.type === 'province') {
|
const group = editing ? groups.find((x) => x.id === groupId) : undefined;
|
||||||
const nextRoute = route as ProvinceRoute;
|
if (editing && !group) throw new Error('通道组不存在或已被删除');
|
||||||
setProvinceRoutes((current) => {
|
const initial = fromGroup(group);
|
||||||
const withoutSameProvince = current.filter((item) => item.id === nextRoute.id || item.province !== nextRoute.province);
|
setChannels(items);
|
||||||
const exists = withoutSameProvince.some((item) => item.id === nextRoute.id);
|
setLoadedGroup(group);
|
||||||
return exists ? withoutSameProvince.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSameProvince, nextRoute];
|
setDraft(initial);
|
||||||
|
setBaseline(initial);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((reason: Error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(reason.message || '加载失败');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
return () => {
|
||||||
const nextRoute = route as NationalRoute;
|
cancelled = true;
|
||||||
setNationalRoutes((current) => {
|
};
|
||||||
const withoutSamePriority = current.filter((item) => item.id === nextRoute.id || item.priority !== nextRoute.priority);
|
}, [editing, groupId, loadVersion]);
|
||||||
const exists = withoutSamePriority.some((item) => item.id === nextRoute.id);
|
function moveRoute(sourceId: string, targetId: string) {
|
||||||
const next = exists ? withoutSamePriority.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSamePriority, nextRoute];
|
setUndoOrder(draft.nationalRoutes);
|
||||||
return [...next].sort((a, b) => a.priority - b.priority);
|
change({ nationalRoutes: moveNationalRoute(draft.nationalRoutes, sourceId, targetId) });
|
||||||
});
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
}
|
}
|
||||||
|
function saveRoute(route: ProvinceRoute | NationalRoute) {
|
||||||
function buildItems() {
|
const message = validateRouteCandidate(route, draft.provinceRoutes, draft.nationalRoutes);
|
||||||
|
if (message) return message;
|
||||||
|
if ('province' in route)
|
||||||
|
change({
|
||||||
|
provinceRoutes: draft.provinceRoutes.some((x) => x.id === route.id)
|
||||||
|
? draft.provinceRoutes.map((x) => (x.id === route.id ? route : x))
|
||||||
|
: [...draft.provinceRoutes, route],
|
||||||
|
});
|
||||||
|
else
|
||||||
|
change({
|
||||||
|
nationalRoutes: (draft.nationalRoutes.some((x) => x.id === route.id)
|
||||||
|
? draft.nationalRoutes.map((x) => (x.id === route.id ? route : x))
|
||||||
|
: [...draft.nationalRoutes, route]
|
||||||
|
).sort((a, b) => a.priority - b.priority),
|
||||||
|
});
|
||||||
|
setUndoOrder(null);
|
||||||
|
setModal(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function channelColumns<T extends ProvinceRoute | NationalRoute>(): TableColumn<T>[] {
|
||||||
return [
|
return [
|
||||||
...provinceRoutes.map((route) => ({
|
{
|
||||||
channelId: route.channelId,
|
key: 'channel',
|
||||||
carrier,
|
title: '通道名称 / 编号',
|
||||||
province: route.province,
|
width: '250px',
|
||||||
priority: 100,
|
render: (route) => (
|
||||||
})),
|
<div>
|
||||||
...nationalRoutes.map((route) => ({
|
<strong>{channelById.get(route.channelId)?.name ?? '已删除通道'}</strong>
|
||||||
channelId: route.channelId,
|
<div className="muted">{channelById.get(route.channelId)?.code ?? route.channelId}</div>
|
||||||
carrier,
|
</div>
|
||||||
priority: route.priority,
|
),
|
||||||
})),
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
title: '成本价格(元/条)',
|
||||||
|
width: '170px',
|
||||||
|
render: (route) => {
|
||||||
|
const price = channelById.get(route.channelId)?.unitPrice;
|
||||||
|
return price == null ? '—' : formatRateAmount(price / MONEY_UNITS_PER_YUAN);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '通道状态',
|
||||||
|
width: '130px',
|
||||||
|
render: (route) => {
|
||||||
|
const status = channelStatus(channelById.get(route.channelId));
|
||||||
|
return <Tag tone={status === '连接正常' ? 'success' : 'neutral'}>{status}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '150px',
|
||||||
|
render: (route) => (
|
||||||
|
<div className="channel-group-row-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon={<Pencil size={14} />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setModal(
|
||||||
|
'province' in route
|
||||||
|
? { type: 'province', mode: 'edit', route }
|
||||||
|
: { type: 'national', mode: 'edit', route },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if ('province' in route)
|
||||||
|
change({ provinceRoutes: draft.provinceRoutes.filter((x) => x.id !== route.id) });
|
||||||
|
else change({ nationalRoutes: draft.nationalRoutes.filter((x) => x.id !== route.id) });
|
||||||
|
setUndoOrder(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
const provinceColumns: TableColumn<ProvinceRoute>[] = [
|
||||||
function saveGroup() {
|
{ key: 'province', title: '省份', width: '140px', render: (x) => x.province },
|
||||||
if (!groupName.trim()) {
|
...channelColumns<ProvinceRoute>(),
|
||||||
|
];
|
||||||
|
const nationalColumns: TableColumn<NationalRoute>[] = [
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
title: '顺序 / 优先级',
|
||||||
|
width: '190px',
|
||||||
|
render: (route, index) => (
|
||||||
|
<div
|
||||||
|
className="channel-group-editor__order"
|
||||||
|
draggable={!saving}
|
||||||
|
onDragStart={() => setDragId(route.id)}
|
||||||
|
onDragEnd={() => setDragId(null)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragId && dragId !== route.id) moveRoute(dragId, route.id);
|
||||||
|
setDragId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical aria-label="拖动调整顺序" size={16} />
|
||||||
|
<span>
|
||||||
|
{index + 1} / {route.priority}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
aria-label={'上移' + channelById.get(route.channelId)?.name}
|
||||||
|
disabled={saving || index === 0}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveRoute(route.id, draft.nationalRoutes[index - 1].id)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={'下移' + channelById.get(route.channelId)?.name}
|
||||||
|
disabled={saving || index === draft.nationalRoutes.length - 1}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveRoute(route.id, draft.nationalRoutes[index + 1].id)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...channelColumns<NationalRoute>(),
|
||||||
|
];
|
||||||
|
async function saveGroup(confirmed = false) {
|
||||||
|
if (saving) return;
|
||||||
|
const routeError = validateRoutes(draft.provinceRoutes, draft.nationalRoutes);
|
||||||
|
if (routeError) {
|
||||||
|
setError(routeError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draft.name.trim()) {
|
||||||
setError('请输入通道组名称');
|
setError('请输入通道组名称');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const retryHours = Number(retryLimitHours);
|
const hours = Number(draft.hours),
|
||||||
const retryMinutes = Number(retryLimitMinutes);
|
minutes = Number(draft.minutes),
|
||||||
if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) {
|
total = hours * 60 + minutes;
|
||||||
setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数');
|
if (
|
||||||
|
!draft.hours.trim() ||
|
||||||
|
!draft.minutes.trim() ||
|
||||||
|
!Number.isInteger(hours) ||
|
||||||
|
hours < 0 ||
|
||||||
|
hours > 72 ||
|
||||||
|
!Number.isInteger(minutes) ||
|
||||||
|
minutes < 0 ||
|
||||||
|
minutes > 59 ||
|
||||||
|
total < 1 ||
|
||||||
|
total > 4320
|
||||||
|
) {
|
||||||
|
setError('补发时间需为1分钟至72小时,小时和分钟必须为整数');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const retryTimeLimitMinutes = retryHours * 60 + retryMinutes;
|
if (!confirmed) {
|
||||||
if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) {
|
setError('');
|
||||||
setError('补发时间上限需大于 0 分钟且不超过 72 小时');
|
setConfirmSave(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const original = (id: string) => loadedGroup?.items?.find((x) => x.id === id);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: groupName.trim(),
|
name: draft.name.trim(),
|
||||||
carrier,
|
carrier: draft.carrier,
|
||||||
status: 'active',
|
status: loadedGroup?.status ?? 'active',
|
||||||
retryEnabled,
|
description: loadedGroup?.description ?? undefined,
|
||||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
retryEnabled: draft.retryEnabled,
|
||||||
retryTimeLimitMinutes,
|
retryTimeLimitHours: Math.ceil(total / 60),
|
||||||
items: buildItems(),
|
retryTimeLimitMinutes: total,
|
||||||
|
items: [
|
||||||
|
...draft.provinceRoutes.map((x) => ({
|
||||||
|
channelId: x.channelId,
|
||||||
|
carrier: draft.carrier,
|
||||||
|
province: x.province,
|
||||||
|
priority: original(x.id)?.priority ?? 100,
|
||||||
|
weight: original(x.id)?.weight,
|
||||||
|
isBackup: original(x.id)?.isBackup,
|
||||||
|
})),
|
||||||
|
...draft.nationalRoutes.map((x) => ({
|
||||||
|
channelId: x.channelId,
|
||||||
|
carrier: draft.carrier,
|
||||||
|
priority: x.priority,
|
||||||
|
weight: original(x.id)?.weight,
|
||||||
|
isBackup: original(x.id)?.isBackup,
|
||||||
|
})),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
const request = editing && groupId
|
try {
|
||||||
? adminApi.updateChannelGroup(groupId, payload)
|
let targetId = editing ? groupId : createdId.current;
|
||||||
: adminApi.createChannelGroup({
|
if (!targetId) {
|
||||||
code: `CG-${Date.now()}`,
|
const group = await adminApi.createChannelGroup({
|
||||||
name: payload.name,
|
code: 'CG-' + Date.now(),
|
||||||
carrier,
|
name: payload.name,
|
||||||
status: 'active',
|
carrier: draft.carrier,
|
||||||
retryEnabled,
|
status: payload.status,
|
||||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
retryEnabled: draft.retryEnabled,
|
||||||
retryTimeLimitMinutes,
|
retryTimeLimitHours: payload.retryTimeLimitHours,
|
||||||
}).then((group) => adminApi.updateChannelGroup(group.id, payload));
|
retryTimeLimitMinutes: total,
|
||||||
|
});
|
||||||
request
|
targetId = group.id;
|
||||||
.then(() => navigate('/admin/channel-groups'))
|
createdId.current = targetId;
|
||||||
.catch((reason: Error) => setError(reason.message || '通道组保存失败'))
|
}
|
||||||
.finally(() => setSaving(false));
|
await adminApi.updateChannelGroup(targetId, payload);
|
||||||
|
bypassLeave.current = true;
|
||||||
|
navigate('/admin/channel-groups');
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '保存失败');
|
||||||
|
setConfirmSave(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const routeName = (id: string) => channelById.get(id)?.name ?? id;
|
||||||
|
const orderText = draft.nationalRoutes.map((x) => routeName(x.channelId)).join(' → ') || '尚未配置';
|
||||||
return (
|
return (
|
||||||
<div className="page-stack channel-group-form-page">
|
<div className="page-stack channel-group-form-page channel-group-editor">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<Breadcrumb items={[editing ? '编辑短信通道组' : '添加短信通道组']} />
|
||||||
<Breadcrumb items={[editing ? '编辑短信通道组' : '添加短信通道组']} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? <p className="muted">正在加载真实通道组配置...</p> : null}
|
{loading ? <p className="muted">正在加载真实通道组配置...</p> : null}
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
<section className="surface channel-group-form-section">
|
{error}
|
||||||
<h2>基础设置</h2>
|
</p>
|
||||||
<div className="channel-group-base-form">
|
) : null}
|
||||||
<Input label="* 通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} />
|
{!loading && !baseline ? (
|
||||||
<div className="channel-group-radio-row">
|
<Button
|
||||||
<span>* 运营商</span>
|
onClick={() => {
|
||||||
{(Object.keys(carrierLabels) as Carrier[]).map((item) => (
|
setLoading(true);
|
||||||
<label key={item}>
|
setLoadVersion((x) => x + 1);
|
||||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
}}
|
||||||
{carrierLabels[item]}
|
>
|
||||||
</label>
|
重新加载
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="channel-group-switch-row">
|
|
||||||
<span>* 失败补发</span>
|
|
||||||
<button aria-pressed={retryEnabled} className={retryEnabled ? 'is-on' : ''} onClick={() => setRetryEnabled((current) => !current)} type="button">
|
|
||||||
<i />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="channel-group-retry-limit">
|
|
||||||
<span>补发时间上限</span>
|
|
||||||
<Input
|
|
||||||
disabled={!retryEnabled}
|
|
||||||
max="72"
|
|
||||||
min="0"
|
|
||||||
onChange={(event) => setRetryLimitHours(event.target.value)}
|
|
||||||
suffix="小时"
|
|
||||||
type="number"
|
|
||||||
value={retryLimitHours}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
disabled={!retryEnabled}
|
|
||||||
max="59"
|
|
||||||
min="0"
|
|
||||||
onChange={(event) => setRetryLimitMinutes(event.target.value)}
|
|
||||||
suffix="分钟"
|
|
||||||
type="number"
|
|
||||||
value={retryLimitMinutes}
|
|
||||||
/>
|
|
||||||
<small>后端限制 1 分钟到 72 小时;默认 12 小时 0 分钟。</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="surface channel-group-form-section">
|
|
||||||
<h2>省网分流配置</h2>
|
|
||||||
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" pagination={false} rowKey="id" />
|
|
||||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
|
||||||
添加通道
|
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
) : null}
|
||||||
|
<fieldset disabled={loading || saving || !baseline} className="channel-group-editor__fields">
|
||||||
<section className="surface channel-group-form-section">
|
<section className="surface channel-group-form-section">
|
||||||
<h2>全国通道配置</h2>
|
<h2>基础设置</h2>
|
||||||
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" pagination={false} rowKey="id" />
|
<div className="channel-group-base-form">
|
||||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
<Input label="* 通道组名称" value={draft.name} onChange={(e) => change({ name: e.target.value })} />
|
||||||
添加通道
|
<div className="channel-group-radio-row">
|
||||||
</Button>
|
<span>* 运营商</span>
|
||||||
</section>
|
{(Object.keys(carrierLabels) as Carrier[]).map((x) => (
|
||||||
|
<label key={x}>
|
||||||
<div className="channel-group-form-footer">
|
<input type="radio" checked={draft.carrier === x} onChange={() => change({ carrier: x })} />
|
||||||
<Button disabled={saving || loading} onClick={saveGroup}>{saving ? '保存中...' : '确认'}</Button>
|
{carrierLabels[x]}
|
||||||
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost">返回</Button>
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="channel-group-switch-row">
|
||||||
|
<span>* 失败补发</span>
|
||||||
|
<button
|
||||||
|
aria-label="失败补发"
|
||||||
|
aria-pressed={draft.retryEnabled}
|
||||||
|
className={draft.retryEnabled ? 'is-on' : ''}
|
||||||
|
onClick={() => change({ retryEnabled: !draft.retryEnabled })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="channel-group-retry-limit">
|
||||||
|
<span>补发时间上限</span>
|
||||||
|
<Input
|
||||||
|
disabled={!draft.retryEnabled}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="72"
|
||||||
|
suffix="小时"
|
||||||
|
value={draft.hours}
|
||||||
|
onChange={(e) => change({ hours: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
disabled={!draft.retryEnabled}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
suffix="分钟"
|
||||||
|
value={draft.minutes}
|
||||||
|
onChange={(e) => change({ minutes: e.target.value })}
|
||||||
|
/>
|
||||||
|
<small>1分钟至72小时。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="surface channel-group-form-section">
|
||||||
|
<h2>省网分流配置</h2>
|
||||||
|
<Table
|
||||||
|
columns={provinceColumns}
|
||||||
|
data={draft.provinceRoutes}
|
||||||
|
emptyText="暂无省网通道"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={() => setModal({ type: 'province', mode: 'create' })}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
添加省网通道
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
<section className="surface channel-group-form-section">
|
||||||
|
<h2>全国通道配置</h2>
|
||||||
|
<p className="muted">顺序靠前的通道优先使用;拖动顺序单元格或上下移动,保存后生效。</p>
|
||||||
|
<div className="channel-group-editor__preview">{orderText}</div>
|
||||||
|
<Button
|
||||||
|
disabled={!undoOrder}
|
||||||
|
icon={<Undo2 size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (undoOrder) change({ nationalRoutes: undoOrder });
|
||||||
|
setUndoOrder(null);
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
撤销排序
|
||||||
|
</Button>
|
||||||
|
<Table
|
||||||
|
columns={nationalColumns}
|
||||||
|
data={draft.nationalRoutes}
|
||||||
|
emptyText="暂无全国通道"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={() => setModal({ type: 'national', mode: 'create' })}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
添加全国通道
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
<div className="channel-group-editor__summary" role="status">
|
||||||
|
{dirty ? '有未保存修改' : '当前配置未修改'} · 新增 {changes.added.length} · 移除 {changes.removed.length} · 调整{' '}
|
||||||
|
{changes.updated.length}
|
||||||
|
{baseChanged ? ' · 基础设置已修改' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="channel-group-form-footer">
|
||||||
|
<Button disabled={saving || loading || !baseline || !dirty} onClick={() => void saveGroup()}>
|
||||||
|
{saving ? '保存中...' : '保存修改'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (!dirty || window.confirm('有未保存的修改,确认离开?')) {
|
||||||
|
bypassLeave.current = true;
|
||||||
|
navigate('/admin/channel-groups');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={confirmSave}
|
||||||
|
title="确认保存通道组"
|
||||||
|
onClose={() => {
|
||||||
|
if (!saving) setConfirmSave(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={saving} onClick={() => setConfirmSave(false)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={saving} onClick={() => void saveGroup(true)}>
|
||||||
|
{saving ? '保存中...' : '确认保存'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="channel-group-editor__summary">
|
||||||
|
<p>
|
||||||
|
新增 {changes.added.length} 条,移除 {changes.removed.length} 条,调整 {changes.updated.length} 条。
|
||||||
|
</p>
|
||||||
|
{baseChanged ? (
|
||||||
|
<p>
|
||||||
|
基础设置已修改:{draft.name} / {carrierLabels[draft.carrier]} /{' '}
|
||||||
|
{draft.retryEnabled ? '启用补发' : '关闭补发'} / {draft.hours}小时{draft.minutes}分钟
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{changes.added.map((x) => (
|
||||||
|
<p key={'add' + x.channelId}>新增:{routeName(x.channelId)}</p>
|
||||||
|
))}
|
||||||
|
{changes.removed.map((x) => (
|
||||||
|
<p key={'remove' + x.channelId}>移除:{routeName(x.channelId)}</p>
|
||||||
|
))}
|
||||||
|
<p>全国顺序:{orderText}</p>
|
||||||
|
<p>确认后统一保存;失败保留本次输入。</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
{modal ? (
|
{modal ? (
|
||||||
<RouteConfigModal
|
<RouteConfigModal
|
||||||
carrier={carrier}
|
|
||||||
channels={channels}
|
channels={channels}
|
||||||
|
carrier={draft.carrier}
|
||||||
modal={modal}
|
modal={modal}
|
||||||
occupiedChannelIds={[
|
occupiedChannelIds={[...draft.provinceRoutes, ...draft.nationalRoutes].map((x) => x.channelId)}
|
||||||
...provinceRoutes.map((route) => route.channelId),
|
nextPriority={nextAvailablePriority(draft.nationalRoutes)}
|
||||||
...nationalRoutes.map((route) => route.channelId),
|
|
||||||
]}
|
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onSubmit={saveRoute}
|
onSubmit={saveRoute}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter, Link } from 'react-router-dom';
|
||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
import { AdminSignatureAuditPage } from './AdminSignatureAuditPage';
|
||||||
|
|
||||||
|
vi.mock('@/api/adminApi', () => ({ adminApi: { listEnterpriseSignatures: vi.fn().mockResolvedValue([]) } }));
|
||||||
|
vi.mock('./ReportImportAuditPanel', () => ({ ReportImportAuditPanel: () => <div>导入审核面板内容</div> }));
|
||||||
|
|
||||||
|
it('opens import reviews when the notification changes query parameters on the current audit page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/admin/signatures']}>
|
||||||
|
<Link to="/admin/signatures?tab=import">签名导入待审入口</Link>
|
||||||
|
<AdminSignatureAuditPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('导入审核面板内容')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('link', { name: '签名导入待审入口' }));
|
||||||
|
expect(await screen.findByText('导入审核面板内容')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -161,7 +161,7 @@ function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose:
|
|||||||
export function AdminSignatureAuditPage() {
|
export function AdminSignatureAuditPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requestedImportBatchId = searchParams.get('batchId') ?? undefined;
|
const requestedImportBatchId = searchParams.get('batchId') ?? undefined;
|
||||||
const [activeTab, setActiveTab] = useState(searchParams.get('tab') === 'import' ? 'import' : 'single');
|
const activeTab = searchParams.get('tab') === 'import' ? 'import' : 'single';
|
||||||
const [items, setItems] = useState<ClientSmsSignature[]>([]);
|
const [items, setItems] = useState<ClientSmsSignature[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [status, setStatus] = useState('pending');
|
const [status, setStatus] = useState('pending');
|
||||||
@@ -269,7 +269,6 @@ export function AdminSignatureAuditPage() {
|
|||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
<Tabs
|
<Tabs
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setActiveTab(value);
|
|
||||||
setSearchParams(value === 'import' ? { tab: 'import' } : {});
|
setSearchParams(value === 'import' ? { tab: 'import' } : {});
|
||||||
}}
|
}}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function ReportImportAuditPanel({
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await adminApi.reviewReportImportItems(detail.id, { decision, itemIds, reason: reason.trim() || undefined });
|
await adminApi.reviewReportImportItems(detail.id, { decision, itemIds, reason: reason.trim() || undefined });
|
||||||
|
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setReason('');
|
setReason('');
|
||||||
setRejectOpen(false);
|
setRejectOpen(false);
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
|||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCompleted = vi.fn();
|
const onCompleted = vi.fn();
|
||||||
|
const refresh = vi.fn();
|
||||||
|
window.addEventListener('cmpp-audit-count-refresh', refresh);
|
||||||
render(<ReportMaterialImportModal onClose={vi.fn()} onCompleted={onCompleted} />);
|
render(<ReportMaterialImportModal onClose={vi.fn()} onCompleted={onCompleted} />);
|
||||||
await waitFor(() => expect(adminApi.listTenantOptions).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(adminApi.listTenantOptions).toHaveBeenCalledTimes(1));
|
||||||
await user.click(screen.getByText('所属企业').closest('label')!.querySelector('button')!);
|
await user.click(screen.getByText('所属企业').closest('label')!.querySelector('button')!);
|
||||||
@@ -146,5 +148,7 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(onCompleted).toHaveBeenCalledWith(expect.objectContaining({ id: 'analysis-1', status: 'pending_review' }));
|
expect(onCompleted).toHaveBeenCalledWith(expect.objectContaining({ id: 'analysis-1', status: 'pending_review' }));
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
window.removeEventListener('cmpp-audit-count-refresh', refresh);
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ export function ReportMaterialImportModal({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||||
onCompleted(result);
|
onCompleted(result);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
.channel-route-editor .channel-route-editor__content {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__note,
|
||||||
|
.channel-route-editor .channel-route-editor__selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__note svg,
|
||||||
|
.channel-route-editor .channel-route-editor__selected svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choices {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choices legend {
|
||||||
|
padding: 0 0 var(--space-3);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(160px, 1.5fr) minmax(135px, 1fr) minmax(75px, 0.6fr) minmax(130px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choice:hover,
|
||||||
|
.channel-route-editor .channel-route-editor__choice:focus-within {
|
||||||
|
border-color: var(--color-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choice.is-selected {
|
||||||
|
border-color: var(--color-selected);
|
||||||
|
background: var(--color-selected-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choice.is-disabled {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__choice input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--color-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__identity,
|
||||||
|
.channel-route-editor .channel-route-editor__facts,
|
||||||
|
.channel-route-editor .channel-route-editor__connection {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__identity strong {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__code,
|
||||||
|
.channel-route-editor .channel-route-editor__facts,
|
||||||
|
.channel-route-editor .channel-route-editor__reason {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__facts strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__carriers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__selected {
|
||||||
|
color: var(--color-selected);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__empty {
|
||||||
|
padding: var(--space-6);
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 760px) {
|
||||||
|
.channel-route-editor .channel-route-editor__choice {
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__identity {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__facts:nth-child(3) {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-editor .channel-route-editor__connection {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AdminChannel } from '@/api/adminApi';
|
||||||
|
import { RouteConfigModal } from './RouteConfigModal';
|
||||||
|
|
||||||
|
const channel = (id: string, overrides: Partial<AdminChannel> = {}): AdminChannel => ({
|
||||||
|
id,
|
||||||
|
name: `通道${id}`,
|
||||||
|
code: `CODE-${id}`,
|
||||||
|
carriers: ['mobile'],
|
||||||
|
sendRegion: '广东省',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
account: 'test',
|
||||||
|
srcId: '106',
|
||||||
|
rateLimitPerSecond: 1,
|
||||||
|
unitPrice: 325,
|
||||||
|
status: 'active',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RouteConfigModal', () => {
|
||||||
|
it('keeps historical priority and exposes costs while permitting a disconnected active channel', async () => {
|
||||||
|
const onSubmit = vi.fn(() => null);
|
||||||
|
const current = channel('current', { connectionStates: [] });
|
||||||
|
render(
|
||||||
|
<RouteConfigModal
|
||||||
|
channels={[current]}
|
||||||
|
carrier="mobile"
|
||||||
|
modal={{ type: 'national', mode: 'edit', route: { id: 'route-1', channelId: current.id, priority: 20 } }}
|
||||||
|
occupiedChannelIds={[current.id]}
|
||||||
|
nextPriority={30}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /优先级/ })).toHaveValue(20);
|
||||||
|
expect(screen.getByText('0.0325')).toBeVisible();
|
||||||
|
expect(screen.getByText('暂无连接回写')).toBeVisible();
|
||||||
|
expect(screen.getByRole('radio')).toBeEnabled();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ id: 'route-1', channelId: 'current', priority: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows unavailable reasons and keeps the selected channel while searching', async () => {
|
||||||
|
const channels = [
|
||||||
|
channel('available'),
|
||||||
|
channel('occupied'),
|
||||||
|
channel('deleted', { status: 'deleted' }),
|
||||||
|
channel('telecom', { carriers: ['telecom'] }),
|
||||||
|
channel('disabled', { status: 'disabled' }),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<RouteConfigModal
|
||||||
|
channels={channels}
|
||||||
|
carrier="mobile"
|
||||||
|
modal={{ type: 'national', mode: 'create' }}
|
||||||
|
occupiedChannelIds={['occupied']}
|
||||||
|
nextPriority={30}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={() => null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
for (const id of ['occupied', 'deleted', 'telecom'])
|
||||||
|
expect(screen.getByRole('radio', { name: new RegExp(`CODE-${id}`) })).toBeDisabled();
|
||||||
|
expect(screen.getByText('已在当前通道组中配置')).toBeVisible();
|
||||||
|
expect(screen.getByText('通道已删除')).toBeVisible();
|
||||||
|
expect(screen.getByText('不支持移动')).toBeVisible();
|
||||||
|
expect(screen.getByText('已停用')).toBeVisible();
|
||||||
|
expect(screen.getByRole('radio', { name: /CODE-disabled/ })).toBeEnabled();
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: /CODE-available/ }));
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: '搜索通道' }), 'CODE-telecom');
|
||||||
|
expect(screen.getAllByRole('radio')).toHaveLength(1);
|
||||||
|
expect(screen.getByText('已选:通道available(CODE-available)')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables region mismatches but preserves a historical province option', () => {
|
||||||
|
render(
|
||||||
|
<RouteConfigModal
|
||||||
|
channels={[channel('other', { sendRegion: '江苏省' })]}
|
||||||
|
carrier="mobile"
|
||||||
|
modal={{ type: 'province', mode: 'edit', route: { id: 'p-1', channelId: 'missing', province: '浙江省' } }}
|
||||||
|
occupiedChannelIds={[]}
|
||||||
|
nextPriority={10}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={() => null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('浙江省')).toBeVisible();
|
||||||
|
expect(screen.getByRole('radio')).toBeDisabled();
|
||||||
|
expect(screen.getByText('通道地区与所选省份不匹配')).toBeVisible();
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('原通道不存在,请重新选择');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains form values after parent conflict rejection and rejects a fractional priority', async () => {
|
||||||
|
const onSubmit = vi.fn(() => '同一通道组内全国通道优先级不能重复');
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RouteConfigModal
|
||||||
|
channels={[channel('1')]}
|
||||||
|
carrier="mobile"
|
||||||
|
modal={{ type: 'national', mode: 'create' }}
|
||||||
|
occupiedChannelIds={[]}
|
||||||
|
nextPriority={20}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('radio'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('优先级不能重复');
|
||||||
|
expect(screen.getByRole('radio')).toBeChecked();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
const input = screen.getByRole('spinbutton', { name: /优先级/ });
|
||||||
|
fireEvent.change(input, { target: { value: '1.5' } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('整数');
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.change(input, { target: { value: '-10' } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
|
||||||
|
expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ priority: -10 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shared unsaved guard when dismissing a changed selection', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RouteConfigModal
|
||||||
|
channels={[channel('1')]}
|
||||||
|
carrier="mobile"
|
||||||
|
modal={{ type: 'national', mode: 'create' }}
|
||||||
|
occupiedChannelIds={[]}
|
||||||
|
nextPriority={10}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={() => null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('radio'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^取消$/ }));
|
||||||
|
expect(screen.getByRole('alertdialog')).toHaveTextContent('放弃未保存的修改');
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||||
|
expect(screen.getByRole('radio')).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useId, useState } from 'react';
|
||||||
|
import { CheckCircle2, Info, Search } from 'lucide-react';
|
||||||
|
import type { AdminChannel } from '@/api/adminApi';
|
||||||
|
import { Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
|
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
|
||||||
|
import { isValidPriority, normalizeRegion, type NationalRoute, type ProvinceRoute } from './model';
|
||||||
|
import './RouteConfigModal.css';
|
||||||
|
|
||||||
|
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||||
|
export type RouteModalState = {
|
||||||
|
type: 'province' | 'national';
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
route?: ProvinceRoute | NationalRoute;
|
||||||
|
};
|
||||||
|
|
||||||
|
const carrierLabels: Record<Carrier, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||||
|
|
||||||
|
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
|
||||||
|
return channel.carriers?.length
|
||||||
|
? channel.carriers.includes(carrier)
|
||||||
|
: !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionLabel(channel: AdminChannel) {
|
||||||
|
if (channel.status === 'deleted') return { text: '已删除', tone: 'neutral' as const };
|
||||||
|
if (channel.status !== 'active') return { text: '已停用', tone: 'neutral' as const };
|
||||||
|
const states = channel.connectionStates ?? [];
|
||||||
|
const connected = states.filter(
|
||||||
|
(state) => state.status === 'connected' && state.desiredConnections > 0 && state.currentConnections > 0,
|
||||||
|
);
|
||||||
|
if (connected.length) {
|
||||||
|
return {
|
||||||
|
text: `已连接 · ${connected.reduce((sum, state) => sum + state.currentConnections, 0)} 条`,
|
||||||
|
tone: 'success' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!states.length) return { text: '暂无连接回写', tone: 'neutral' as const };
|
||||||
|
return {
|
||||||
|
text: states.some((state) => state.status === 'connecting') ? '连接中' : '未连接',
|
||||||
|
tone: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteConfigModal({
|
||||||
|
channels,
|
||||||
|
carrier,
|
||||||
|
modal,
|
||||||
|
occupiedChannelIds,
|
||||||
|
nextPriority,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
channels: AdminChannel[];
|
||||||
|
carrier: Carrier;
|
||||||
|
modal: RouteModalState;
|
||||||
|
occupiedChannelIds: string[];
|
||||||
|
nextPriority: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (route: ProvinceRoute | NationalRoute) => string | null;
|
||||||
|
}) {
|
||||||
|
const provinceRoute = modal.type === 'province' ? (modal.route as ProvinceRoute | undefined) : undefined;
|
||||||
|
const nationalRoute = modal.type === 'national' ? (modal.route as NationalRoute | undefined) : undefined;
|
||||||
|
const initialPriority = String(nationalRoute?.priority ?? nextPriority);
|
||||||
|
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
||||||
|
const [priority, setPriority] = useState(initialPriority);
|
||||||
|
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const selectionName = useId();
|
||||||
|
const selected = channels.find((channel) => channel.id === channelId);
|
||||||
|
const dirty =
|
||||||
|
province !== (provinceRoute?.province ?? '') ||
|
||||||
|
priority !== initialPriority ||
|
||||||
|
channelId !== (modal.route?.channelId ?? '');
|
||||||
|
const provinces = channels
|
||||||
|
.filter((channel) => isCarrierCompatible(channel, carrier))
|
||||||
|
.map((channel) => channel.sendRegion)
|
||||||
|
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国'));
|
||||||
|
if (provinceRoute?.province) provinces.push(provinceRoute.province);
|
||||||
|
const provinceOptions = [
|
||||||
|
{ label: '请选择省份', value: '' },
|
||||||
|
...Array.from(new Set(provinces))
|
||||||
|
.sort()
|
||||||
|
.map((region) => ({ label: region, value: region })),
|
||||||
|
];
|
||||||
|
|
||||||
|
function unavailableReason(channel: AdminChannel) {
|
||||||
|
if (channel.status === 'deleted') return '通道已删除';
|
||||||
|
if (!isCarrierCompatible(channel, carrier)) return `不支持${carrierLabels[carrier]}`;
|
||||||
|
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return '已在当前通道组中配置';
|
||||||
|
if (modal.type === 'province') {
|
||||||
|
if (!province) return '请先选择省份';
|
||||||
|
if (normalizeRegion(channel.sendRegion) !== normalizeRegion(province)) return '通道地区与所选省份不匹配';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = keyword.trim().toLocaleLowerCase();
|
||||||
|
const visibleChannels = channels.filter((channel) =>
|
||||||
|
`${channel.name} ${channel.code} ${channel.sendRegion ?? '全国'}`.toLocaleLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
const availableCount = visibleChannels.filter((channel) => !unavailableReason(channel)).length;
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (modal.type === 'province' && !province) {
|
||||||
|
setError('请选择省份');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selected) {
|
||||||
|
setError(channelId ? '原通道不存在,请重新选择' : '请选择通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = unavailableReason(selected);
|
||||||
|
if (reason) {
|
||||||
|
setError(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (modal.type === 'national' && (!priority.trim() || !isValidPriority(Number(priority)))) {
|
||||||
|
setError('优先级需为 -2147483648 到 2147483647 的整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
modal.type === 'province'
|
||||||
|
? { id: provinceRoute?.id ?? `p-${selectionName}`, province, channelId }
|
||||||
|
: { id: nationalRoute?.id ?? `n-${selectionName}`, priority: Number(priority), channelId };
|
||||||
|
setError(onSubmit(route) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="channel-route-editor"
|
||||||
|
dirty={dirty}
|
||||||
|
footer={({ requestClose }) => (
|
||||||
|
<>
|
||||||
|
<Button onClick={requestClose} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
|
||||||
|
>
|
||||||
|
<div className="channel-route-editor__content">
|
||||||
|
{modal.type === 'province' ? (
|
||||||
|
<Select
|
||||||
|
label="选择省份"
|
||||||
|
required
|
||||||
|
onChange={(event) => {
|
||||||
|
setProvince(event.target.value);
|
||||||
|
setChannelId('');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
options={provinceOptions}
|
||||||
|
value={province}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
label="优先级"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="-2147483648"
|
||||||
|
max="2147483647"
|
||||||
|
onChange={(event) => {
|
||||||
|
setPriority(event.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
value={priority}
|
||||||
|
hint="数值越小越先使用;失败补发会跳到下一优先级。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="搜索通道"
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="输入通道名称、编号或地区"
|
||||||
|
prefix={<Search aria-hidden="true" size={16} />}
|
||||||
|
value={keyword}
|
||||||
|
/>
|
||||||
|
<p className="channel-route-editor__note">
|
||||||
|
<Info aria-hidden="true" size={16} />
|
||||||
|
<span>连接状态来自最近一次真实回写;暂时断连不影响配置,实际发送仍受可用性校验。</span>
|
||||||
|
</p>
|
||||||
|
<fieldset className="channel-route-editor__choices">
|
||||||
|
<legend>
|
||||||
|
选择通道 · {visibleChannels.length} 个结果,{availableCount} 个可选
|
||||||
|
</legend>
|
||||||
|
{visibleChannels.map((channel) => {
|
||||||
|
const reason = unavailableReason(channel);
|
||||||
|
const connection = connectionLabel(channel);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`channel-route-editor__choice${channelId === channel.id ? ' is-selected' : ''}${reason ? ' is-disabled' : ''}`}
|
||||||
|
key={channel.id}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={`选择通道 ${channel.name}(${channel.code})`}
|
||||||
|
checked={channelId === channel.id}
|
||||||
|
disabled={Boolean(reason)}
|
||||||
|
name={selectionName}
|
||||||
|
onChange={() => {
|
||||||
|
setChannelId(channel.id);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
type="radio"
|
||||||
|
value={channel.id}
|
||||||
|
/>
|
||||||
|
<span className="channel-route-editor__identity">
|
||||||
|
<strong>{channel.name}</strong>
|
||||||
|
<span className="channel-route-editor__code">{channel.code}</span>
|
||||||
|
<span className="channel-route-editor__carriers">
|
||||||
|
{(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).map((item) => (
|
||||||
|
<CarrierTag carrier={item} key={item} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="channel-route-editor__facts">
|
||||||
|
<span>成本价格(元/条)</span>
|
||||||
|
<strong>
|
||||||
|
{channel.unitPrice == null ? '—' : formatRateAmount(channel.unitPrice / MONEY_UNITS_PER_YUAN)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span className="channel-route-editor__facts">
|
||||||
|
<span>地区</span>
|
||||||
|
<strong>{channel.sendRegion || '全国'}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="channel-route-editor__connection">
|
||||||
|
<Tag tone={connection.tone}>{connection.text}</Tag>
|
||||||
|
{reason ? <span className="channel-route-editor__reason">{reason}</span> : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleChannels.length === 0 ? (
|
||||||
|
<p className="channel-route-editor__empty">
|
||||||
|
{channels.length ? '没有匹配的通道,请调整搜索条件' : '暂无通道数据'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
{selected ? (
|
||||||
|
<p className="channel-route-editor__selected">
|
||||||
|
<CheckCircle2 aria-hidden="true" size={17} />
|
||||||
|
<span>
|
||||||
|
已选:{selected.name}({selected.code})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{channelId && !selected ? (
|
||||||
|
<p className="channel-route-editor__error" role="alert">
|
||||||
|
原通道不存在,请重新选择
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="channel-route-editor__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidPriority,
|
||||||
|
moveNationalRoute,
|
||||||
|
nextAvailablePriority,
|
||||||
|
normalizeRegion,
|
||||||
|
summarizeRouteChanges,
|
||||||
|
validateRouteCandidate,
|
||||||
|
validateRoutes,
|
||||||
|
type NationalRoute,
|
||||||
|
type ProvinceRoute,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
const national = (id: string, priority: number): NationalRoute => ({
|
||||||
|
id,
|
||||||
|
channelId: `channel-${id}`,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
const province = (id: string, region: string): ProvinceRoute => ({
|
||||||
|
id,
|
||||||
|
channelId: `channel-${id}`,
|
||||||
|
province: region,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('channel group route validation', () => {
|
||||||
|
it('uses the same province aliases as the backend contract', () => {
|
||||||
|
expect(normalizeRegion(' 广西壮族自治区 ')).toBe('广西');
|
||||||
|
expect(normalizeRegion('新疆维吾尔自治区')).toBe('新疆');
|
||||||
|
expect(normalizeRegion('北京市')).toBe('北京');
|
||||||
|
expect(normalizeRegion(null)).toBe('');
|
||||||
|
expect(validateRoutes([province('a', '山东'), province('b', '山东省')], [])).toContain('同一省份');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate channels across provincial and national routes', () => {
|
||||||
|
expect(validateRoutes([province('a', '山东省')], [national('a', 10)])).toContain('同一通道');
|
||||||
|
expect(validateRoutes([], [national('a', 10), national('b', 10)])).toContain('优先级不能重复');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows historical priorities and editing the current route without removing another row', () => {
|
||||||
|
const routes = [national('a', 10), national('b', 20)];
|
||||||
|
const snapshot = structuredClone(routes);
|
||||||
|
expect(validateRoutes([], routes)).toBeNull();
|
||||||
|
expect(validateRouteCandidate(national('a', 10), [], routes)).toBeNull();
|
||||||
|
expect(validateRouteCandidate(national('a', 20), [], routes)).toContain('优先级不能重复');
|
||||||
|
expect(routes).toEqual(snapshot);
|
||||||
|
expect(validateRouteCandidate(province('a', '山东'), [province('a', '山东省')], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects incomplete rows and priority values PostgreSQL cannot persist', () => {
|
||||||
|
expect(validateRoutes([{ ...province('a', '山东'), channelId: '' }], [])).toBe('请选择通道');
|
||||||
|
expect(validateRoutes([province('a', '全国')], [])).toContain('省份');
|
||||||
|
expect(validateRoutes([province('a', '')], [])).toContain('省份');
|
||||||
|
for (const priority of [NaN, Infinity, 1.5, 2147483648, -2147483649]) {
|
||||||
|
expect(isValidPriority(priority)).toBe(false);
|
||||||
|
expect(validateRoutes([], [national('a', priority)])).toContain('整数');
|
||||||
|
}
|
||||||
|
for (const priority of [-2147483648, -10, 0, 10, 20, 2147483647]) {
|
||||||
|
expect(isValidPriority(priority)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('national route movement', () => {
|
||||||
|
it('moves down and up by identity while preserving historical priority slots and route metadata', () => {
|
||||||
|
const routes = [
|
||||||
|
{ ...national('a', 10), status: 'normal' },
|
||||||
|
{ ...national('b', 20), status: 'stopped' },
|
||||||
|
{ ...national('c', 100), status: 'normal' },
|
||||||
|
];
|
||||||
|
const original = structuredClone(routes);
|
||||||
|
const moved = moveNationalRoute(routes, 'a', 'c');
|
||||||
|
expect(moved.map(({ id, priority, status }) => ({ id, priority, status }))).toEqual([
|
||||||
|
{ id: 'b', priority: 10, status: 'stopped' },
|
||||||
|
{ id: 'c', priority: 20, status: 'normal' },
|
||||||
|
{ id: 'a', priority: 100, status: 'normal' },
|
||||||
|
]);
|
||||||
|
expect(moveNationalRoute(moved, 'a', 'b')).toEqual(original);
|
||||||
|
expect(routes).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses priority order even if an API array arrives unsorted', () => {
|
||||||
|
const moved = moveNationalRoute([national('b', 20), national('a', 10)], 'b', 'a');
|
||||||
|
expect(moved).toEqual([national('b', 10), national('a', 20)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves stale drag targets and invalid duplicate priorities unchanged', () => {
|
||||||
|
const routes = [national('a', 10), national('b', 20)];
|
||||||
|
expect(moveNationalRoute(routes, 'missing', 'a')).toEqual(routes);
|
||||||
|
expect(moveNationalRoute(routes, 'a', 'missing')).toEqual(routes);
|
||||||
|
expect(moveNationalRoute(routes, 'a', 'a')).toEqual(routes);
|
||||||
|
const invalid = [national('a', 10), national('b', 10)];
|
||||||
|
expect(moveNationalRoute(invalid, 'a', 'b')).toEqual(invalid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('next available priority', () => {
|
||||||
|
it('starts at ten and advances from normal historical priorities', () => {
|
||||||
|
expect(nextAvailablePriority([])).toBe(10);
|
||||||
|
expect(nextAvailablePriority([national('a', 10), national('b', 20)])).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside the PostgreSQL integer range when the highest slot is occupied', () => {
|
||||||
|
expect(nextAvailablePriority([national('a', 2147483647), national('b', 2147483637)])).toBe(2147483627);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('channel group save summary', () => {
|
||||||
|
it('reports additions, removals, route changes and national relative order separately', () => {
|
||||||
|
const before = {
|
||||||
|
provinceRoutes: [province('p', '山东'), province('q', '河南')],
|
||||||
|
nationalRoutes: [national('a', 10), national('b', 20)],
|
||||||
|
};
|
||||||
|
const after = {
|
||||||
|
provinceRoutes: [province('p', '河北'), province('r', '北京')],
|
||||||
|
nationalRoutes: moveNationalRoute(before.nationalRoutes, 'b', 'a'),
|
||||||
|
};
|
||||||
|
const summary = summarizeRouteChanges(before, after);
|
||||||
|
expect(summary.added).toEqual([province('r', '北京')]);
|
||||||
|
expect(summary.removed).toEqual([province('q', '河南')]);
|
||||||
|
expect(summary.updated.map((change) => change.after.channelId)).toEqual(['channel-p', 'channel-b', 'channel-a']);
|
||||||
|
expect(summary.orderChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores recreated member IDs, connection metadata and equivalent province aliases', () => {
|
||||||
|
const summary = summarizeRouteChanges(
|
||||||
|
{ provinceRoutes: [province('p', '广西')], nationalRoutes: [national('a', 10)] },
|
||||||
|
{
|
||||||
|
provinceRoutes: [{ ...province('p', '广西壮族自治区'), id: 'new-p' }],
|
||||||
|
nationalRoutes: [{ ...national('a', 10), id: 'new-a' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(summary).toEqual({ added: [], removed: [], updated: [], orderChanged: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim a reorder for adding or removing a channel without changing surviving order', () => {
|
||||||
|
const summary = summarizeRouteChanges(
|
||||||
|
{
|
||||||
|
provinceRoutes: [],
|
||||||
|
nationalRoutes: [national('a', 10), national('b', 20), national('c', 30)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provinceRoutes: [],
|
||||||
|
nationalRoutes: [national('a', 10), national('d', 15), national('c', 30)],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(summary.added).toHaveLength(1);
|
||||||
|
expect(summary.removed).toHaveLength(1);
|
||||||
|
expect(summary.orderChanged).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports moving the same channel from province to national as a configuration change', () => {
|
||||||
|
const summary = summarizeRouteChanges(
|
||||||
|
{ provinceRoutes: [province('a', '山东')], nationalRoutes: [] },
|
||||||
|
{ provinceRoutes: [], nationalRoutes: [national('a', 10)] },
|
||||||
|
);
|
||||||
|
expect(summary.added).toEqual([]);
|
||||||
|
expect(summary.removed).toEqual([]);
|
||||||
|
expect(summary.updated).toEqual([{ before: province('a', '山东'), after: national('a', 10) }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
export type ProvinceRoute = {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
province: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NationalRoute = {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Route = ProvinceRoute | NationalRoute;
|
||||||
|
|
||||||
|
export type RouteConfiguration = {
|
||||||
|
provinceRoutes: readonly ProvinceRoute[];
|
||||||
|
nationalRoutes: readonly NationalRoute[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteChangeSummary = {
|
||||||
|
added: Route[];
|
||||||
|
removed: Route[];
|
||||||
|
updated: Array<{ before: Route; after: Route }>;
|
||||||
|
orderChanged: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeRegion(region?: string | null) {
|
||||||
|
return String(region ?? '')
|
||||||
|
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPriority(priority: number) {
|
||||||
|
// Match PostgreSQL Int without narrowing the existing API's historical values.
|
||||||
|
return Number.isInteger(priority) && priority >= -2147483648 && priority <= 2147483647;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextAvailablePriority(routes: readonly NationalRoute[]) {
|
||||||
|
const used = new Set(routes.map((route) => route.priority));
|
||||||
|
if (!used.size) return 10;
|
||||||
|
const highest = Math.max(...used);
|
||||||
|
if (highest <= 2147483637 && !used.has(highest + 10)) return highest + 10;
|
||||||
|
const lowest = Math.min(...used);
|
||||||
|
if (lowest >= -2147483638 && !used.has(lowest - 10)) return lowest - 10;
|
||||||
|
// Among routes.length + 1 consecutive integers, at least one is unused.
|
||||||
|
for (let candidate = 0; candidate <= routes.length; candidate += 1) {
|
||||||
|
if (!used.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRoutes(
|
||||||
|
provinceRoutes: readonly ProvinceRoute[],
|
||||||
|
nationalRoutes: readonly NationalRoute[],
|
||||||
|
): string | null {
|
||||||
|
const channels = new Set<string>();
|
||||||
|
for (const route of [...provinceRoutes, ...nationalRoutes]) {
|
||||||
|
if (!route.channelId) return '请选择通道';
|
||||||
|
if (channels.has(route.channelId)) return '通道组内不能重复配置同一通道';
|
||||||
|
channels.add(route.channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinces = new Set<string>();
|
||||||
|
for (const route of provinceRoutes) {
|
||||||
|
const province = normalizeRegion(route.province);
|
||||||
|
if (!province || province === '全国') return '请选择省网路由的省份';
|
||||||
|
if (provinces.has(province)) return '同一通道组内同一省份只能配置一个通道';
|
||||||
|
provinces.add(province);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorities = new Set<number>();
|
||||||
|
for (const route of nationalRoutes) {
|
||||||
|
if (!isValidPriority(route.priority)) return '优先级必须为 -2147483648 到 2147483647 的整数';
|
||||||
|
if (priorities.has(route.priority)) return '同一通道组内全国通道优先级不能重复';
|
||||||
|
priorities.add(route.priority);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRouteCandidate(
|
||||||
|
candidate: Route,
|
||||||
|
provinceRoutes: readonly ProvinceRoute[],
|
||||||
|
nationalRoutes: readonly NationalRoute[],
|
||||||
|
): string | null {
|
||||||
|
const provinces = provinceRoutes.filter((route) => route.id !== candidate.id);
|
||||||
|
const nationals = nationalRoutes.filter((route) => route.id !== candidate.id);
|
||||||
|
if ('province' in candidate) provinces.push(candidate);
|
||||||
|
else nationals.push(candidate);
|
||||||
|
return validateRoutes(provinces, nationals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveNationalRoute<T extends NationalRoute>(
|
||||||
|
routes: readonly T[],
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
): T[] {
|
||||||
|
const ordered = [...routes].sort((left, right) => left.priority - right.priority);
|
||||||
|
const sourceIndex = ordered.findIndex((route) => route.id === sourceId);
|
||||||
|
const targetIndex = ordered.findIndex((route) => route.id === targetId);
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return [...routes];
|
||||||
|
if (validateRoutes([], ordered)) return [...routes];
|
||||||
|
|
||||||
|
// A move changes which channel occupies each existing priority slot. It must
|
||||||
|
// not rewrite historical values such as 10/20 to an unrelated 1/2 sequence.
|
||||||
|
const priorities = ordered.map((route) => route.priority);
|
||||||
|
const [moved] = ordered.splice(sourceIndex, 1);
|
||||||
|
ordered.splice(targetIndex, 0, moved);
|
||||||
|
return ordered.map((route, index) => ({ ...route, priority: priorities[index] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredRoutes(configuration: RouteConfiguration): Route[] {
|
||||||
|
return [
|
||||||
|
...configuration.provinceRoutes,
|
||||||
|
...[...configuration.nationalRoutes].sort((left, right) => left.priority - right.priority),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeChanged(before: Route, after: Route) {
|
||||||
|
if ('province' in before && 'province' in after) {
|
||||||
|
return normalizeRegion(before.province) !== normalizeRegion(after.province);
|
||||||
|
}
|
||||||
|
if ('priority' in before && 'priority' in after) return before.priority !== after.priority;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRouteChanges(before: RouteConfiguration, after: RouteConfiguration): RouteChangeSummary {
|
||||||
|
const beforeRoutes = configuredRoutes(before);
|
||||||
|
const afterRoutes = configuredRoutes(after);
|
||||||
|
// The API recreates member IDs on save; the unique channel is the stable
|
||||||
|
// business identity for changes to its province or national priority.
|
||||||
|
const beforeByChannel = new Map(beforeRoutes.map((route) => [route.channelId, route]));
|
||||||
|
const afterByChannel = new Map(afterRoutes.map((route) => [route.channelId, route]));
|
||||||
|
const updated: RouteChangeSummary['updated'] = [];
|
||||||
|
for (const route of afterRoutes) {
|
||||||
|
const previous = beforeByChannel.get(route.channelId);
|
||||||
|
if (previous && routeChanged(previous, route)) updated.push({ before: previous, after: route });
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeNationalChannels = new Set(before.nationalRoutes.map((route) => route.channelId));
|
||||||
|
const afterNationalChannels = new Set(after.nationalRoutes.map((route) => route.channelId));
|
||||||
|
const commonOrder = (routes: readonly NationalRoute[], other: Set<string>) =>
|
||||||
|
[...routes]
|
||||||
|
.sort((left, right) => left.priority - right.priority)
|
||||||
|
.filter((route) => other.has(route.channelId))
|
||||||
|
.map((route) => route.channelId);
|
||||||
|
const beforeOrder = commonOrder(before.nationalRoutes, afterNationalChannels);
|
||||||
|
const afterOrder = commonOrder(after.nationalRoutes, beforeNationalChannels);
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: afterRoutes.filter((route) => !beforeByChannel.has(route.channelId)),
|
||||||
|
removed: beforeRoutes.filter((route) => !afterByChannel.has(route.channelId)),
|
||||||
|
updated,
|
||||||
|
orderChanged: beforeOrder.some((channelId, index) => channelId !== afterOrder[index]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react';
|
||||||
|
|
||||||
|
export function useUnsavedChanges(dirty: boolean, bypass: RefObject<boolean>) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
let currentIndex = window.history.state?.idx as number | undefined;
|
||||||
|
const confirmLeave = () => bypass.current || window.confirm('有未保存的修改,确认离开?');
|
||||||
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (bypass.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
};
|
||||||
|
const click = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.altKey
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||||
|
if (
|
||||||
|
!(link instanceof HTMLAnchorElement) ||
|
||||||
|
link.target === '_blank' ||
|
||||||
|
link.hasAttribute('download') ||
|
||||||
|
link.href === window.location.href
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (!confirmLeave()) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pop = (event: PopStateEvent) => {
|
||||||
|
const nextIndex = event.state?.idx as number | undefined;
|
||||||
|
if (nextIndex === currentIndex) return;
|
||||||
|
if (!confirmLeave() && typeof currentIndex === 'number' && typeof nextIndex === 'number') {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
window.history.go(currentIndex - nextIndex);
|
||||||
|
} else currentIndex = nextIndex;
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', beforeUnload);
|
||||||
|
document.addEventListener('click', click, true);
|
||||||
|
window.addEventListener('popstate', pop, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload);
|
||||||
|
document.removeEventListener('click', click, true);
|
||||||
|
window.removeEventListener('popstate', pop, true);
|
||||||
|
};
|
||||||
|
}, [dirty, bypass]);
|
||||||
|
}
|
||||||
@@ -30,12 +30,12 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
UserX,
|
UserX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { adminApi } from '@/api/adminApi';
|
import { adminApi, type PendingAuditCounts } from '@/api/adminApi';
|
||||||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
|
|
||||||
const EMPTY_PENDING_AUDITS = {
|
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||||
enterpriseCertifications: 0,
|
enterpriseCertifications: 0,
|
||||||
smsAudits: 0,
|
smsAudits: 0,
|
||||||
templates: 0,
|
templates: 0,
|
||||||
@@ -153,6 +153,11 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||||
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
|
||||||
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
|
||||||
|
{
|
||||||
|
label: '签名导入待审',
|
||||||
|
count: pendingAudits.signatureImports ?? 0,
|
||||||
|
to: '/admin/signatures?tab=import',
|
||||||
|
},
|
||||||
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
|
||||||
]}
|
]}
|
||||||
navSections={[
|
navSections={[
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"mainImportOrder": [
|
||||||
|
"src/styles/tokens.css",
|
||||||
|
"src/styles/reset.css",
|
||||||
|
"src/styles/shell.css",
|
||||||
|
"src/styles/domains/index.css",
|
||||||
|
"src/styles/admin.css",
|
||||||
|
"src/styles/client.css",
|
||||||
|
"src/styles/components.css"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/ReportMaterialImportModal.css",
|
||||||
|
"owners": ["src/apps/admin/ReportMaterialImportModal.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["report-material-import-modal"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/channels/AdminChannelsPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminChannelsPage.tsx"],
|
||||||
|
"legacyFingerprint": "fc2d168035c9adac5b330a7526424831ac18725da92a8c2392600e08a0891c69",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminEnterpriseApplicationsPage.tsx"],
|
||||||
|
"legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
|
||||||
|
"owners": ["src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"],
|
||||||
|
"legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminSmsRecordsPage.tsx"],
|
||||||
|
"legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminSmsTaskProgressPage.tsx"],
|
||||||
|
"legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
|
||||||
|
"owners": ["src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"],
|
||||||
|
"legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/client/ClientUsersPage.css",
|
||||||
|
"owners": ["src/apps/client/ClientUsersPage.tsx"],
|
||||||
|
"legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/admin.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/client.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/components.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/01-operations-dashboard.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/02-client-sending.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/03-client-records.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/04-signatures.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/05-templates.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/06-auth-enterprise.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/07-admin-operations.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/08-reporting.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/09-channels.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/10-signature-quality.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/11-deliveries-reporting.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/12-admin-configuration.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/13-client-signatures.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/14-responsive-requeue.css",
|
||||||
|
"owners": ["src/styles/domains/index.css"],
|
||||||
|
"legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/domains/index.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/reset.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/shell.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/styles/tokens.css",
|
||||||
|
"owners": ["src/main.tsx"],
|
||||||
|
"legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223",
|
||||||
|
"stylelintLegacy": true,
|
||||||
|
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
|
||||||
|
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/AdminChannelGroupFormPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminChannelGroupFormPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["channel-group-editor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/channel-groups/RouteConfigModal.css",
|
||||||
|
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["channel-route-editor"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import postcss from 'postcss';
|
||||||
|
import selectorParser from 'postcss-selector-parser';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
// Retain declaration values, order and at-rule conditions; ignore comments and raw formatting only.
|
||||||
|
export function cssFingerprint(css) {
|
||||||
|
function nodeValue(node) {
|
||||||
|
if (node.type === 'comment') return null;
|
||||||
|
const value = { type: node.type };
|
||||||
|
for (const key of ['selector', 'name', 'params', 'prop', 'value', 'important']) {
|
||||||
|
if (node[key] !== undefined) value[key] = node[key];
|
||||||
|
}
|
||||||
|
if (node.nodes) value.nodes = node.nodes.map(nodeValue).filter(Boolean);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(nodeValue(postcss.parse(css.replaceAll('\r\n', '\n')))))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyframe(rule) {
|
||||||
|
for (let parent = rule.parent; parent; parent = parent.parent) {
|
||||||
|
if (parent.type === 'atrule' && /(?:^|-)keyframes$/i.test(parent.name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchored(selector, roots) {
|
||||||
|
// A positive class in the first compound must constrain the target itself or an ancestor.
|
||||||
|
// :not/:has and sibling combinators cannot establish ownership.
|
||||||
|
const nodes = selector.nodes.filter((node) => node.type !== 'comment');
|
||||||
|
const boundary = nodes.findIndex((node) => node.type === 'combinator');
|
||||||
|
const compound = boundary === -1 ? nodes : nodes.slice(0, boundary);
|
||||||
|
return (
|
||||||
|
compound.some((node) => node.type === 'class' && roots.includes(node.value)) &&
|
||||||
|
(boundary === -1 || [' ', '>'].includes(nodes[boundary].value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unscopedSelectors(css, roots) {
|
||||||
|
const findings = [];
|
||||||
|
postcss.parse(css).walkRules((rule) => {
|
||||||
|
if (isKeyframe(rule)) return;
|
||||||
|
selectorParser((selectors) => {
|
||||||
|
selectors.each((selector) => {
|
||||||
|
if (!anchored(selector, roots)) findings.push(selector.toString());
|
||||||
|
});
|
||||||
|
}).processSync(rule.selector);
|
||||||
|
});
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadBusinessSelectors(css) {
|
||||||
|
const findings = [];
|
||||||
|
postcss.parse(css).walkRules((rule) => {
|
||||||
|
if (isKeyframe(rule)) return;
|
||||||
|
selectorParser((selectors) => {
|
||||||
|
selectors.each((selector) => {
|
||||||
|
const roots = selector.nodes
|
||||||
|
.filter(
|
||||||
|
(node) =>
|
||||||
|
node.type === 'class' && !/^(?:selected|active|disabled|loading|item|title|card|is-.+)$/.test(node.value),
|
||||||
|
)
|
||||||
|
.map((node) => node.value);
|
||||||
|
if (!anchored(selector, roots)) findings.push(selector.toString());
|
||||||
|
});
|
||||||
|
}).processSync(rule.selector);
|
||||||
|
});
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localImports(file, code) {
|
||||||
|
if (file.endsWith('.css')) {
|
||||||
|
const imports = [];
|
||||||
|
postcss.parse(code).walkAtRules('import', (rule) => {
|
||||||
|
const match = rule.params.match(/^(?:url\(\s*)?['"]([^'"]+)['"]/);
|
||||||
|
if (!match) throw new Error(`${file}: CSS @import 必须使用明确的引号路径`);
|
||||||
|
imports.push(match[1]);
|
||||||
|
});
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
const imports = [];
|
||||||
|
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
|
||||||
|
function visit(node) {
|
||||||
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
|
||||||
|
if (ts.isStringLiteral(node.moduleSpecifier) && !node.importClause?.isTypeOnly && !node.isTypeOnly) {
|
||||||
|
imports.push(node.moduleSpecifier.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||||
|
if (node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) imports.push(node.arguments[0].text);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(source);
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFiles(root) {
|
||||||
|
return fs
|
||||||
|
.readdirSync(path.join(root, 'src'), { recursive: true })
|
||||||
|
.map((file) => `src/${String(file).replaceAll('\\', '/')}`)
|
||||||
|
.filter((file) => /\.(?:tsx?|css)$/.test(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredRootClasses(file, code) {
|
||||||
|
const classes = new Set();
|
||||||
|
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer) {
|
||||||
|
function collect(value) {
|
||||||
|
if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) {
|
||||||
|
for (const token of value.text.split(/\s+/)) classes.add(token);
|
||||||
|
}
|
||||||
|
ts.forEachChild(value, collect);
|
||||||
|
}
|
||||||
|
collect(node.initializer);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(source);
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importGraph(root) {
|
||||||
|
const files = sourceFiles(root);
|
||||||
|
const available = new Set(files);
|
||||||
|
const graph = new Map();
|
||||||
|
for (const file of files) {
|
||||||
|
const edges = [];
|
||||||
|
for (const specifier of localImports(file, fs.readFileSync(path.join(root, file), 'utf8'))) {
|
||||||
|
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue;
|
||||||
|
const target = specifier.startsWith('@/')
|
||||||
|
? `src/${specifier.slice(2)}`
|
||||||
|
: path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier));
|
||||||
|
const resolved = [target, `${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`].find(
|
||||||
|
(candidate) => available.has(candidate),
|
||||||
|
);
|
||||||
|
if (resolved) edges.push(resolved);
|
||||||
|
else if (/\.css$/.test(target)) throw new Error(`${file}: CSS import 不存在 ${specifier}`);
|
||||||
|
}
|
||||||
|
graph.set(file, edges);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyOwnership(root, policy) {
|
||||||
|
const graph = importGraph(root);
|
||||||
|
const reachable = new Set();
|
||||||
|
function visit(file) {
|
||||||
|
if (reachable.has(file)) return;
|
||||||
|
reachable.add(file);
|
||||||
|
for (const dependency of graph.get(file) ?? []) visit(dependency);
|
||||||
|
}
|
||||||
|
visit('src/main.tsx');
|
||||||
|
const cssFiles = [...graph.keys()].filter((file) => file.endsWith('.css'));
|
||||||
|
const records = new Map(policy.files.map((record) => [record.file, record]));
|
||||||
|
if (records.size !== policy.files.length) throw new Error('CSS 所有权清单包含重复文件');
|
||||||
|
for (const file of records.keys()) {
|
||||||
|
if (!cssFiles.includes(file)) throw new Error(`CSS 所有权登记文件不存在:${file}`);
|
||||||
|
}
|
||||||
|
for (const file of cssFiles) {
|
||||||
|
const record = records.get(file);
|
||||||
|
if (!record) throw new Error(`${file}: 必须登记 CSS 所有者和根类名`);
|
||||||
|
if (!reachable.has(file)) throw new Error(`${file}: 从 src/main.tsx 不可达`);
|
||||||
|
const owners = [...graph]
|
||||||
|
.filter(([, edges]) => edges.includes(file))
|
||||||
|
.map(([owner]) => owner)
|
||||||
|
.sort();
|
||||||
|
if (!owners.length || JSON.stringify(owners) !== JSON.stringify([...record.owners].sort())) {
|
||||||
|
throw new Error(`${file}: 实际 import 所有者与登记不一致:${owners.join(', ')}`);
|
||||||
|
}
|
||||||
|
const css = fs.readFileSync(path.join(root, file), 'utf8');
|
||||||
|
if (record.legacyFingerprint) {
|
||||||
|
if (!record.reason || !record.removalCondition) throw new Error(`${file}: 历史兼容必须记录原因及清理条件`);
|
||||||
|
if (cssFingerprint(css) !== record.legacyFingerprint) throw new Error(`${file}: 历史 CSS 内容与已评审基线不一致`);
|
||||||
|
} else {
|
||||||
|
if (!record.roots?.length) throw new Error(`${file}: 新 CSS 必须登记非空根类名`);
|
||||||
|
const declared = new Set(
|
||||||
|
owners
|
||||||
|
.filter((owner) => owner.endsWith('.tsx'))
|
||||||
|
.flatMap((owner) => [...declaredRootClasses(owner, fs.readFileSync(path.join(root, owner), 'utf8'))]),
|
||||||
|
);
|
||||||
|
if (record.roots.some((rootClass) => !declared.has(rootClass))) {
|
||||||
|
throw new Error(`${file}: 根类名必须出现在直接 TSX 所有者的 className 中`);
|
||||||
|
}
|
||||||
|
const findings = unscopedSelectors(css, record.roots);
|
||||||
|
if (findings.length) throw new Error(`${file}: 选择器未限定在所有者根节点:${findings.join(', ')}`);
|
||||||
|
if (file.startsWith('src/apps/') && owners.some((owner) => !owner.startsWith(`${path.posix.dirname(file)}/`))) {
|
||||||
|
throw new Error(`${file}: 新页面 CSS 必须由同目录所有者 import`);
|
||||||
|
}
|
||||||
|
postcss.parse(css).walkDecls((declaration) => {
|
||||||
|
if (declaration.important) throw new Error(`${file}: 新 CSS 禁止未登记的 !important`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.stylelintrc.json'), 'utf8'));
|
||||||
|
const compatible = policy.files
|
||||||
|
.filter((record) => record.stylelintLegacy)
|
||||||
|
.map((record) => record.file)
|
||||||
|
.sort();
|
||||||
|
if (
|
||||||
|
JSON.stringify(config.overrides?.[0]?.files?.slice().sort()) !== JSON.stringify(compatible) ||
|
||||||
|
config.overrides.length !== 1
|
||||||
|
) {
|
||||||
|
throw new Error('Stylelint 兼容范围必须与精确历史文件清单一致,禁止目录通配符和额外覆盖');
|
||||||
|
}
|
||||||
|
return { graph, cssFiles };
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import process from 'node:process';
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import postcss from 'postcss';
|
import postcss from 'postcss';
|
||||||
|
import { broadBusinessSelectors, verifyOwnership } from './css-policy.mjs';
|
||||||
|
export { broadBusinessSelectors } from './css-policy.mjs';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
@@ -24,18 +26,6 @@ export function analyzeCss(css) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadBusinessSelectors(css) {
|
|
||||||
const findings = [];
|
|
||||||
postcss.parse(css).walkRules((rule) => {
|
|
||||||
for (const selector of rule.selectors ?? [rule.selector]) {
|
|
||||||
if (/^(?:div|section|article|button|table|input|select|textarea)(?:\b|[ >+~:[.#])/.test(selector.trim())) {
|
|
||||||
findings.push(selector);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return findings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyGlobalCssAbsent(repoRoot) {
|
export function verifyGlobalCssAbsent(repoRoot) {
|
||||||
const legacy = path.join(repoRoot, 'src/styles/global.css');
|
const legacy = path.join(repoRoot, 'src/styles/global.css');
|
||||||
if (fs.existsSync(legacy)) throw new Error('src/styles/global.css 已完成迁移,禁止重新创建');
|
if (fs.existsSync(legacy)) throw new Error('src/styles/global.css 已完成迁移,禁止重新创建');
|
||||||
@@ -45,13 +35,6 @@ function read(relativePath) {
|
|||||||
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCssFiles(directory) {
|
|
||||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
||||||
const target = path.join(directory, entry.name);
|
|
||||||
return entry.isDirectory() ? currentCssFiles(target) : entry.name.endsWith('.css') ? [target] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function changedCssFiles() {
|
function changedCssFiles() {
|
||||||
if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] };
|
if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] };
|
||||||
const configuredBase = process.env.QUALITY_BASE_REF;
|
const configuredBase = process.env.QUALITY_BASE_REF;
|
||||||
@@ -89,6 +72,12 @@ function baseCss(base, file) {
|
|||||||
|
|
||||||
function verify() {
|
function verify() {
|
||||||
verifyGlobalCssAbsent(root);
|
verifyGlobalCssAbsent(root);
|
||||||
|
const policy = JSON.parse(read('tools/quality/css-ownership.json'));
|
||||||
|
const { graph } = verifyOwnership(root, policy);
|
||||||
|
const mainStyles = graph.get('src/main.tsx').filter((file) => file.endsWith('.css'));
|
||||||
|
if (JSON.stringify(mainStyles) !== JSON.stringify(policy.mainImportOrder)) {
|
||||||
|
throw new Error('应用入口 CSS 层级与已评审基线不一致');
|
||||||
|
}
|
||||||
const baseline = JSON.parse(read('tools/quality/css-governance-baseline.json'));
|
const baseline = JSON.parse(read('tools/quality/css-governance-baseline.json'));
|
||||||
const entry = read(baseline.entry);
|
const entry = read(baseline.entry);
|
||||||
const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) =>
|
const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) =>
|
||||||
@@ -132,22 +121,6 @@ function verify() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceText = currentCssFiles(path.join(root, 'src'))
|
|
||||||
.map((file) => fs.readFileSync(file, 'utf8'))
|
|
||||||
.join('\n');
|
|
||||||
const codeText = fs
|
|
||||||
.readdirSync(path.join(root, 'src'), { recursive: true })
|
|
||||||
.filter((name) => /\.(?:ts|tsx)$/.test(String(name)))
|
|
||||||
.map((name) => fs.readFileSync(path.join(root, 'src', String(name)), 'utf8'))
|
|
||||||
.join('\n');
|
|
||||||
for (const file of currentCssFiles(path.join(root, 'src'))) {
|
|
||||||
const relative = path.relative(path.join(root, 'src'), file).replace(/\\/g, '/');
|
|
||||||
if (relative === 'styles/tokens.css' || relative === 'styles/reset.css') continue;
|
|
||||||
if (!sourceText.includes(path.basename(file)) && !codeText.includes(path.basename(file))) {
|
|
||||||
throw new Error(`${relative} 没有明确 import 所有者`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicateCount = selectorList.length - new Set(selectorList).size;
|
const duplicateCount = selectorList.length - new Set(selectorList).size;
|
||||||
const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
|
const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
|
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
|
||||||
|
import { cssFingerprint, localImports, unscopedSelectors, verifyOwnership } from './css-policy.mjs';
|
||||||
|
import stylelint from 'stylelint';
|
||||||
|
|
||||||
test('AST metrics ignore formatting and count selectors and important declarations', () => {
|
test('AST metrics ignore formatting and count selectors and important declarations', () => {
|
||||||
assert.deepEqual(analyzeCss('.page { color: red !important; }\n.page a, .page button { display: block; }'), {
|
assert.deepEqual(analyzeCss('.page { color: red !important; }\n.page a, .page button { display: block; }'), {
|
||||||
@@ -31,3 +33,132 @@ test('global.css cannot be recreated after migration', () => {
|
|||||||
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
|
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unrooted tags, universal selectors, standalone states and sibling escapes are rejected', () => {
|
||||||
|
const selectors = ['body button', '* button', 'h1', '.selected', ':not(.page) button', '.page + button'];
|
||||||
|
assert.deepEqual(
|
||||||
|
broadBusinessSelectors(selectors.map((selector) => `${selector} { color: red; }`).join('\n')),
|
||||||
|
selectors,
|
||||||
|
);
|
||||||
|
assert.deepEqual(broadBusinessSelectors('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page ownership rejects unrelated classes and pseudo-class ownership tricks', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
unscopedSelectors('.page > button, .page.is-active, .page .row + .row { color: red; }', ['page']),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
for (const selector of ['.other', '.page + .other', ':has(.page)', ':is(.page, body)', '.page ~ button']) {
|
||||||
|
assert.deepEqual(unscopedSelectors(`${selector} { color: red; }`, ['page']), [selector]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fingerprint detects declaration values, media conditions, declaration order and important changes', () => {
|
||||||
|
const original = '@media (max-width: 780px) { .page { color: red; display: grid; } }';
|
||||||
|
for (const modified of [
|
||||||
|
original.replace('red', 'blue'),
|
||||||
|
original.replace('780px', '781px'),
|
||||||
|
original.replace('color: red; display: grid;', 'display: grid; color: red;'),
|
||||||
|
original.replace('red;', 'red !important;'),
|
||||||
|
]) {
|
||||||
|
assert.notEqual(cssFingerprint(original), cssFingerprint(modified));
|
||||||
|
}
|
||||||
|
assert.equal(cssFingerprint('.page{color:red}'), cssFingerprint('/* note */ .page {\n color: red;\n}'));
|
||||||
|
const multiline = '.page,\n.other { transition: color 1s,\n background 2s; }';
|
||||||
|
assert.equal(cssFingerprint(multiline), cssFingerprint(multiline.replaceAll('\n', '\r\n')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import parser ignores comments and strings and recognizes static, dynamic and CSS url imports', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
localImports(
|
||||||
|
'page.tsx',
|
||||||
|
`// import './fake.css';\nconst note = "real.css"; import './real.css'; const Page = import('./Page');`,
|
||||||
|
),
|
||||||
|
['./real.css', './Page'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(localImports('entry.css', `/* @import './fake.css'; */ @import url('./real.css');`), ['./real.css']);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-policy-'));
|
||||||
|
// Only this test-created, absolute temporary directory is removed.
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
function write(file, text) {
|
||||||
|
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(directory, file), text);
|
||||||
|
}
|
||||||
|
write('src/main.tsx', `import { Page } from './apps/Page';`);
|
||||||
|
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
|
||||||
|
write('src/apps/Page.css', '.page { color: red; }');
|
||||||
|
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: [] }] }));
|
||||||
|
const policy = { files: [{ file: 'src/apps/Page.css', owners: ['src/apps/Page.tsx'], roots: ['page'] }] };
|
||||||
|
return { directory, write, policy };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('new page with real reachable owner and matching roots passes without .git', (t) => {
|
||||||
|
const { directory, policy } = fixture(t);
|
||||||
|
assert.doesNotThrow(() => verifyOwnership(directory, policy));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment-only imports, disconnected imports and wrong same-name paths fail', (t) => {
|
||||||
|
const { directory, policy, write } = fixture(t);
|
||||||
|
write('src/apps/Page.tsx', `// import './Page.css';\nexport const Page = () => <div className="page" />;`);
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
|
||||||
|
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
|
||||||
|
write('src/main.tsx', 'const note = "Page.tsx";');
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
|
||||||
|
write('src/main.tsx', `import './apps/Page';`);
|
||||||
|
write('src/apps/Page.tsx', `import './missing/Page.css';`);
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /import 不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ownership registration must match exact importers and rendered class names', (t) => {
|
||||||
|
const { directory, policy, write } = fixture(t);
|
||||||
|
policy.files[0].owners = ['src/main.tsx'];
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /所有者与登记不一致/);
|
||||||
|
policy.files[0].owners = ['src/apps/Page.tsx'];
|
||||||
|
write(
|
||||||
|
'src/apps/Page.tsx',
|
||||||
|
`import './Page.css'; const note = 'page'; export const Page = () => <div className="different" />;`,
|
||||||
|
);
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /className/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unregistered files and new important fail in archive mode too', (t) => {
|
||||||
|
const { directory, policy, write } = fixture(t);
|
||||||
|
write('src/apps/Page.css', '.page { color: red !important; }');
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /important/);
|
||||||
|
write('src/apps/Page.css', '.page { color: red; }');
|
||||||
|
write('src/apps/Unknown.css', '.unknown { color: red; }');
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /必须登记/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historical declaration or media change fails even when AST counts match', (t) => {
|
||||||
|
const { directory, policy, write } = fixture(t);
|
||||||
|
Object.assign(policy.files[0], {
|
||||||
|
legacyFingerprint: cssFingerprint('.page { color: red; }'),
|
||||||
|
reason: 'legacy',
|
||||||
|
removalCondition: 'review',
|
||||||
|
});
|
||||||
|
assert.doesNotThrow(() => verifyOwnership(directory, policy));
|
||||||
|
write('src/apps/Page.css', '.page { color: blue; }');
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /历史 CSS 内容/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stylelint wildcard compatibility expansion is rejected', (t) => {
|
||||||
|
const { directory, policy, write } = fixture(t);
|
||||||
|
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: ['src/styles/*.css'] }] }));
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /精确历史文件清单/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new stylesheet under legacy directories actually receives strict Stylelint rules', async () => {
|
||||||
|
for (const file of ['src/styles/new-page.css', 'src/styles/domains/15-new-page.css', 'src/apps/NewPage.css']) {
|
||||||
|
const config = await stylelint.resolveConfig(file);
|
||||||
|
assert.deepEqual(config.rules['no-duplicate-selectors'], [true]);
|
||||||
|
const result = await stylelint.lint({
|
||||||
|
code: '.page { color: red; }\n.page { color: blue; }',
|
||||||
|
codeFilename: path.resolve(file),
|
||||||
|
});
|
||||||
|
assert.ok(result.results[0].warnings.some((warning) => warning.rule === 'no-duplicate-selectors'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user