2 Commits
Author SHA1 Message Date
hectorzhao 458ddd97df refactor: 完成全局CSS模块化治理
CSS quality / css-quality (push) Has been cancelled
2026-09-05 00:45:05 +08:00
hectorzhao 1a7a7245a6 fix: 修复报备导入映射与审核跳转 2026-09-05 00:20:33 +08:00
41 changed files with 18144 additions and 10016 deletions
+23
View File
@@ -0,0 +1,23 @@
name: CSS quality
on:
pull_request:
push:
branches: [main]
jobs:
css-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- name: Verify changed formatting and CSS ownership
env:
QUALITY_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || format('{0}^', github.sha) }}
run: npm run format:check && npm run style:check && npm run css:verify
+55
View File
@@ -0,0 +1,55 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": ["dist/**/*.css"],
"rules": {
"alpha-value-notation": null,
"color-function-notation": null,
"declaration-block-single-line-max-declarations": null,
"media-feature-range-notation": null,
"no-descending-specificity": null,
"selector-class-pattern": null
},
"overrides": [
{
"files": [
"src/styles/*.css",
"src/styles/domains/*.css",
"src/apps/admin/channels/AdminChannelsPage.css",
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
"src/apps/client/ClientUsersPage.css"
],
"rules": {
"at-rule-empty-line-before": null,
"block-no-empty": null,
"color-function-alias-notation": null,
"color-hex-length": null,
"comment-empty-line-before": null,
"custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-empty-line-before": null,
"declaration-property-value-keyword-no-deprecated": null,
"font-family-name-quotes": null,
"function-url-quotes": null,
"import-notation": null,
"keyframes-name-pattern": null,
"length-zero-no-unit": null,
"no-duplicate-selectors": null,
"number-max-precision": null,
"property-no-vendor-prefix": null,
"rule-empty-line-before": null,
"selector-attribute-quotes": null,
"selector-id-pattern": null,
"selector-not-notation": null,
"selector-pseudo-class-no-unknown": null,
"selector-type-no-unknown": null,
"shorthand-property-no-redundant-values": null,
"value-keyword-case": null,
"value-no-vendor-prefix": null
}
}
]
}
+44
View File
@@ -0,0 +1,44 @@
# CMPP 平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
## 开始工作前
- 先检查当前分支、最近提交、远端差异以及 staged、unstaged、untracked 文件。
- 保留其他会话和用户已有修改;禁止未经授权执行 reset、清理、覆盖、切分支、推送或部署。
- 功能事实必须以当前代码、真实 API、PostgreSQL、Redis、MinIO、Gateway 和目标环境为准,交接记录只作为线索。
- 不得发送、补发、重投或重新入队短信;不得擅自修改余额、通道或客户配置。
## CSS 任务强制阅读
凡新增、修改、迁移或审查 CSS,必须先完整阅读:
1. `docs/css-development-guidelines.md`
2. 涉及存量拆分时,再阅读 `docs/global-css-modularization-plan-20260904.md`
## CSS 所有权
- 设计变量归 `src/styles/tokens.css`
- 浏览器重置和标签基础规则归 `src/styles/reset.css`
- AppShell、侧栏、顶栏、导航和页面骨架归 `src/styles/shell.css`
- 跨业务复用且语义一致的公共组件样式归 `src/styles/components/`;迁移完成前的既有公共规则可继续位于明确登记的共享样式文件。
- 同一业务域多个页面共享的样式归业务域目录,并由业务域根类名限定。
- 只服务一个页面、弹窗或局部组件的样式必须放在其 TSX 同目录或对应业务目录,由所有者直接 import。
- `src/styles/global.css` 是存量兼容文件:只允许迁出、删除和保持视觉等价所必需的修正,不得新增页面或业务选择器。例外必须在变更说明中写明原因、影响范围和清理条件。
## CSS 禁止事项
- 禁止把新页面、新弹窗或新业务组件的选择器写入 `global.css`
- 禁止使用无业务根节点限定的标签选择器或短通用类名影响其他页面。
- 禁止通过新增 `!important`、提高 specificity 或依赖偶然加载顺序掩盖归属和级联问题;第三方不可控样式等例外必须记录原因、影响范围和移除条件。
- 禁止把公共组件样式建立在业务页面样式之上。
- 禁止对 `global.css` 执行整文件格式化,或在迁移提交中夹带视觉改版和无关重排。
- 禁止仅凭类名前缀批量移动选择器;必须同时核对 TSX 引用、其他 CSS 定义、媒体查询和真实 DOM。
## CSS 变更验收
- 提交前检查 staged diff,确保只包含本轮目标文件或精确 hunk。
- 运行与范围匹配的定向测试、前端全量测试、TypeScript、生产构建、仓库当前提供的格式和样式门禁以及 `git diff --check`;自动门禁尚未覆盖的项目按日常规范人工检查并记录。
- 页面级变更至少检查 1600×1000、1366×768 和 390×844;覆盖首次进入、刷新、跨路由切换及相关交互状态。
- 使用真实 API 和测试环境完成最终功能验收;构建成功、组件测试或隔离截图不能代替真实页面验收。
- 纯 CSS 拆分不得改变计算样式。发现差异时先恢复原级联关系,不得顺手调整设计。
@@ -18,6 +18,7 @@ import {
hasValue, hasValue,
normalizeImageExtension, normalizeImageExtension,
imageContentType, imageContentType,
duplicateCoreMappingKind,
} from './report-materials.helpers'; } from './report-materials.helpers';
import { ReportImportParserService } from './import-parser.service'; import { ReportImportParserService } from './import-parser.service';
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility'; import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
@@ -36,6 +37,17 @@ export class ReportImportReviewService {
if (!batch) throw new NotFoundException('导入批次不存在'); if (!batch) throw new NotFoundException('导入批次不存在');
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入'); if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射'); if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
const duplicateCoreKind = duplicateCoreMappingKind(data.mappings);
if (duplicateCoreKind) {
const label = {
signatureName: '短信签名',
purpose: '签名用途/依据',
siteName: '站点名称',
url: '引流 URL 或号码',
remark: '备注',
}[duplicateCoreKind];
throw new BadRequestException(`目标字段“${label}”只能映射一个源列`);
}
if (data.profile) if (data.profile)
await this.importParser.saveImportProfile({ await this.importParser.saveImportProfile({
...data.profile, ...data.profile,
@@ -0,0 +1,90 @@
import { ReportMaterialsService } from './report-materials.service';
import { duplicateCoreMappingKind, signatureCoreMapping } from './report-materials.helpers';
describe('report material import mappings', () => {
it('distinguishes signature category or purpose columns from the signature name', () => {
expect(signatureCoreMapping('签名/签名')).toMatchObject({
kind: 'signatureName',
code: 'signature_name',
});
expect(signatureCoreMapping('签名用途/签名用途')).toMatchObject({
kind: 'purpose',
code: 'purpose',
});
expect(signatureCoreMapping('签名类别1营业执照2商标3APP/签名类别')).toMatchObject({
kind: 'purpose',
code: 'purpose',
});
});
it('rejects duplicate core targets without blocking distinct dynamic fields', () => {
expect(
duplicateCoreMappingKind([
{
sourceHeader: '签名',
sourceColumnIndex: 1,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
},
{
sourceHeader: '签名类别',
sourceColumnIndex: 2,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
},
]),
).toBe('signatureName');
expect(
duplicateCoreMappingKind([
{
sourceHeader: '正面',
sourceColumnIndex: 1,
targetFieldCode: 'identity',
targetKind: 'dynamic',
fieldType: 'image',
},
{
sourceHeader: '反面',
sourceColumnIndex: 2,
targetFieldCode: 'identity',
targetKind: 'dynamic',
fieldType: 'image',
},
]),
).toBeUndefined();
});
it('rejects duplicate core mappings before loading the workbook', async () => {
const prisma = {
reportMaterialImportBatch: {
findUnique: jest.fn().mockResolvedValue({ id: 'batch-duplicate', status: 'analyzed' }),
},
};
const files = { getDownload: jest.fn() };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
await expect(
service.commitImport('batch-duplicate', {
mappings: [
{
sourceHeader: '签名',
sourceColumnIndex: 1,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
},
{
sourceHeader: '签名类别',
sourceColumnIndex: 2,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
},
],
}),
).rejects.toThrow('目标字段“短信签名”只能映射一个源列');
expect(files.getDownload).not.toHaveBeenCalled();
});
});
@@ -100,6 +100,16 @@ export function suggestMappings(
}); });
} }
export function duplicateCoreMappingKind(mappings: ImportMapping[]) {
const seen = new Set<ImportMapping['targetKind']>();
for (const mapping of mappings) {
if (mapping.targetKind === 'dynamic') continue;
if (seen.has(mapping.targetKind)) return mapping.targetKind;
seen.add(mapping.targetKind);
}
return undefined;
}
export function remapProfileColumns( export function remapProfileColumns(
profileColumns: ImportMapping[], profileColumns: ImportMapping[],
sourceColumns: Array<{ sourceColumns: Array<{
@@ -137,8 +147,8 @@ export function remapProfileColumns(
export function signatureCoreMapping( export function signatureCoreMapping(
header: string, header: string,
): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined { ): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/签名(?:用途|依据|类别|类型)|用途/.test(header)) return { code: 'purpose', kind: 'purpose' };
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true }; if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
return undefined; return undefined;
} }
+80
View File
@@ -0,0 +1,80 @@
# CSS 日常开发规范
## 1. 适用范围
本规范适用于新增、修改、迁移和审查前端 CSS。它是日常开发的强制规则;`global.css` 的存量拆分步骤另见 `global-css-modularization-plan-20260904.md`
原则只有两条:每条样式必须有唯一所有者;样式的生效范围必须和所有者范围一致。
## 2. 先判断归属
按以下顺序判断,命中后停止:
| 问题 | 归属 |
|---|---|
| 是否只是颜色、间距、字号、圆角、阴影等设计变量? | `src/styles/tokens.css`,只定义自定义属性 |
| 是否是浏览器重置或标签字体继承? | `src/styles/reset.css`,不得包含业务类名 |
| 是否属于 AppShell、侧栏、顶栏、导航、登录框架或页面容器? | `src/styles/shell.css` |
| 是否被多个业务域复用,且所有状态和视觉语义一致? | `src/styles/components/` 中对应公共组件文件 |
| 是否只在同一业务域的多个页面复用? | 业务域目录中的共享 CSS,并使用业务域根前缀 |
| 是否只服务一个页面、弹窗或局部组件? | 与所有者同目录的 CSS,由所有者直接 import |
| 是否无法确认用途的历史规则? | 暂留 `global.css`,先记录和查证,不得复制或新增 |
“看起来相似”不等于公共组件。只有 API、交互状态和视觉语义都一致,才能上移到公共层。
## 3. 命名和作用域
- 页面必须有唯一根类名,例如 `.admin-report-materials-page`
- 页面样式必须位于页面根类名下,避免路由切换后已加载 CSS 污染其他页面。
- 业务域共享样式使用稳定的业务域前缀;公共组件使用组件名,不使用 `item``title``card` 等无上下文短类名。
- 状态类使用所属组件限定,例如 `.report-field-card.is-selected`,不得单独定义全局 `.selected`
- 不新增无根节点限定的 `div``button``table` 等业务样式。
- 新代码不使用 ID 选择器控制样式。
## 4. Import 和级联
固定层级为:
```text
tokens → reset → shell → shared components → business shared → page styles
```
- 基础层和全局共享层只能由应用入口或明确的公共组件入口加载。
- 页面 CSS 由页面或其业务域入口直接 import,不得通过 `global.css` 间接加载。
- 公共组件不得依赖业务页面样式;业务页面可以覆盖公共组件,但必须位于页面根节点下。
- 相同 specificity 下的覆盖如果依赖加载顺序,必须在代码中保持稳定,并验证首次进入、刷新和跨路由跳转。
- 伪类、子选择器、动画和媒体查询跟随其所有者,不集中到新的全局文件。
## 5. 修改规则
- 优先复用 `tokens.css` 中已有变量;新增 token 必须具有跨页面意义。
- 不新增 `!important`。确有第三方样式等不可控原因时,需说明原因、影响范围和移除条件。
- 不通过不断嵌套或重复类名提高 specificity;应修正所有权、根节点或加载层级。
- 不在纯样式修复中顺手重排整个文件、改色、改字号或重做布局。
- 不复制已有规则来快速覆盖问题;先搜索同名选择器和全部消费者。
- 动态数值可使用 React `style` 或 CSS 自定义属性;稳定视觉规则仍应归入 CSS 所有者文件。
## 6. `global.css` 过渡规则
- 在完成自动门禁和相关脚本适配前,文件继续保留 `global.css` 名称,行为上视为只减不增的 legacy 文件。
- 允许:迁出选择器、删除已验证无用规则、为保持迁移前计算样式而做的最小修正。
- 禁止:新页面选择器、新业务组件、单页补丁、无根节点覆盖以及新的 `!important`
- 例外必须在变更说明中记录原因、影响页面、验证证据和后续清理条件;不能只写“临时处理”。
- 禁止整文件 Prettier;迁出的新文件应单独格式化。
## 7. 开发和审查清单
提交前至少确认:
1. 样式文件和选择器有明确所有者。
2. 页面及业务样式具有根节点命名空间。
3. 没有向 `global.css` 增加页面或业务规则。
4. 没有新的无说明 `!important`、宽泛标签选择器或跨层依赖。
5. 修改共享样式时已列出并检查所有消费者。
6. hover、focus、disabled、loading、selected、danger、空数据和错误状态没有遗漏。
7. 1600×1000、1366×768、390×844 下没有新增溢出、遮挡和层级错误。
8. 首次进入、刷新和跨路由切换后的计算样式一致。
9. 已运行 CSS 格式与样式门禁、相关测试、TypeScript、生产构建和 `git diff --check`
10. staged diff 没有整文件重排或其他会话的修改。
在自动门禁正式落地前,第 3、4、5、8 项必须人工检查,不能因工具尚未覆盖而跳过。
@@ -0,0 +1,47 @@
# 全局 CSS 模块化实施结果
## 结果
2026-09-05 完成 `src/styles/global.css` 的全量迁移。原文件已删除,业务样式由 `src/styles/domains/index.css` 按固定顺序加载14个所有权模块;设计变量、重置、Shell、既有运营端/客户端共享层和公共组件层继续按 `main.tsx` 的原层级加载。
## 模块与所有者
| 顺序 | 文件 | 所有权 |
|---:|---|---|
| 1 | `01-operations-dashboard.css` | Gateway异常辅助界面与运营/客户端概览 |
| 2 | `02-client-sending.css` | 客户端短信/彩信发送和批次任务列表 |
| 3 | `03-client-records.css` | 客户端任务详情、发送记录和上行 |
| 4 | `04-signatures.css` | 签名和引流资料体验 |
| 5 | `05-templates.css` | 短信模板和彩信模板 |
| 6 | `06-auth-enterprise.css` | 登录、风险确认、用户表单和企业认证 |
| 7 | `07-admin-operations.css` | 运营详情、应用路由、企业档案和短信审核 |
| 8 | `08-reporting.css` | 报备资料、导入映射、批次和字段选择 |
| 9 | `09-channels.css` | 通道指标、通道组和路由 |
| 10 | `10-signature-quality.css` | 签名退订与发送质量矩阵 |
| 11 | `11-deliveries-reporting.css` | 下游投递、报表任务和彩信运营页 |
| 12 | `12-admin-configuration.css` | 上行、账务、用户、号段和报备字段库 |
| 13 | `13-client-signatures.css` | 客户端签名和引流资料 |
| 14 | `14-responsive-requeue.css` | 原共享响应式尾段和下游恢复工作台 |
模块顺序是原 `global.css` 顶层节点顺序的连续分区,不得按名称重新排序。媒体查询、伪类、子选择器和动画均随原节点迁移。
## 等价证据
- 原始基线:213062字节、9846行、1749条规则、2017个选择器、5321条声明、30个`!important`
- 模块化后的AST总量和完整选择器顺序与原基线一致。
- 迁移前后Vite主CSS产物均为 `index-CkSy6WDi.css`SHA-256均为 `d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`,字节比较无差异。
- 机器可读证据位于 `tools/quality/css-governance-baseline.json`,自动核验入口为 `npm run css:verify`
## 自动门禁
- `npm run format:check`:增量Prettier现同时覆盖CSS。
- `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。
- `npm run css:verify`:验证`global.css`不得重建、固定导入顺序、AST基线、选择器顺序、`!important`增长、宽泛业务标签规则和import所有权,并执行允许/拒绝样例测试。
- `.github/workflows/css-quality.yml`:PR和main推送均以完整提交范围运行格式、Stylelint和CSS治理门禁。
## 维护边界
- 新页面、弹窗和业务组件继续使用同目录CSS并由所有者直接import。
- 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。
- 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。
- 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主CSS产物或关键计算样式等价。
@@ -0,0 +1,457 @@
# `global.css` 分模块治理方案
## 1. 文档目的
本方案用于治理当前前端全局样式文件 `src/styles/global.css` 体积过大、页面样式归属不清、格式化容易产生大范围无关差异、跨页面覆盖难以追踪的问题。
本方案定义拆分策略、实施批次、验证方法和回退边界。2026-09-05 已按本文完成首次全量治理,实施结果和长期维护方式见 `css-modularization-result-20260905.md`;后续仍须遵守本文的所有权、门禁和验收规则。
本方案与以下规范共同生效:
- 根目录 `AGENTS.md`:定义整个仓库必须遵守的 CSS 所有权、禁止事项和验收底线。
- `docs/css-development-guidelines.md`:定义日常新增和修改 CSS 时的归属决策、命名、import、级联和审查规则。
- 本文档:只负责存量 `global.css` 的渐进拆分、验证和回退。
实施顺序必须是“强制规则 → 自动门禁 → 视觉基线 → 存量拆分”。不得先大规模移动 CSS,再补规则和门禁。
## 2. 当前事实基线
截至 2026-09-04,只读核验结果如下:
- `src/styles/global.css`:约 213,062 字节、9,846 行。
- 该文件最早创建于提交 `2f3c274``Initial CMPP frontend prototype`),创建时已经约 6,267 行、136KB,并非由一个很小的基础样式文件自然增长而来。
- 最近 30 天约有 30 个提交修改该文件,累计约增加 975 行、删除 274 行,是前端高频冲突文件。
- `src/main.tsx` 当前按顺序全局加载:
1. `tokens.css`
2. `reset.css`
3. `shell.css`
4. `global.css`
5. `admin.css`
6. `client.css`
7. `components.css`
- 2026-07-31 的提交 `ca4f591` 曾拆出 `reset.css``shell.css``admin.css``client.css`,但拆分没有持续完成。目前 `client.css` 仅 8 行,大量运营端、客户端和业务组件样式仍留在 `global.css`
- 当前已经存在少量页面级 CSS:短信通道、企业应用、安全检测、短信记录、短信任务进度、系统监控和客户端用户页面。这说明项目已具备页面就近管理样式的实现基础。
当前问题主要影响开发、审查和协作效率,不直接等同于浏览器运行性能问题。
## 3. 治理目标
### 3.1 主要目标
1.`global.css` 从业务样式总仓库降级为只减不增的临时兼容入口,最终清空并删除。
2. 每个页面或业务域的样式有唯一、可定位的归属文件。
3. 修改一个页面时,不再需要格式化、审查或合并近万行全局 CSS。
4. 保持现有页面视觉、交互、响应式和打印/导出效果不变。
5. 建立规则,防止新业务样式重新写回全局文件。
### 3.2 建议量化标准
- 第一阶段完成后:`global.css` 降至 6,000 行以内。
- 第二阶段完成后:`global.css` 降至 2,000 行以内。
- 最终阶段:`global.css` 清零并删除,或仅保留不超过 300 行、经过明确评审的全局兼容规则。
- 单个页面 CSS 建议不超过 800 行。
- 单个业务域 CSS 建议不超过 1,200 行;超过后继续按页面或组件拆分。
- 不允许业务页面通过无前缀标签选择器影响其他页面。
## 4. 不在本次治理中处理的内容
以下事项不能与 CSS 拆分同时进行:
- 页面重新设计、颜色调整、字号调整或布局优化。
- React 组件大规模重构。
- 后端 API、数据库、Redis、短信链路或权限逻辑修改。
- 将全部普通 CSS 一次性改造成 CSS Modules、Tailwind 或 CSS-in-JS。
- 删除看起来“可能无用”但没有运行时证据的选择器。
- 对整份历史 CSS 执行一次性 Prettier 重排并混入拆分提交。
拆分阶段的核心原则是“只改变样式存放位置,不改变最终计算样式”。
## 5. 目标目录结构
建议继续使用项目当前的普通 CSS 和页面前缀命名方式,不在第一轮引入新的样式技术栈。在通用门禁和所有硬编码路径完成适配前,保留 `global.css` 文件名,行为上将其视为 legacy 文件;不得为了目录整齐过早重命名。
```text
src/
├─ styles/
│ ├─ tokens.css # 设计变量,仅允许 CSS 自定义属性
│ ├─ reset.css # 标签重置和浏览器一致性
│ ├─ shell.css # AppShell、侧栏、顶栏、导航、页面容器
│ ├─ utilities.css # 少量明确、稳定的工具类
│ ├─ components/
│ │ ├─ buttons.css # 通用 UI Button
│ │ ├─ forms.css # Input、Select、Textarea、校验提示
│ │ ├─ modal.css # Modal、Drawer、Popover
│ │ ├─ table.css # 通用表格、分页、空状态
│ │ ├─ cards.css # 通用卡片与统计卡
│ │ └─ feedback.css # Tag、Alert、Toast、Loading
│ └─ global.css # 拆分过程中的临时剩余区,只减不增,治理后删除
├─ apps/
│ ├─ admin/
│ │ ├─ reporting/ # 报备资料、批次、字段映射、状态记录
│ │ ├─ enterprise-signatures/ # 企业签名、引流资料、退订/质量状态
│ │ ├─ channels/ # 通道、通道组、通道详情
│ │ ├─ sms-records/ # 短信记录与详情
│ │ ├─ sms-task-progress/ # 批次任务与进度
│ │ ├─ monitoring/ # 系统监控、服务监控、日志
│ │ ├─ risk/ # 风控、黑名单、敏感词、安全检测
│ │ └─ billing/ # 充值、对账、利润与账务页面
│ └─ client/
│ ├─ dashboard/
│ ├─ send/
│ ├─ signatures/
│ ├─ templates/
│ ├─ billing/
│ └─ users/
```
目录名称应优先复用现有页面目录,避免仅为样式拆分同步搬动 TSX 文件。
现有 `components.css``admin.css``client.css` 应先完成所有权清单,再决定迁入目录、拆分或保留;不得在旧文件仍承担相同职责时直接创建第二套重复公共样式。
## 6. 样式归属规则
日常开发以 `docs/css-development-guidelines.md` 的归属决策为准。本节用于拆分时判断存量选择器的目标位置。
| 样式类型 | 目标文件 | 规则 |
|---|---|---|
| 颜色、间距、字号、圆角、阴影变量 | `tokens.css` | 只定义变量,不放页面选择器 |
| `html``body`、标题、表单字体继承等浏览器重置 | `reset.css` | 不包含业务类名 |
| 侧栏、导航、页面框架、登录框架 | `shell.css` | 仅负责应用骨架,不负责页面业务卡片 |
| Button、Input、Modal、Tag、Pagination 等通用组件 | `styles/components/*.css` | 选择器必须与通用组件类名一致 |
| 只被一个页面使用的样式 | 页面同目录 CSS | 由该页面 TSX 直接 import |
| 同一业务域多个页面共享的样式 | 业务域目录中的共享 CSS | 必须带业务域根前缀 |
| 尚未完成归属确认的旧样式 | `global.css` | 行为上作为 legacy 文件,只允许迁出、删除和保持视觉等价所需的最小修正 |
禁止继续向 `global.css` 增加以下内容:
- 新页面样式。
- 新业务组件样式。
- 为解决单页问题而增加的无页面根节点限定覆盖。
- 新的 `!important`,除非有单独说明和回归用例。
## 7. 拆分前置工作
### 7.1 建立选择器清单
`global.css` 中每个顶层选择器记录:
- 选择器名称。
- 被哪些 TSX 文件引用。
- 是否存在重复定义。
- 是否出现在媒体查询中。
- 是否依赖前后定义顺序。
- 建议目标文件。
- 是否存在无法确认用途的情况。
不能仅凭类名猜测归属。必须通过 `rg`、构建产物和真实页面 DOM 共同确认。
### 7.2 建立视觉基线
至少保存以下尺寸下的真实页面截图和关键元素尺寸:
- 桌面:1600×1000。
- 常见笔记本:1366×768。
- 窄屏:390×844。
基线页面至少包括:
- 运营看板。
- 短信通道列表和报备详情。
- 报备字段库。
- 报备资料池、批次、通道报备明细和状态记录。
- 企业签名管理及查看资料弹窗。
- 短信记录和详情弹窗。
- 系统监控、安全检测和高风险操作弹窗。
- 客户端首页、发送短信、签名、模板、账务和用户页面。
- 运营端与客户端登录页。
使用真实 API 和测试环境数据进行最终验收;隔离截图数据只能用于布局比较,不能替代真实功能验收。
### 7.3 冻结无关格式化
拆分完成前:
- 禁止对 `global.css` 执行整文件 `prettier --write`
- 每个提交必须检查 `git diff --stat` 和 CSS diff;出现明显超出迁移范围的大量重排立即停止。
- 增加“本提交是否只移动目标选择器”的人工检查项。
- 不通过关闭全部格式检查规避问题;应对新文件执行格式检查,对历史遗留文件采用迁出即格式化策略。
### 7.4 建立治理基线
正式拆分前必须提交一份机器可读的基线,至少记录:
- `global.css` 的字节数、规则数、选择器数和声明数。
- 已知允许暂留的全局基础选择器。
- 已拆分页面的根类名和目标样式文件。
- 当前 CSS import 层级和入口顺序。
- 临时例外的原因、影响范围和清理条件。
基线比较必须使用 PostCSS AST;行数和字节数只作为辅助指标,不能把注释或换行变化误判为新增业务规则。
## 8. 分阶段实施方案
### 阶段 0:建立防扩散门禁
目标:在正式拆分前停止继续恶化。
1. 提交根目录 `AGENTS.md``docs/css-development-guidelines.md`,明确规则已开始生效。
2. 保留 `global.css` 文件名并增加“只减不增”文件头;先不重命名,避免破坏当前硬编码该路径的质量脚本。
3. 扩展增量格式脚本,使新增和修改的 CSS 进入 Prettier 检查。
4. 引入 Stylelint:新文件严格执行;`global.css` 初期使用兼容配置,不要求一次清理全部历史问题。
5. 新增基于 PostCSS AST 的通用静态检查,阻止向 `global.css` 增加页面/业务选择器、无说明 `!important` 和宽泛业务标签规则。
6. 建立增长基线和显式例外清单;无例外时,`global.css` 的规则、选择器、声明和字节数均不得无说明增长。
7. CI 使用目标分支 merge-base 或显式 `QUALITY_BASE_REF` 比较完整提交范围,不能只比较工作区相对 `HEAD` 的未提交差异。
8. 建立 CSS 选择器与页面引用清单,并登记已拆分页面的根类名和所有者文件。
9. 新页面必须使用同目录 CSS;共享样式必须先通过归属判断。
验收标准:规则文档可以被 Codex 和开发者直接执行;本地质量命令与 CI 都能拦截故意加入的违规样例;删除违规样例后全部通过;不改变任何计算样式,所有现有页面和构建结果保持一致。
### 阶段 1:基础层和应用骨架去重
目标:先迁移风险较低、归属明确的全局基础规则。
1. 对比 `global.css``reset.css``shell.css``components.css` 中重复的基础规则。
2. 将标签级重置统一到 `reset.css`
3. 将侧栏、顶栏、导航、页面容器和登录框架统一到 `shell.css`
4. 同名规则只在确认计算样式一致后删除旧定义。
风险重点:加载顺序和相同 specificity 下的后定义覆盖。
### 阶段 2:通用 UI 组件拆分
目标:将跨页面真正复用的组件从业务样式中分离。
建议依次拆分:
1. 按钮、图标按钮和操作按钮组。
2. Input、Select、Textarea、查询栏和错误提示。
3. Modal、Drawer、确认弹窗和弹窗 Footer。
4. Tag、Alert、Loading、Empty State。
5. Table、Pagination、统计卡和详情列表。
每迁出一类组件,需要搜索所有调用页面,验证 hover、focus、disabled、loading、danger 和窄屏状态。
### 阶段 3:报备工作台业务域
优先拆分近期最活跃、冲突概率最高的报备相关样式:
- `report-*`
- `channel-field-*`
- `channel-selected-*`
- `channel-export-*`
- `report-material-*`
- `report-batch-*`
- 报备资料查看、导入、导出格式选择弹窗
建议建立:
```text
src/apps/admin/reporting/
├─ report-workbench.css
├─ report-field-mapping.css
├─ report-materials.css
├─ report-batches.css
└─ report-records.css
```
本阶段必须特别验证:
- 字段池单卡片固定高度。
- 导出字段顺序和按钮宽度。
- 图片预览和历史字段展示。
- 状态记录大屏免横向滚动布局。
- 资料池、批次和明细页面的桌面/窄屏响应式。
### 阶段 4:企业签名和通道域
拆分:
- `signature-*`
- `enterprise-signature-*`
- `carrier-report-*`
- `channel-*` 中不属于报备字段弹窗的通道管理样式
- 通道组、通道状态、质量矩阵和签名三网状态样式
需要先区分“通道管理共享样式”和“报备工作台专用样式”,避免仅按 `channel-` 前缀批量移动。
### 阶段 5:短信、任务、监控和风险域
按现有页面目录继续迁移:
1. 短信发送、短信记录和发送详情。
2. 批次任务、任务进度和下游恢复/重投页面。
3. 系统日志、系统监控和服务指标。
4. 黑名单、敏感词、风险规则和安全检测。
5. 充值、对账、利润与账务页面。
已存在页面 CSS 的模块先做“从 global.css 迁出遗漏规则”,不重新创建第二份页面样式文件。
### 阶段 6:客户端域
`client.css` 从当前 8 行的占位文件调整为客户端共享层,并优先把页面专用规则放到具体客户端页面目录。
重点防止运营端和客户端使用相同短类名时互相覆盖。共享规则必须确认视觉语义一致,否则分别使用 `.admin-*``.client-*` 根前缀。
### 阶段 7:清理遗留文件
1. 对剩余选择器逐项确认引用。
2. 无引用选择器必须经过构建、DOM 检查和真实页面核验后按小批次删除。
3. 检查重复媒体查询、重复属性和无效覆盖。
4. 删除空的 `global.css` 及其入口引用。
5. 更新项目开发规范和测试进度。
## 9. 单批次迁移方法
每一批只处理一个页面或一个可独立验证的组件族:
1. 记录目标选择器及其原始行号。
2. 搜索 TSX 使用位置和其他 CSS 中的同名定义。
3. 将完整规则连同伪类、子选择器和媒体查询一起移动。
4. 在目标页面或业务域入口显式 import 新 CSS,并确保页面具有唯一根类名命名空间。
5. 保持选择器文本、属性顺序和 import 后的级联顺序不变。
6. 运行定向测试、TypeScript 和生产构建。
7. 在三个尺寸下比较迁移前后页面。
8. 检查浏览器控制台、网络请求、滚动和弹窗层级。
9. 独立提交,不夹带业务代码修改。
修改共享样式时必须列出全部已知消费者,并逐一检查关键状态;不能只验证提出本次修改的页面。
禁止使用正则一次性移动所有相同前缀,因为同一前缀可能跨多个页面或业务域。
## 10. 级联和加载顺序策略
CSS 拆分最大的风险不是文件移动本身,而是加载顺序变化。
建议最终固定顺序:
```text
tokens → reset → shell → shared components → business shared → page styles
```
注意事项:
- 相同 specificity 的规则依赖“后加载覆盖”,移动前必须记录原顺序。
- 页面级 CSS 由懒加载页面 import 时,要验证首次进入、刷新、跨页面跳转后的样式一致性。
- SPA 中已经加载的页面 CSS 通常不会在离开路由后卸载,因此页面样式必须位于唯一页面根类名下,不能把正确性建立在“当前页面最后加载”之上。
- 不依靠提高 specificity 或新增 `!important` 掩盖顺序错误。
- 公共组件样式不能反向依赖业务页面样式。
- 媒体查询应跟随其所属组件迁移,不集中到新的全局响应式文件。
## 11. 测试与验收要求
### 11.1 自动化检查
每批至少执行:
- 与页面匹配的 Vitest/Testing Library 用例。
- 前端全量测试。
- TypeScript 检查。
- Vite 生产构建。
- Bundle 预算检查。
- CSS/代码格式检查,仅覆盖新文件和本批实际修改文件。
- Stylelint;新文件执行严格规则,遗留文件执行过渡规则。
- `global.css` AST 增长和禁止选择器检查。
- 页面根类名、样式所有者和 import 关系检查。
- `git diff --check`
- 选择器引用检查和重复选择器报告。
门禁实现要求:
- 本地增量检查可覆盖 staged、unstaged 和 untracked CSS;提交前以 staged diff 为最终范围。
- CI 必须比较目标分支 merge-base 到当前提交,不能依赖默认 `HEAD` 导致已提交变化漏检。
- `global.css` 增长判断以 PostCSS AST 为主,行数和字节数为辅。
- 例外采用仓库内显式清单,至少包含选择器或规则、原因、影响范围和清理条件;不接受只有提交信息中的“临时处理”。
- 门禁脚本自身必须包含允许样例和拒绝样例测试。
### 11.2 浏览器验收
构建成功不能等同于拆分成功。必须检查:
- 真实页面是否加载对应 CSS 资源。
- 页面首次进入和路由切换后的样式是否一致。
- loading、空数据、失败、权限不足和历史数据状态。
- Modal、Drawer、下拉框、Sticky 表头和滚动容器。
- hover、focus、disabled、selected 和 danger 状态。
- 桌面端、笔记本和窄屏。
- 浏览器控制台无新增错误和样式资源 404。
- 页面没有新增横向溢出、遮挡或层级错误。
### 11.3 视觉差异判定
- 纯文件拆分批次的目标是关键页面像素级无变化。
- 允许字体抗锯齿等环境噪声,但布局尺寸、间距、颜色、折行、滚动和交互状态必须一致。
- 发现视觉变化时优先恢复原级联顺序,不允许顺手“优化得更好看”后继续合并。
## 12. Git 与协作策略
- 每个提交只拆一个页面或一个组件族。
- 提交名称示例:`refactor: extract report field mapping styles`
- 提交前检查 staged diff,只包含目标 CSS、新 import、测试和文档。
- 不在拆分提交中执行全仓库格式化。
- 不删除或覆盖其他会话的 staged、unstaged、untracked 文件。
- CSS 门禁例外必须与对应变更同批审查;不得用扩大例外范围的方式让无关规则通过。
- 主分支持续开发时,优先迁移冲突最频繁的业务域,并缩短分支存活时间。
- 每批可独立 `git revert`,不要求整体回退全部拆分阶段。
## 13. 风险与应对
| 风险 | 表现 | 应对 |
|---|---|---|
| CSS 加载顺序变化 | 颜色、间距或布局悄然变化 | 固定 import 顺序并比较 computed style |
| 选择器漏迁 | 某些弹窗或窄屏状态失去样式 | 伪类、子选择器、媒体查询按选择器族迁移 |
| 同名选择器跨页面复用 | 拆走后其他页面异常 | TSX/CSS 双向搜索,无法确认时暂留 legacy |
| 懒加载 CSS 顺序不稳定 | 跨页面跳转后样式变化 | 页面根节点命名空间隔离,验证刷新和跳转 |
| 整文件格式化污染 diff | 数千行无关修改 | 禁止格式化 legacy,迁出后只格式化新文件 |
| 误删历史样式 | 历史数据或低频页面显示异常 | 删除单独分批,真实历史数据页面复验 |
| 拆分期间业务需求冲突 | 合并困难、重复搬运 | 小提交、短周期、按高频业务域优先实施 |
## 14. 回退方案
每个迁移批次的回退单位是对应独立提交:
1. 浏览器验收失败时停止后续拆分。
2. 保留截图、控制台和具体选择器差异证据。
3. 回退当批提交,恢复原 import 和原文件位置。
4. 不通过追加高优先级覆盖掩盖错误。
5. 修正选择器归属或加载顺序后重新提交该批次。
CSS 拆分不涉及数据库迁移、服务配置或短信链路,因此不应触发数据库回滚、队列处理或短信重投。
## 15. 建议实施顺序与工作量
| 阶段 | 内容 | 预计工作量 |
|---|---|---:|
| 0 | AGENTS、日常规范、清单、视觉基线、防扩散门禁 | 2~3 人日 |
| 1 | Reset、Shell、基础层去重 | 12 人日 |
| 2 | 通用 UI 组件拆分 | 24 人日 |
| 3 | 报备工作台 | 2~3 人日 |
| 4 | 企业签名和通道 | 2~3 人日 |
| 5 | 短信、任务、监控、风险、账务 | 3~5 人日 |
| 6 | 客户端页面 | 2~3 人日 |
| 7 | 遗留清理、全量回归和规范固化 | 2~3 人日 |
整体建议按 2~4 周的渐进式治理安排实施,而不是一次性大提交。实际工期取决于测试环境登录态、历史数据覆盖和视觉回归工具可用性。
## 16. 完成标准
只有同时满足以下条件,才能认为治理完成:
1. `global.css` 已删除,或只保留经过批准且不超过 300 行的真正全局规则。
2. 所有业务样式均有清晰的页面或业务域归属。
3. 新业务样式写入遗留全局文件会被门禁阻止。
4. 根目录 `AGENTS.md` 和日常 CSS 规范已成为当前生效规则,自动门禁与其一致。
5. CSS 已纳入增量 Prettier、Stylelint、所有权和 AST 增长检查,本地及 CI 均能识别完整变更范围。
6. 前端全量测试、TypeScript、生产构建和包体积检查通过。
7. 核心运营端、客户端页面在三种尺寸下完成真实浏览器回归。
8. 浏览器控制台和网络请求无新增错误,CSS 资源无 404。
9. 报备状态记录的大屏布局、字段池固定高度、Sticky 表头和所有关键弹窗没有回归。
10. `docs/testing-progress.md` 和相关系统功能用例已同步更新。
## 17. 2026-09-05 实施状态
- `src/styles/global.css` 已删除,入口替换为 `src/styles/domains/index.css`;原1749条规则、2017个选择器、5321条声明和30个既有`!important`按原顶层节点顺序迁入14个业务域文件。
- 迁移前后Vite主CSS产物名称、字节内容和SHA-256完全一致,证明本次只改变源码所有权与文件边界,没有改变最终CSS级联结果。
- `tools/quality/css-governance-baseline.json`保存原始AST指标、完整选择器顺序、模块所有者、导入顺序和模块摘要;`verify-css-governance.mjs`验证旧文件不得重建、AST总量及顺序不变、既有`!important`不得增长、新CSS不得增加宽泛标签选择器、所有CSS必须存在明确import。
- CSS已进入增量Prettier和Stylelint。迁出的历史规则使用显式兼容清单保留现状;未登记的新CSS默认执行Stylelint标准规则,因此兼容配置不会自动扩散到新页面。
- 新增GitHub CSS质量工作流,以目标分支或父提交作为`QUALITY_BASE_REF`,避免只检查未提交工作区导致已提交违规变化漏检。
- 原方案建议逐批迁移;本次为满足一次性执行要求,采用单次原序抽取。由于14个模块共同组成一条完整且经产物哈希证明等价的级联链,保持为一个可整体回退的原子变更,避免中间提交出现规则重复或缺失。
+14
View File
@@ -5049,6 +5049,20 @@ npm run verify:phase8
| TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 | | TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 |
| TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 | | TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 |
| TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 | | TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 |
| TC-HFQ-011 | 批量导入签名资料,源文件同时包含“签名”和“签名类别/签名用途”列 | 自动映射分别指向“短信签名”和“签名用途/依据”;同一核心目标不得被两个源列重复映射,提交后自动进入本次导入批次审核详情,签名名称不得被类别值覆盖 |
| TC-HFQ-012 | 在导入字段映射中展开或收起“查看前10行解析预览” | 预览内容在弹窗内独立滚动,目标字段下拉框及映射表列宽在展开前后保持不变,桌面及窄屏均不产生页面级横向溢出 |
| TC-HFQ-013 | 使用无扩展的干净浏览器打开批量导入流程并检查控制台 | 应用源码及产物不包含`reportAllChanges`;不得出现平台自身脚本错误。若仅扩展注入的匿名`VM*`脚本报`startTime`错误,应与平台功能错误分开记录,不得通过全局吞错掩盖 |
## TC-CSS-MODULARIZATION-20260905 全局样式模块化治理
| 用例ID | 场景 | 预期 |
|---|---|---|
| TC-CSS-MOD-001 | 构建模块化前后的前端生产资源并比较主CSS | 主CSS字节内容和SHA-256一致,规则、选择器、声明、`!important`数量及选择器顺序与AST基线一致 |
| TC-CSS-MOD-002 | 在`src/styles/global.css`新增业务规则或重新创建该文件 | CSS治理门禁失败并明确提示旧文件禁止重建 |
| TC-CSS-MOD-003 | 在新页面CSS加入无根节点的`button`规则或新增`!important` | CSS治理门禁失败;删除违规样例后恢复通过 |
| TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入PrettierStylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 |
| TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 |
| TC-CSS-MOD-006 | 在1600×1000、1366×768和390×844检查核心页面 | 页面无新增横向溢出、遮挡、错位或层级问题,窄屏交互仍可访问 |
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强 ## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
+17
View File
@@ -4503,3 +4503,20 @@ git diff --check
- 新migration已执行,测试环境由95项增至96项;`cmpp-report-material-worker`已安装并保持`active/running``NRestarts=0``Result=success`。API、Gateway、其他Worker、Nginx、PostgreSQL、Redis、MinIO和Prometheus均正常,发布窗口API及新Worker无error级journal;三条短信相关Redis Stream发布前后均为`pending=0 / lag=0`,没有发送、补发、重投或重新入队短信。 - 新migration已执行,测试环境由95项增至96项;`cmpp-report-material-worker`已安装并保持`active/running``NRestarts=0``Result=success`。API、Gateway、其他Worker、Nginx、PostgreSQL、Redis、MinIO和Prometheus均正常,发布窗口API及新Worker无error级journal;三条短信相关Redis Stream发布前后均为`pending=0 / lag=0`,没有发送、补发、重投或重新入队短信。
- 真实HTTP上传20,976,525字节的`行业报备.xlsx`在约0.32秒内返回`202 / queued / 10%`,独立Worker随后完成解析并持久化`analyzed / 100% / 解析完成`,结果为13列、16条数据行、43张图片;错误租户查询返回404。将同一任务模拟为UTC口径的超时心跳后重启Worker,任务从70%恢复并重新完成到100%,验证了中断恢复。 - 真实HTTP上传20,976,525字节的`行业报备.xlsx`在约0.32秒内返回`202 / queued / 10%`,独立Worker随后完成解析并持久化`analyzed / 100% / 解析完成`,结果为13列、16条数据行、43张图片;错误租户查询返回404。将同一任务模拟为UTC口径的超时心跳后重启Worker,任务从70%恢复并重新完成到100%,验证了中断恢复。
- 真实Chrome页面从企业签名管理发起同一WPS文件上传,网络证据为`202 queued 10%`后轮询至`200 analyzed 100%`,映射弹窗显示13列和合计43张图片;1600×1000桌面端及390×844窄屏均无页面级横向溢出,浏览器控制台无warning/error。浏览器会话关闭后,临时管理员已从数据库删除且剩余数为0;验收任务和对应`FileObject`保留为可追溯测试证据。预生产本轮未访问或修改。 - 真实Chrome页面从企业签名管理发起同一WPS文件上传,网络证据为`202 queued 10%`后轮询至`200 analyzed 100%`,映射弹窗显示13列和合计43张图片;1600×1000桌面端及390×844窄屏均无页面级横向溢出,浏览器控制台无warning/error。浏览器会话关闭后,临时管理员已从数据库删除且剩余数为0;验收任务和对应`FileObject`保留为可追溯测试证据。预生产本轮未访问或修改。
## 2026-09-05 签名导入映射、审核跳转及预览宽度修复(本地提交)
- 测试环境两个真实导入批次的只读证据显示:首次批次因源文件没有映射“签名用途”而在审核阶段明确报缺少必填资料;第二次批次把“签名*”和“签名类别*(1-营业执照/2-商标/3-APP)”同时自动映射到`signatureName`,导致19条暂存签名名均被类别值`1`覆盖。解析和提交接口均已成功返回,问题根因是通用“签名”规则先于用途/类别规则匹配,以及核心字段缺少重复映射保护,不是浏览器缓存或异步任务未执行。
- 自动映射改为优先识别签名用途、依据、类别和类型,再识别签名名称;前后端同时拒绝两个源列映射到同一核心目标字段,动态资料字段仍允许按既有兼容逻辑重复。提交成功后页面直接进入审核中心“导入批次审核”并自动打开本次批次详情,避免用户误以为没有进入审核。
- 映射弹窗新增页面私有样式约束,解析预览和映射表都使用可收缩容器,长预览内容在自身区域滚动,展开“查看前10行解析预览”不再改变目标字段下拉框宽度;没有向`global.css`新增选择器。
- 控制台所示`VM*:2``reportAllChanges``startTime`调用栈不在仓库源码、依赖声明或构建入口中,形态与浏览器动态注入的Web Vitals采集脚本一致。本轮没有增加全局错误吞噬或修改业务代码掩盖该外部异常;后续真实浏览器验收需用无扩展会话区分平台脚本和注入脚本。
- 定向前端组件测试2项、定向API映射测试3项通过;前端全量13套65项、API全量55套619项通过;前后端TypeScript、API生产构建、Vite生产构建、依赖安全、部署契约、结构质量及增量ESLint通过。Vite仅保留既有Chart分块超过500kB提示,增量ESLint仅保留3条既有Hook依赖warning。本轮按用户最终要求只本地提交,不推送、不部署测试环境或预生产,不操作短信链路、余额、通道及客户配置。
## 2026-09-05 全局CSS模块化治理(本地实施与发布前验收完成)
- 重新读取根目录`AGENTS.md``docs/css-development-guidelines.md`和完整拆分方案后执行。原`global.css`为213062字节、9846行,经PostCSS统计为1749条规则、2017个选择器、5321条声明和30个既有`!important`;现已删除并按原顶层节点顺序迁入14个所有权模块,由`src/styles/domains/index.css`固定顺序加载。各业务域文件均不超过1200行建议值。
- 迁移没有改变CSS级联:格式化新模块并重新生产构建后,迁移前后主CSS均为`index-CkSy6WDi.css`SHA-256均为`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`,工作站逐字节比较无差异。机器基线保存完整选择器顺序、模块指标、所有者、摘要、既有例外和入口顺序。
- 新增Stylelint及显式历史兼容范围;未登记新CSS默认执行标准规则。增量Prettier现覆盖CSS,跨平台调用改为直接执行Node入口。CSS治理门禁阻止重建`global.css`、改变模块顺序或AST总量、新增未登记`!important`、新增宽泛业务标签选择器及无import所有者文件;Node四项门禁测试和实际临时违规样例均验证为能拒绝,样例随后删除。
- 新增GitHub CSS质量工作流,在PR中比较目标分支、main推送中比较父提交,避免只检查工作区。实施结果、模块所有权和后续维护边界记录于`docs/css-modularization-result-20260905.md`,方案文档同步改为已实施状态。
- 发布前真实Chrome基线使用测试环境真实API和PostgreSQL数据:运营端13个核心路由、客户端10个核心路由分别在1600×1000、1366×768、390×844检查,共69张截图;页面均非空、无框架错误层、无页面级横向溢出,控制台warning/error为0。临时平台管理员和企业管理员仅用于验收,发布后将精确删除。
- 前端全量13套65项、API全量55套619项、CSS治理4项通过;前后端TypeScript、API/Vite生产构建、Stylelint、增量ESLint/Prettier、依赖安全、部署契约、结构质量、包体积和`git diff --check`通过。入口gzip 107.85KiB,低于250KiB预算;只保留既有Chart分块超过500kB提示。发布、推送、发布后69页三尺寸回归、恢复资产、服务/Stream和临时账号清理结果将在完成后追加。
+1128
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -22,9 +22,11 @@
"test:frontend:coverage": "vitest run --coverage", "test:frontend:coverage": "vitest run --coverage",
"test:auth-redis-integration": "node tools/testing/verify-auth-redis-multi-instance.mjs", "test:auth-redis-integration": "node tools/testing/verify-auth-redis-multi-instance.mjs",
"test:gateway": "npm run spike:gateway", "test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit", "lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && npm run style:check && npm run css:verify && tsc --noEmit",
"format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format", "format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format",
"quality:verify": "node tools/quality/verify-code-quality.mjs", "quality:verify": "node tools/quality/verify-code-quality.mjs",
"style:check": "stylelint \"src/**/*.css\"",
"css:verify": "node tools/quality/verify-css-governance.mjs && node --test tools/quality/verify-css-governance.test.mjs",
"bundle:verify": "node tools/quality/verify-bundle-budget.mjs", "bundle:verify": "node tools/quality/verify-bundle-budget.mjs",
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build", "verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
"verify:phase2": "npm run verify:phase1", "verify:phase2": "npm run verify:phase1",
@@ -64,6 +66,8 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"msw": "^2.15.0", "msw": "^2.15.0",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"stylelint": "^16.23.1",
"stylelint-config-standard": "^38.0.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.68.0", "typescript-eslint": "^8.68.0",
"vitest": "^4.1.11" "vitest": "^4.1.11"
+7 -1
View File
@@ -218,7 +218,13 @@ export const adminChannelsReportsApi = {
id: string, id: string,
body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } },
) => ) =>
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { request<{
id: string;
reportType: 'signature' | 'drainage';
status: string;
successCount: number;
failedCount: number;
}>(`/admin/report-materials/imports/${id}/commit`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -49,7 +49,6 @@ export function AdminEnterpriseSignaturesPage() {
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null); const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null);
const listRequestSequence = useRef(0); const listRequestSequence = useRef(0);
@@ -89,7 +88,9 @@ export function AdminEnterpriseSignaturesPage() {
.catch((failure: Error) => { .catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业及应用选项加载失败'); if (!cancelled) setError(failure.message || '企业及应用选项加载失败');
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -326,7 +327,6 @@ export function AdminEnterpriseSignaturesPage() {
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<div className="surface section-stack"> <div className="surface section-stack">
<Tabs <Tabs
@@ -386,9 +386,8 @@ export function AdminEnterpriseSignaturesPage() {
{importOpen ? ( {importOpen ? (
<ReportMaterialImportModal <ReportMaterialImportModal
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
onCompleted={() => { onCompleted={(result) => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次'); navigate(`/admin/signatures?tab=import&batchId=${encodeURIComponent(result.id)}&imported=1`);
void loadData();
}} }}
/> />
) : null} ) : null}
+297 -59
View File
@@ -1,7 +1,24 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Eye, Search, X } from 'lucide-react'; import { Eye, Search, X } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi'; import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import {
AuditReviewInfo,
Breadcrumb,
Button,
DateRangeInput,
FileActions,
Input,
Modal,
RiskAction,
Select,
Table,
Tabs,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { ReportImportAuditPanel } from './ReportImportAuditPanel'; import { ReportImportAuditPanel } from './ReportImportAuditPanel';
@@ -13,16 +30,18 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
}; };
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} }
function fileRef(value: unknown): FileRef | null { function fileRef(value: unknown): FileRef | null {
const item = asRecord(value); const item = asRecord(value);
return item.fileObjectId && item.fileName ? { return item.fileObjectId && item.fileName
fileObjectId: String(item.fileObjectId), ? {
fileName: String(item.fileName), fileObjectId: String(item.fileObjectId),
contentType: item.contentType ? String(item.contentType) : undefined, fileName: String(item.fileName),
} : null; contentType: item.contentType ? String(item.contentType) : undefined,
}
: null;
} }
function canReviewSignature(item: ClientSmsSignature) { function canReviewSignature(item: ClientSmsSignature) {
@@ -33,59 +52,142 @@ function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose:
const payload = asRecord(item.drainageInfo); const payload = asRecord(item.drainageInfo);
const profile = asRecord(payload.signatureProfile); const profile = asRecord(payload.signatureProfile);
const values = asRecord(payload.signatureReportValues); const values = asRecord(payload.signatureReportValues);
const profileFiles = ['credentialFile', 'legalFrontFile', 'legalBackFile', 'responsibleFrontFile', 'responsibleBackFile'] const profileFiles = [
'credentialFile',
'legalFrontFile',
'legalBackFile',
'responsibleFrontFile',
'responsibleBackFile',
]
.map((key) => fileRef(profile[key])) .map((key) => fileRef(profile[key]))
.filter((value): value is FileRef => Boolean(value)); .filter((value): value is FileRef => Boolean(value));
const materialFiles: FileRef[] = (item.materials ?? []).flatMap((value) => { const materialFiles: FileRef[] = (item.materials ?? []).flatMap((value) => {
const material = asRecord(value); const material = asRecord(value);
return material.fileObjectId ? [{ return material.fileObjectId
fileObjectId: String(material.fileObjectId), ? [
fileName: String(material.title ?? material.fileName ?? material.fileObjectId), {
contentType: material.contentType ? String(material.contentType) : undefined, fileObjectId: String(material.fileObjectId),
}] : []; fileName: String(material.title ?? material.fileName ?? material.fileObjectId),
contentType: material.contentType ? String(material.contentType) : undefined,
},
]
: [];
}); });
const files = [...new Map([...profileFiles, ...materialFiles].map((file) => [file.fileObjectId, file])).values()]; const files = [...new Map([...profileFiles, ...materialFiles].map((file) => [file.fileObjectId, file])).values()];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="签名审核详情"> return (
<div className="page-stack"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="签名审核详情">
<div className="detail-grid"> <div className="page-stack">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div> <div className="detail-grid">
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div> <div>
<div><span></span><strong>{item.name}</strong></div> <span></span>
<div><span></span><Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}</Tag></div> <strong>{item.tenant?.name ?? item.tenantId}</strong>
<div><span></span><strong>{String(profile.basis ?? '-')}</strong></div> </div>
<div><span></span><strong>{String(profile.companyName ?? '-')}</strong></div> <div>
<div><span></span><strong>{String(profile.creditCode ?? '-')}</strong></div> <span></span>
<div><span></span><strong>{String(profile.legalPersonName ?? '-')}</strong></div> <strong>{item.application?.name ?? '-'}</strong>
<div><span></span><strong>{String(profile.responsibleName ?? '-')}</strong></div> </div>
<div><span></span><strong>{String(profile.responsiblePhone ?? '-')}</strong></div> <div>
<AuditReviewInfo targetId={item.id} targetType="sms_signature" /> <span></span>
{item.rejectReason ? <div className="detail-grid__wide"><span></span><strong>{item.rejectReason}</strong></div> : null} <strong>{item.name}</strong>
</div>
<div>
<span></span>
<Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>
{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}
</Tag>
</div>
<div>
<span></span>
<strong>{String(profile.basis ?? '-')}</strong>
</div>
<div>
<span></span>
<strong>{String(profile.companyName ?? '-')}</strong>
</div>
<div>
<span></span>
<strong>{String(profile.creditCode ?? '-')}</strong>
</div>
<div>
<span></span>
<strong>{String(profile.legalPersonName ?? '-')}</strong>
</div>
<div>
<span></span>
<strong>{String(profile.responsibleName ?? '-')}</strong>
</div>
<div>
<span></span>
<strong>{String(profile.responsiblePhone ?? '-')}</strong>
</div>
<AuditReviewInfo targetId={item.id} targetType="sms_signature" />
{item.rejectReason ? (
<div className="detail-grid__wide">
<span></span>
<strong>{item.rejectReason}</strong>
</div>
) : null}
</div>
<div className="surface" style={{ padding: 16 }}>
<strong></strong>
<div className="table-actions" style={{ marginTop: 12 }}>
{files.length ? (
files.map((file) => <FileActions file={file} key={file.fileObjectId} />)
) : (
<span className="muted"></span>
)}
</div>
</div>
<div className="surface" style={{ padding: 16 }}>
<strong></strong>
<div className="detail-grid" style={{ marginTop: 12 }}>
{Object.entries(values).length ? (
Object.entries(values).map(([key, value]) => (
<div key={key}>
<span>{key}</span>
<strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong>
</div>
))
) : (
<span className="muted"></span>
)}
</div>
</div>
</div> </div>
<div className="surface" style={{ padding: 16 }}><strong></strong><div className="table-actions" style={{ marginTop: 12 }}>{files.length ? files.map((file) => <FileActions file={file} key={file.fileObjectId} />) : <span className="muted"></span>}</div></div> </Modal>
<div className="surface" style={{ padding: 16 }}><strong></strong><div className="detail-grid" style={{ marginTop: 12 }}>{Object.entries(values).length ? Object.entries(values).map(([key, value]) => <div key={key}><span>{key}</span><strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong></div>) : <span className="muted"></span>}</div></div> );
</div>
</Modal>;
} }
export function AdminSignatureAuditPage() { export function AdminSignatureAuditPage() {
const [searchParams, setSearchParams] = useSearchParams();
const requestedImportBatchId = searchParams.get('batchId') ?? undefined;
const [activeTab, setActiveTab] = useState(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');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({}); const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue }); const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
status: 'pending',
submittedDateRange: {} as DateRangeValue,
});
const [detail, setDetail] = useState<ClientSmsSignature>(); const [detail, setDetail] = useState<ClientSmsSignature>();
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>(); const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData(filters = appliedFilters) { function loadData(filters = appliedFilters) {
adminApi.listEnterpriseSignatures({ adminApi
keyword: filters.keyword, .listEnterpriseSignatures({
status: filters.status === 'all' ? undefined : filters.status, keyword: filters.keyword,
submittedAtFrom: filters.submittedDateRange.start, status: filters.status === 'all' ? undefined : filters.status,
submittedAtTo: filters.submittedDateRange.end, submittedAtFrom: filters.submittedDateRange.start,
}) submittedAtTo: filters.submittedDateRange.end,
.then((records) => { setItems(records); setError(''); }) })
.then((records) => {
setItems(records);
setError('');
})
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败')); .catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
} }
@@ -95,26 +197,162 @@ export function AdminSignatureAuditPage() {
async function reject() { async function reject() {
if (!rejectTarget || !reason.trim()) return; if (!rejectTarget || !reason.trim()) return;
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); } try {
await adminApi.rejectSignature(rejectTarget.id, reason.trim());
setRejectTarget(undefined);
setReason('');
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名审核驳回失败');
}
} }
const columns = useMemo<Array<TableColumn<ClientSmsSignature>>>(() => [ const columns = useMemo<Array<TableColumn<ClientSmsSignature>>>(
{ key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> }, () => [
{ key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId }, { key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> },
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' }, { key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) }, { key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> }, { key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={() => loadData()} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button></div> }, {
], []); key: 'status',
title: '状态',
render: (record) => (
<Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>
{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}
</Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">
</Button>
<RiskAction
disabled={!canReviewSignature(record)}
onCompleted={() => loadData()}
targetId={record.id}
targetType="signature"
/>
<Button
disabled={!canReviewSignature(record)}
icon={<X size={15} />}
onClick={() => setRejectTarget(record)}
size="sm"
variant="danger"
>
</Button>
</div>
),
},
],
[],
);
return <section className="page-stack admin-template-audit-page"> return (
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1></h1></div></div> <section className="page-stack admin-template-audit-page">
{error ? <p className="form-error">{error}</p> : null} <div className="page-heading">
<Tabs items={[ <div>
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} }); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> }, <Breadcrumb items={['审核中心', '短信签名审核']} />
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> }, <h1></h1>
]} /> </div>
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null} </div>
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost"></Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger"></Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回签名审核"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal> {searchParams.get('imported') === '1' ? (
</section>; <p className="form-success" role="status">
</p>
) : null}
{error ? <p className="form-error">{error}</p> : null}
<Tabs
onChange={(value) => {
setActiveTab(value);
setSearchParams(value === 'import' ? { tab: 'import' } : {});
}}
value={activeTab}
items={[
{
label: '单条签名审核',
value: 'single',
content: (
<div className="page-stack">
<div className="surface audit-filter-card">
<div className="ui-filter-row">
<Input
label="搜索"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、应用或签名"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
label="审核状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' },
{ label: '草稿', value: 'draft' },
]}
value={status}
/>
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button
icon={<Search size={17} />}
onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setStatus('pending');
setSubmittedDateRange({});
setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} });
}}
variant="ghost"
>
</Button>
</div>
</div>
</div>
<div className="surface">
<Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" />
</div>
</div>
),
},
{
label: '导入批次审核',
value: 'import',
content: <ReportImportAuditPanel initialBatchId={requestedImportBatchId} reportType="signature" />,
},
]}
/>
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
<Modal
footer={
<>
<Button onClick={() => setRejectTarget(undefined)} variant="ghost">
</Button>
<Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger">
</Button>
</>
}
onClose={() => setRejectTarget(undefined)}
open={Boolean(rejectTarget)}
title="驳回签名审核"
>
<Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} />
</Modal>
</section>
);
} }
+300 -83
View File
@@ -1,7 +1,19 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react'; import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi'; import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
import { Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import {
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
@@ -32,8 +44,17 @@ function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'dan
return status === 'pending_review' ? 'info' : 'neutral'; return status === 'pending_review' ? 'info' : 'neutral';
} }
export function ReportImportAuditPanel({ reportType }: { reportType: 'signature' | 'drainage' }) { export function ReportImportAuditPanel({
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>({ items: [], total: 0, page: 1, pageSize: 20 }); reportType,
initialBatchId,
}: {
reportType: 'signature' | 'drainage';
initialBatchId?: string;
}) {
const initialBatchHandled = useRef(false);
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>(
{ items: [], total: 0, page: 1, pageSize: 20 },
);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 20; const pageSize = 20;
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
@@ -47,19 +68,25 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
const [error, setError] = useState(''); const [error, setError] = useState('');
function load(targetPage = page) { function load(targetPage = page) {
adminApi.listReportImportReviewBatches({ adminApi
reportType, .listReportImportReviewBatches({
status: status === 'all' ? undefined : status, reportType,
keyword: keyword.trim() || undefined, status: status === 'all' ? undefined : status,
startAt: submittedDateRange.start, keyword: keyword.trim() || undefined,
endAt: submittedDateRange.end, startAt: submittedDateRange.start,
page: targetPage, endAt: submittedDateRange.end,
pageSize, page: targetPage,
}).then((result) => { pageSize,
setData(result); })
setError(''); .then((result) => {
if (detail) setDetail(result.items.find((item) => item.id === detail.id)); setData(result);
}).catch((failure: Error) => setError(failure.message || '导入审核批次加载失败')); setError('');
if (!initialBatchHandled.current && initialBatchId) {
setDetail(result.items.find((item) => item.id === initialBatchId));
initialBatchHandled.current = true;
} else if (detail) setDetail(result.items.find((item) => item.id === detail.id));
})
.catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
} }
useEffect(() => { useEffect(() => {
@@ -98,78 +125,268 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
} }
} }
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(() => [ const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(
{ key: 'file', title: '导入批次/文件', render: (record) => <div><strong>{record.id}</strong><div className="muted">{record.fileName}</div></div> }, () => [
{ key: 'tenant', title: '企业/应用', render: (record) => <div><strong>{record.tenant?.name ?? record.tenantId}</strong><div className="muted">{record.application?.name ?? '未指定应用'}</div></div> }, {
{ key: 'summary', title: '明细统计', render: (record) => { key: 'file',
const approved = record.items.filter((item) => item.status === 'approved').length; title: '导入批次/文件',
const pending = record.items.filter((item) => item.status === 'pending_review').length; render: (record) => (
const abnormal = record.items.filter((item) => item.status === 'invalid').length; <div>
return `${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`; <strong>{record.id}</strong>
} }, <div className="muted">{record.fileName}</div>
{ key: 'status', title: '状态', render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag> }, </div>
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) }, ),
{ key: 'actions', title: '操作', align: 'right', render: (record) => <Button icon={<Eye size={15} />} onClick={() => { },
setDetail(record); {
setSelected(new Set()); key: 'tenant',
}} size="sm" variant="ghost"></Button> }, title: '企业/应用',
], []); render: (record) => (
<div>
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<div className="muted">{record.application?.name ?? '未指定应用'}</div>
</div>
),
},
{
key: 'summary',
title: '明细统计',
render: (record) => {
const approved = record.items.filter((item) => item.status === 'approved').length;
const pending = record.items.filter((item) => item.status === 'pending_review').length;
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
return `${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
},
},
{
key: 'status',
title: '状态',
render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag>,
},
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<Button
icon={<Eye size={15} />}
onClick={() => {
setDetail(record);
setSelected(new Set());
}}
size="sm"
variant="ghost"
>
</Button>
),
},
],
[],
);
const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [ const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [
{ key: 'select', title: '', width: '48px', render: (item) => <input aria-label={`选择第${item.rowNumber}`} checked={selected.has(item.id)} disabled={item.status !== 'pending_review'} onChange={() => setSelected((current) => { {
const next = new Set(current); key: 'select',
if (next.has(item.id)) next.delete(item.id); else next.add(item.id); title: '',
return next; width: '48px',
})} type="checkbox" /> }, render: (item) => (
<input
aria-label={`选择第${item.rowNumber}`}
checked={selected.has(item.id)}
disabled={item.status !== 'pending_review'}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
})
}
type="checkbox"
/>
),
},
{ key: 'row', title: '文件行', width: '80px', render: (item) => `${item.rowNumber}` }, { key: 'row', title: '文件行', width: '80px', render: (item) => `${item.rowNumber}` },
{ key: 'name', title: reportType === 'signature' ? '签名' : '引流url或号码', render: (item) => <strong>{itemName(item)}</strong> }, {
{ key: 'operation', title: '变更类型', width: '100px', render: (item) => item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效' }, key: 'name',
{ key: 'status', title: '状态', width: '110px', render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag> }, title: reportType === 'signature' ? '签名' : '引流url或号码',
render: (item) => <strong>{itemName(item)}</strong>,
},
{
key: 'operation',
title: '变更类型',
width: '100px',
render: (item) => (item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效'),
},
{
key: 'status',
title: '状态',
width: '110px',
render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag>,
},
{ key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' }, { key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' },
]; ];
return <div className="page-stack"> return (
{error ? <p className="form-error">{error}</p> : null} <div className="page-stack">
<div className="surface audit-filter-card"> {error ? <p className="form-error">{error}</p> : null}
<div className="ui-filter-row"> <div className="surface audit-filter-card">
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} /> <div className="ui-filter-row">
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[ <Input
{ label: '全部状态', value: 'all' }, label="批次号/文件名"
{ label: '待审核', value: 'pending_review' }, onChange={(event) => setKeyword(event.target.value)}
{ label: '部分已审核', value: 'partially_reviewed' }, placeholder="搜索导入批次或文件"
{ label: '已通过', value: 'approved' }, value={keyword}
{ label: '部分通过', value: 'partially_approved' }, />
{ label: '已驳回', value: 'rejected' }, <Select
]} value={status} /> label="批次状态"
<DateRangeInput label="提交时间" onChange={(value) => { setSubmittedDateRange(value); setPage(1); }} value={submittedDateRange} /> onChange={(event) => {
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}></Button><Button onClick={() => { setKeyword(''); setStatus('all'); setSubmittedDateRange({}); setPage(1); }} variant="ghost"></Button></div> setStatus(event.target.value);
</div> setPage(1);
</div> }}
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div> options={[
<Pagination { label: '全部状态', value: 'all' },
nextDisabled={page * pageSize >= data.total} { label: '待审核', value: 'pending_review' },
onNext={() => setPage((current) => current + 1)} { label: '部分已审核', value: 'partially_reviewed' },
onPageChange={setPage} { label: '已通过', value: 'approved' },
onPrevious={() => setPage((current) => Math.max(1, current - 1))} { label: '部分通过', value: 'partially_approved' },
page={page} { label: '已驳回', value: 'rejected' },
previousDisabled={page <= 1} ]}
total={data.total} value={status}
totalPages={Math.max(1, Math.ceil(data.total / pageSize))} />
/> <DateRangeInput
{detail ? <Modal footer={<><Button onClick={() => setDetail(undefined)} variant="ghost"></Button><Button disabled={busy || pendingItems.length === 0} icon={<X size={15} />} onClick={() => setRejectOpen(true)} variant="danger">{selected.size ? `驳回所选(${selected.size}` : `驳回全部可审核项(${pendingItems.length}`}</Button><Button disabled={busy || pendingItems.length === 0} icon={<Check size={15} />} onClick={() => void review('approve')} variant="success">{selected.size ? `通过所选(${selected.size}` : `通过全部可审核项(${pendingItems.length}`}</Button></>} onClose={() => setDetail(undefined)} open size="xl" title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}> label="提交时间"
<div className="page-stack"> onChange={(value) => {
<div className="detail-grid"> setSubmittedDateRange(value);
<div><span></span><strong>{detail.fileName}</strong></div> setPage(1);
<div><span></span><strong>{detail.tenant?.name ?? detail.tenantId}</strong></div> }}
<div><span></span><strong>{detail.application?.name ?? '未指定应用'}</strong></div> value={submittedDateRange}
<div><span></span><strong>{formatDateTime(detail.createdAt)}</strong></div> />
<div className="audit-filter-actions ui-filter-actions">
<Button
icon={<Search size={16} />}
onClick={() => {
setPage(1);
load(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setSubmittedDateRange({});
setPage(1);
}}
variant="ghost"
>
</Button>
</div>
</div> </div>
<label className="table-actions"><input checked={allPendingSelected} onChange={() => setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))} type="checkbox" /></label>
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
</div> </div>
</Modal> : null} <div className="surface">
{rejectOpen ? <Modal footer={<><Button onClick={() => setRejectOpen(false)} variant="ghost"></Button><Button disabled={busy} onClick={() => void review('reject')} variant="danger"></Button></>} onClose={() => setRejectOpen(false)} open title="驳回导入资料"> <Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" />
<Textarea label="驳回原因(选填)" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /> </div>
</Modal> : null} <Pagination
</div>; nextDisabled={page * pageSize >= data.total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
previousDisabled={page <= 1}
total={data.total}
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
/>
{detail ? (
<Modal
footer={
<>
<Button onClick={() => setDetail(undefined)} variant="ghost">
</Button>
<Button
disabled={busy || pendingItems.length === 0}
icon={<X size={15} />}
onClick={() => setRejectOpen(true)}
variant="danger"
>
{selected.size ? `驳回所选(${selected.size}` : `驳回全部可审核项(${pendingItems.length}`}
</Button>
<Button
disabled={busy || pendingItems.length === 0}
icon={<Check size={15} />}
onClick={() => void review('approve')}
variant="success"
>
{selected.size ? `通过所选(${selected.size}` : `通过全部可审核项(${pendingItems.length}`}
</Button>
</>
}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{detail.fileName}</strong>
</div>
<div>
<span></span>
<strong>{detail.tenant?.name ?? detail.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{detail.application?.name ?? '未指定应用'}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(detail.createdAt)}</strong>
</div>
</div>
<label className="table-actions">
<input
checked={allPendingSelected}
onChange={() =>
setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))
}
type="checkbox"
/>
</label>
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
{rejectOpen ? (
<Modal
footer={
<>
<Button onClick={() => setRejectOpen(false)} variant="ghost">
</Button>
<Button disabled={busy} onClick={() => void review('reject')} variant="danger">
</Button>
</>
}
onClose={() => setRejectOpen(false)}
open
title="驳回导入资料"
>
<Textarea
label="驳回原因(选填)"
onChange={(event) => setReason(event.target.value)}
rows={4}
value={reason}
/>
</Modal>
) : null}
</div>
);
} }
@@ -0,0 +1,16 @@
.report-material-import-modal .report-import-mapping,
.report-material-import-modal .report-import-mapping-table,
.report-material-import-modal .report-import-preview {
max-width: 100%;
min-width: 0;
}
.report-material-import-modal .report-import-mapping-table {
width: 100%;
}
.report-material-import-modal .report-import-preview pre {
box-sizing: border-box;
max-width: 100%;
width: 100%;
}
@@ -6,6 +6,7 @@ import { ReportMaterialImportModal } from './ReportMaterialImportModal';
const { adminApi } = vi.hoisted(() => ({ const { adminApi } = vi.hoisted(() => ({
adminApi: { adminApi: {
analyzeReportMaterialImport: vi.fn(), analyzeReportMaterialImport: vi.fn(),
commitReportMaterialImport: vi.fn(),
getReportMaterialImportAnalysisStatus: vi.fn(), getReportMaterialImportAnalysisStatus: vi.fn(),
listDrainageFields: vi.fn(), listDrainageFields: vi.fn(),
listEnterpriseApplicationOptions: vi.fn(), listEnterpriseApplicationOptions: vi.fn(),
@@ -43,6 +44,13 @@ describe('ReportMaterialImportModal mapping profile action', () => {
rows: [], rows: [],
suggestedMappings: [], suggestedMappings: [],
}); });
adminApi.commitReportMaterialImport.mockResolvedValue({
id: 'analysis-1',
reportType: 'signature',
status: 'pending_review',
successCount: 1,
failedCount: 0,
});
}); });
it('renders the reusable mapping choice as a clear pressed-state shared button', async () => { it('renders the reusable mapping choice as a clear pressed-state shared button', async () => {
@@ -70,4 +78,73 @@ describe('ReportMaterialImportModal mapping profile action', () => {
); );
expect(screen.getByLabelText('映射方案名称')).toBeVisible(); expect(screen.getByLabelText('映射方案名称')).toBeVisible();
}, 10_000); }, 10_000);
it('keeps signature category separate from the signature name and submits the review batch', async () => {
adminApi.getReportMaterialImportAnalysisStatus.mockResolvedValue({
id: 'analysis-1',
status: 'analyzed',
progress: 100,
progressStage: '解析完成',
columns: [
{ sourceColumnIndex: 1, columnLetter: 'A', sourceHeader: '签名*', sourceHeaderPath: '签名*', imageCount: 0 },
{
sourceColumnIndex: 2,
columnLetter: 'B',
sourceHeader: '签名类别*',
sourceHeaderPath: '签名类别*\n1-营业执照\n2-商标\n3-APP',
imageCount: 0,
},
],
rows: [{ rowNumber: 2, values: { 1: '【真实签名】', 2: '1' }, imageColumns: [] }],
suggestedMappings: [
{
sourceHeader: '签名*',
sourceHeaderPath: '签名*',
sourceColumnIndex: 1,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
},
{
sourceHeader: '签名类别*',
sourceHeaderPath: '签名类别*\n1-营业执照\n2-商标\n3-APP',
sourceColumnIndex: 2,
targetFieldCode: 'purpose',
targetKind: 'purpose',
fieldType: 'string',
},
],
});
const user = userEvent.setup();
const onCompleted = vi.fn();
render(<ReportMaterialImportModal onClose={vi.fn()} onCompleted={onCompleted} />);
await waitFor(() => expect(adminApi.listTenantOptions).toHaveBeenCalledTimes(1));
await user.click(screen.getByText('所属企业').closest('label')!.querySelector('button')!);
await user.click(screen.getByRole('option', { name: /测试企业/ }));
await user.click(screen.getByText('企业应用(必选)').closest('label')!.querySelector('button')!);
await user.click(screen.getByRole('option', { name: '测试应用' }));
fireEvent.change(document.querySelector('input[type="file"]')!, {
target: { files: [new File(['xlsx'], 'mapping.xlsx')] },
});
await user.click(screen.getByRole('button', { name: '解析文件并配置映射' }));
const preview = await screen.findByText('查看前 1 行解析预览');
await user.click(preview);
expect(preview.closest('details')).toHaveAttribute('open');
await user.click(screen.getByRole('button', { name: '提交导入审核' }));
await waitFor(() =>
expect(adminApi.commitReportMaterialImport).toHaveBeenCalledWith(
'analysis-1',
expect.objectContaining({
mappings: expect.arrayContaining([
expect.objectContaining({ targetKind: 'signatureName', targetFieldCode: 'signature_name' }),
expect.objectContaining({ targetKind: 'purpose', targetFieldCode: 'purpose' }),
]),
}),
),
);
expect(onCompleted).toHaveBeenCalledWith(expect.objectContaining({ id: 'analysis-1', status: 'pending_review' }));
}, 10_000);
}); });
+53 -9
View File
@@ -9,6 +9,7 @@ import {
type TenantOption, type TenantOption,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag } from '@/components/ui'; import { Button, Input, Modal, Select, Tag } from '@/components/ui';
import './ReportMaterialImportModal.css';
type ReportType = 'signature' | 'drainage'; type ReportType = 'signature' | 'drainage';
type AnalyzeResult = { type AnalyzeResult = {
@@ -26,6 +27,21 @@ type AnalyzeResult = {
suggestedMappings: ReportImportMapping[]; suggestedMappings: ReportImportMapping[];
}; };
type AnalysisProgress = { id: string; status: string; progress: number; progressStage?: string; errorMessage?: string }; type AnalysisProgress = { id: string; status: string; progress: number; progressStage?: string; errorMessage?: string };
type ImportCompleted = {
id: string;
reportType: ReportType;
status: string;
successCount: number;
failedCount: number;
};
const coreTargetLabels: Record<Exclude<ReportImportMapping['targetKind'], 'dynamic'>, string> = {
signatureName: '短信签名',
purpose: '签名用途/依据',
siteName: '站点名称',
url: '引流 URL 或号码',
remark: '备注',
};
const transforms = [ const transforms = [
{ label: '保持原值', value: '' }, { label: '保持原值', value: '' },
@@ -48,7 +64,13 @@ function coreTargets(reportType: ReportType) {
]; ];
} }
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) { export function ReportMaterialImportModal({
onClose,
onCompleted,
}: {
onClose: () => void;
onCompleted: (result: ImportCompleted) => void;
}) {
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]); const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]); const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
@@ -169,14 +191,26 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
} }
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) { function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
if (!encoded) {
setMappings((current) => current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex));
setError('');
return;
}
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [
ReportImportMapping['targetKind'],
string,
ReportImportMapping['fieldType'],
];
if (
targetKind !== 'dynamic' &&
mappings.some((item) => item.sourceColumnIndex !== column.sourceColumnIndex && item.targetKind === targetKind)
) {
setError(`目标字段“${coreTargetLabels[targetKind]}”只能映射一个源列`);
return;
}
setError('');
setMappings((current) => { setMappings((current) => {
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex); const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
if (!encoded) return remaining;
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [
ReportImportMapping['targetKind'],
string,
ReportImportMapping['fieldType'],
];
return [ return [
...remaining, ...remaining,
{ {
@@ -208,10 +242,19 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
setError('请输入映射方案名称'); setError('请输入映射方案名称');
return; return;
} }
const duplicateCoreKind = mappings.find(
(mapping, index) =>
mapping.targetKind !== 'dynamic' &&
mappings.findIndex((candidate) => candidate.targetKind === mapping.targetKind) !== index,
)?.targetKind;
if (duplicateCoreKind && duplicateCoreKind !== 'dynamic') {
setError(`目标字段“${coreTargetLabels[duplicateCoreKind]}”只能映射一个源列`);
return;
}
setBusy(true); setBusy(true);
setError(''); setError('');
try { try {
await adminApi.commitReportMaterialImport(analysis.id, { const result = await adminApi.commitReportMaterialImport(analysis.id, {
mappings, mappings,
profile: saveProfile profile: saveProfile
? { ? {
@@ -227,7 +270,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
} }
: undefined, : undefined,
}); });
onCompleted(); onCompleted(result);
onClose(); onClose();
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '导入失败'); setError(failure instanceof Error ? failure.message : '导入失败');
@@ -247,6 +290,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
return ( return (
<Modal <Modal
className="report-material-import-modal"
footer={ footer={
<> <>
<Button onClick={onClose} variant="ghost"> <Button onClick={onClose} variant="ghost">
+1 -1
View File
@@ -4,7 +4,7 @@ import { HashRouter } from 'react-router-dom';
import '@/styles/tokens.css'; import '@/styles/tokens.css';
import '@/styles/reset.css'; import '@/styles/reset.css';
import '@/styles/shell.css'; import '@/styles/shell.css';
import '@/styles/global.css'; import '@/styles/domains/index.css';
import '@/styles/admin.css'; import '@/styles/admin.css';
import '@/styles/client.css'; import '@/styles/client.css';
import '@/styles/components.css'; import '@/styles/components.css';
@@ -0,0 +1,318 @@
/* Owner: shared operations gateway exceptions and dashboard. Extracted from global.css with original cascade order preserved. */
.gateway-exception-time {
font-size: 1rem !important;
line-height: 1.35;
}
.gateway-exception-command {
margin-top: 1rem;
}
.gateway-exception-command pre {
max-height: 22rem;
margin: 0;
padding: 1rem;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
background: var(--color-surface-muted);
color: var(--color-text);
font-size: 0.78rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
.gateway-requeue-confirm .callout {
display: flex;
gap: 0.75rem;
padding: 0.9rem 1rem;
border: 1px solid;
border-radius: 0.75rem;
}
.gateway-requeue-confirm .callout p {
margin: 0.25rem 0 0;
}
.gateway-requeue-confirm .callout--warning {
border-color: #f5c66f;
background: #fff8e8;
color: #8a5700;
}
.gateway-requeue-confirm .callout--danger {
border-color: #f2a4a4;
background: #fff0f0;
color: #a12222;
}
.gateway-requeue-checkbox {
display: flex;
align-items: flex-start;
gap: 0.6rem;
color: var(--color-text);
line-height: 1.5;
cursor: pointer;
}
.gateway-requeue-checkbox input {
width: 1rem;
height: 1rem;
margin-top: 0.2rem;
}
.text-page-title {
color: var(--color-text-strong);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
}
.text-section-title {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
}
.text-card-title {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
}
.text-body {
color: var(--color-text);
font-size: var(--font-size-md);
line-height: var(--line-height-base);
}
.text-secondary {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: var(--line-height-base);
}
.text-caption {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.overview-hero {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-text);
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-7);
}
.overview-hero h1 {
color: var(--color-text-strong);
}
.overview-hero .eyebrow {
color: var(--color-selected);
}
.overview-hero .muted {
color: var(--color-text-muted);
margin-top: var(--space-2);
}
.dashboard-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1180px) {
.dashboard-grid--four {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.metric-card {
display: grid;
gap: var(--space-2);
}
.metric-card--featured {
background: linear-gradient(180deg, var(--color-surface), var(--color-accent-soft));
border-color: rgba(217, 195, 160, 0.72);
}
.metric-card--featured span,
.metric-card--featured small {
color: var(--color-text-muted);
}
.metric-card span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.metric-card strong {
color: var(--color-text-strong);
font-size: 30px;
line-height: var(--line-height-tight);
}
.metric-card small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.chart-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr);
}
.chart-card {
min-width: 0;
}
.chart-card h2 {
margin-bottom: var(--space-2);
}
.overview-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
}
.overview-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.quick-grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.quick-action {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text);
display: grid;
gap: var(--space-2);
justify-items: start;
min-width: 0;
padding: var(--space-4);
text-align: left;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.quick-action:hover {
border-color: rgba(37, 99, 235, 0.24);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.quick-action svg {
color: var(--color-selected);
}
.quick-action span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.quick-action small {
color: var(--color-text-muted);
}
.progress-heading {
align-items: center;
color: var(--color-text-muted);
display: flex;
justify-content: space-between;
margin: var(--space-4) 0 var(--space-2);
}
.progress-heading strong {
color: var(--color-text-strong);
}
.progress-track {
background: var(--color-surface-muted);
border-radius: var(--radius-full);
height: 10px;
overflow: hidden;
}
.progress-track span {
background: linear-gradient(90deg, var(--color-selected), var(--color-accent));
border-radius: inherit;
display: block;
height: 100%;
}
.mini-status-card {
align-items: flex-start;
display: flex;
gap: var(--space-4);
}
.mini-status-card svg {
color: var(--color-selected);
flex: 0 0 auto;
margin-top: 2px;
}
.mini-status-card div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.mini-status-card span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.mini-status-card strong {
color: var(--color-text-strong);
}
.mini-status-card small {
color: var(--color-text-muted);
}
.mini-status-card--action {
appearance: none;
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
width: 100%;
}
.mini-status-card--action:hover,
.mini-status-card--action:focus-visible {
border-color: var(--color-selected);
box-shadow: var(--shadow-sm);
outline: none;
transform: translateY(-1px);
}
+756
View File
@@ -0,0 +1,756 @@
/* Owner: client SMS and MMS sending and batch task lists. Extracted from global.css with original cascade order preserved. */
.sms-send-page {
display: grid;
gap: var(--space-6);
}
.sms-send-title {
align-items: center;
display: flex;
gap: var(--space-4);
}
.sms-send-title h1 {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.sms-send-title__icon {
align-items: center;
background: var(--color-selected-soft);
border-radius: var(--radius-sm);
color: var(--color-selected);
display: inline-flex;
height: 40px;
justify-content: center;
width: 40px;
}
.sms-send-layout {
align-items: start;
display: grid;
gap: var(--space-6);
grid-template-columns: minmax(0, 1fr) 372px;
}
.sms-send-main {
display: grid;
gap: var(--space-6);
}
.send-card,
.sms-preview-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
}
.send-card__title {
align-items: center;
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-5);
}
.send-card__title span {
align-items: center;
background: var(--color-selected);
border-radius: var(--radius-full);
color: var(--color-text-inverse);
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
height: 24px;
justify-content: center;
width: 24px;
}
.send-card__title h2,
.preview-title h2 {
font-size: var(--font-size-lg);
}
.send-field {
display: grid;
gap: var(--space-3);
}
.send-field span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.send-field input,
.receiver-table input {
background: transparent;
border: 0;
border-bottom: 1px solid transparent;
color: var(--color-text);
height: var(--control-height-md);
outline: 0;
width: 100%;
}
.send-field input::placeholder,
.receiver-table input::placeholder {
color: var(--color-text-subtle);
}
.send-form-row {
display: grid;
gap: var(--space-6);
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: var(--space-6);
}
.template-trigger {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
display: flex;
gap: var(--space-2);
height: var(--control-height-md);
justify-content: space-between;
padding: 0 var(--control-padding-x);
text-align: left;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
width: 100%;
}
.template-trigger:hover,
.template-trigger:focus-visible {
border-color: var(--color-selected);
box-shadow: var(--focus-ring);
}
.template-trigger span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-trigger svg {
color: var(--color-selected);
flex: 0 0 auto;
}
.template-trigger__placeholder {
color: var(--color-text-subtle);
}
.receiver-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: var(--space-4);
}
.receiver-tabs button {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
height: 44px;
}
.receiver-tabs button.active {
border-color: var(--color-selected);
color: var(--color-selected);
}
.receiver-table {
border: 1px solid var(--color-border);
border-bottom: 0;
}
.receiver-table__head,
.receiver-table__row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) 96px;
}
.receiver-table__head {
background: var(--color-surface-muted);
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.receiver-table__head span,
.receiver-table__row span,
.receiver-table__row input,
.receiver-table__row button {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
min-height: 50px;
padding: 0 var(--space-4);
}
.receiver-table__row span {
color: var(--color-selected);
}
.receiver-table__row button {
background: transparent;
border-left: 0;
border-right: 0;
border-top: 0;
color: var(--color-danger);
justify-content: center;
}
.receiver-table__row button:disabled {
color: var(--color-text-subtle);
}
.add-recipient {
align-items: center;
background: var(--color-bg-subtle);
border: 1px dashed var(--color-selected);
color: var(--color-selected);
display: flex;
gap: var(--space-2);
height: 40px;
justify-content: center;
margin-top: var(--space-3);
width: 100%;
}
.import-panel {
align-items: center;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-text-muted);
display: grid;
gap: var(--space-3);
grid-auto-rows: max-content;
align-content: center;
justify-items: center;
min-height: 190px;
padding: var(--space-6);
text-align: center;
}
.import-panel__icon {
align-items: center;
background: var(--color-selected-soft);
border-radius: var(--radius-full);
color: var(--color-selected);
display: inline-flex;
height: 48px;
justify-content: center;
width: 48px;
}
.import-panel__icon svg {
color: var(--color-selected);
}
.import-panel strong {
color: var(--color-text-strong);
}
.send-tip {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
margin-top: var(--space-3);
}
.send-submit-row {
align-items: center;
display: flex;
gap: var(--space-3);
justify-content: flex-end;
}
.client-billing-page .client-billing-amount {
background: none;
color: var(--color-text-strong);
font-family: inherit;
font-size: 32px;
font-style: normal;
font-variant-numeric: normal;
font-weight: var(--font-weight-regular);
letter-spacing: normal;
line-height: 1.25;
text-shadow: none;
-webkit-background-clip: border-box;
-webkit-text-fill-color: currentColor;
}
.send-submit-hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.mms-send-page .send-card {
box-shadow: none;
}
.mms-send-tip {
background: var(--color-selected-soft);
border: 1px solid #bfdbfe;
color: var(--color-text);
font-weight: var(--font-weight-medium);
line-height: 1.6;
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.mms-import-mode {
display: grid;
gap: var(--space-5);
}
.mms-import-rules {
align-items: start;
background: #e5e7eb;
color: var(--color-text-muted);
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-5);
}
.mms-import-rules strong {
color: var(--color-text-strong);
display: block;
margin-bottom: var(--space-3);
}
.mms-import-rules p {
margin: 0 0 var(--space-2);
}
.mms-import-panel {
min-height: 240px;
}
.mms-import-panel .import-panel__icon {
border-radius: var(--radius-md);
height: 64px;
width: 64px;
}
.sms-preview-card {
position: sticky;
top: calc(var(--topbar-height) + var(--space-6));
}
.preview-title {
align-items: center;
color: var(--color-selected);
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-5);
}
.preview-title h2 {
color: var(--color-text-strong);
}
.phone-preview {
background: var(--color-surface-muted);
border-radius: var(--radius-sm);
padding: 22px;
}
.phone-preview div {
background: #dbe6ff;
border: 1px solid #90b0ff;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.18);
color: var(--color-text);
min-height: 54px;
padding: var(--space-4);
}
.preview-stats {
display: grid;
margin-top: var(--space-5);
}
.preview-stats div {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
padding: var(--space-4) 0;
}
.preview-stats span {
color: var(--color-text-muted);
}
.preview-stats strong {
color: var(--color-selected);
}
.preview-note {
background: var(--color-surface-muted);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
margin-top: var(--space-5);
padding: var(--space-3) var(--space-4);
}
.mms-preview-card {
min-height: 340px;
}
.mms-message-preview {
background: #e5e7eb;
border-radius: var(--radius-sm);
padding: var(--space-6);
}
.mms-message-preview > div {
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-text);
display: grid;
gap: var(--space-2);
min-height: 64px;
padding: var(--space-4);
}
.mms-message-preview strong {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
}
.mms-message-preview p {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.6;
margin: 0;
}
.template-picker {
display: grid;
gap: var(--space-4);
}
.template-picker__list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
max-height: 360px;
overflow-y: auto;
}
.template-picker__list button {
align-items: center;
background: var(--color-surface);
border: 0;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
display: flex;
gap: var(--space-4);
justify-content: space-between;
padding: var(--space-4);
text-align: left;
}
.template-picker__list button:last-child {
border-bottom: 0;
}
.template-picker__list button:hover,
.template-picker__list button.active {
background: var(--color-selected-soft);
}
.template-picker__list button.active {
color: var(--color-selected);
}
.template-picker__list span {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.template-picker__list strong {
color: var(--color-text-strong);
}
.template-picker__list small {
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.batch-filter-card {
display: grid;
gap: var(--space-5);
}
.batch-filter-card h2 {
font-size: var(--font-size-lg);
}
.batch-filter-card p strong {
color: var(--color-selected);
}
.batch-filter-grid {
align-items: end;
display: grid;
gap: var(--space-8);
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) minmax(220px, 1fr);
}
.batch-table-card {
overflow: hidden;
padding: 0;
}
.batch-table {
min-width: 1080px;
}
.batch-table th {
background: #e5e7eb;
color: var(--color-text-strong);
}
.batch-table td {
height: 70px;
}
.batch-table tbody tr:hover {
background: transparent;
}
.batch-main-row td {
border-top: 12px solid var(--color-bg);
position: relative;
transition: background var(--transition-fast);
}
.batch-main-row td,
.batch-template-row td {
background: #fbfcff;
}
.batch-task-id {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.batch-task-id strong {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
}
.batch-template-row td {
border-top: 0;
height: auto;
padding: 0 var(--space-4) var(--space-5);
position: relative;
transition: background var(--transition-fast);
}
.batch-main-row.batch-row--hovered td,
.batch-template-row.batch-row--hovered td {
background: #dbeafe;
}
.batch-template-content {
align-items: flex-start;
background: var(--color-surface);
border: 1px solid #edf0f5;
border-radius: var(--radius-md);
box-shadow: inset 0 0 0 1px rgba(18, 18, 26, 0.01);
color: var(--color-text);
display: flex;
gap: var(--space-3);
line-height: 1.7;
margin-left: 0;
padding: var(--space-3) var(--space-4);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.batch-template-row.batch-row--hovered .batch-template-content {
border-color: rgba(37, 99, 235, 0.18);
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.08);
}
.batch-template-row.batch-row--hovered .ui-inline-text-preview {
border-color: rgba(37, 99, 235, 0.18);
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.08);
}
.batch-template-content > span:first-child {
color: var(--color-text-muted);
flex: 0 0 auto;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.batch-template-content p {
color: var(--color-text-muted);
margin: 0;
}
.batch-template-content p strong {
color: var(--color-text-muted);
font-weight: var(--font-weight-regular);
margin-right: 2px;
}
.batch-send-time {
display: grid;
gap: var(--space-1);
}
.batch-send-time span {
align-items: center;
color: var(--color-selected);
display: flex;
gap: var(--space-1);
font-weight: var(--font-weight-semibold);
}
.batch-send-time small {
color: var(--color-text-muted);
}
.batch-progress {
display: grid;
gap: var(--space-2);
}
.batch-progress div:first-child {
align-items: center;
display: flex;
justify-content: space-between;
}
.batch-progress span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.batch-progress strong {
color: var(--color-selected);
font-size: var(--font-size-xs);
}
.batch-progress__track {
background: var(--color-border-strong);
border-radius: var(--radius-full);
height: 8px;
overflow: hidden;
}
.batch-progress__bar {
display: block;
height: 100%;
}
.batch-progress__bar--completed {
background: var(--color-success);
}
.batch-progress__bar--sending {
background: var(--color-selected);
}
.batch-progress__bar--terminated {
background: var(--color-danger);
}
.batch-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.batch-actions .ui-button {
min-width: 52px;
}
.batch-actions .ui-button:last-child {
color: var(--color-danger);
}
.mms-task-table td {
height: 96px;
}
.mms-task-content {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: 74px minmax(0, 1fr);
min-width: 0;
}
.mms-task-content img {
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
object-fit: cover;
width: 74px;
}
.mms-task-content div {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.mms-task-content strong {
color: var(--color-text-strong);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
.mms-task-content p {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--color-text-muted);
display: -webkit-box;
line-height: 1.5;
margin: 0;
overflow: hidden;
}
.mms-detail-row-content {
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: 84px minmax(0, 1fr);
min-width: 0;
}
.mms-detail-row-content img {
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
object-fit: cover;
width: 84px;
}
.mms-detail-row-content div {
display: grid;
gap: 6px;
min-width: 0;
}
.mms-detail-row-content strong {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
+725
View File
@@ -0,0 +1,725 @@
/* Owner: client task detail, delivery records and uplink. Extracted from global.css with original cascade order preserved. */
.mms-detail-row-content p {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--color-text-muted);
display: -webkit-box;
line-height: 1.55;
margin: 0;
overflow: hidden;
}
.mms-task-actions {
grid-template-columns: repeat(2, minmax(62px, 1fr));
}
.mms-task-actions .ui-button:last-child {
color: var(--color-selected);
}
.mms-detail-template {
align-items: start;
display: grid;
gap: var(--space-5);
grid-template-columns: 220px minmax(0, 1fr);
}
.mms-detail-template img {
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
object-fit: cover;
width: 100%;
}
.mms-detail-template div {
display: grid;
gap: var(--space-3);
}
.mms-detail-template p {
color: var(--color-text);
line-height: 1.7;
margin: 0;
}
.batch-pagination {
align-items: center;
display: flex;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
}
.batch-pagination > span {
color: var(--color-text-muted);
}
.batch-pagination > div {
display: flex;
gap: var(--space-2);
}
.task-detail-title {
align-items: center;
display: flex;
gap: var(--space-4);
}
.task-detail-title .sms-send-title__icon {
background: linear-gradient(135deg, #eff6ff 0%, #f6efe4 100%);
border-color: rgba(37, 99, 235, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72);
height: 46px;
width: 46px;
}
.task-detail-title h2 {
color: var(--color-text-strong);
font-size: 30px;
line-height: 1.15;
margin: 0;
}
.task-detail-title p {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin: var(--space-2) 0 0;
}
.task-detail {
display: grid;
gap: var(--space-6);
}
.task-detail-section {
background: var(--color-surface);
border: 1px solid rgba(209, 213, 219, 0.72);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
display: grid;
gap: var(--space-5);
padding: var(--space-6);
}
.task-detail-section > h3,
.task-section-title h3 {
align-items: center;
color: var(--color-text-strong);
display: flex;
font-size: var(--font-size-xl);
gap: var(--space-2);
margin: 0;
}
.task-section-title {
align-items: center;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}
.task-info-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.task-info-grid > div {
background: linear-gradient(180deg, #fbfcfe 0%, #f7f8fa 100%);
border: 1px solid rgba(209, 213, 219, 0.74);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-2);
min-height: 78px;
padding: var(--space-4);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.task-info-grid > div:hover {
border-color: rgba(37, 99, 235, 0.22);
box-shadow: 0 8px 20px rgba(18, 18, 26, 0.05);
transform: translateY(-1px);
}
.task-info-grid span,
.task-stat-card span,
.task-total-progress span,
.overall-rate-card span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.task-info-grid strong {
color: var(--color-text-strong);
font-size: var(--font-size-md);
}
.task-info-full {
grid-column: 1 / -1;
}
.task-info-full strong {
line-height: 1.7;
}
.task-send-type {
align-items: center;
color: var(--color-selected) !important;
display: flex;
gap: var(--space-2);
}
.text-blue {
color: var(--color-selected) !important;
}
.text-green {
color: var(--color-success) !important;
font-weight: var(--font-weight-semibold);
}
.task-stat-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.task-stat-card {
background: var(--color-surface);
border: 1px solid rgba(191, 219, 254, 0.9);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-2);
min-height: 96px;
overflow: hidden;
padding: var(--space-5);
position: relative;
}
.task-stat-card::before {
content: '';
height: 3px;
inset: 0 0 auto;
position: absolute;
}
.task-stat-card--blue::before {
background: var(--color-selected);
}
.task-stat-card--green {
border-color: #bbf7d0;
}
.task-stat-card--green::before {
background: var(--color-success);
}
.task-stat-card strong {
color: var(--color-selected);
font-size: 32px;
line-height: 1;
}
.task-stat-card--green strong {
color: var(--color-success);
}
.task-total-progress {
background: #f8fafc;
border: 1px solid rgba(209, 213, 219, 0.72);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.task-total-progress > div:first-child {
align-items: center;
display: flex;
justify-content: space-between;
}
.task-total-progress strong {
color: var(--color-selected);
}
.overall-rate-card {
align-items: center;
background:
radial-gradient(circle at 50% 24%, rgba(217, 195, 160, 0.24), transparent 28%),
linear-gradient(135deg, #f8fbff 0%, #eef4ff 100%);
border: 1px solid rgba(191, 219, 254, 0.95);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-3);
justify-items: center;
min-height: 178px;
padding: var(--space-6);
}
.overall-rate-card strong {
color: var(--color-success);
font-size: 52px;
line-height: 1;
}
.overall-rate-card small {
color: var(--color-text-muted);
}
.task-detail-section h4 {
color: var(--color-text-strong);
font-size: var(--font-size-base);
margin: 0;
}
.carrier-rate-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.carrier-rate-card {
background: #ffffff;
border: 1px solid rgba(209, 213, 219, 0.78);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-xs);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.carrier-rate-card > div:first-child,
.carrier-rate-card p {
align-items: center;
display: flex;
justify-content: space-between;
}
.carrier-rate-card > div:first-child strong {
color: var(--color-text-strong);
}
.carrier-rate-card > div:first-child span {
color: var(--color-success);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.carrier-rate-card p {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
margin: 0;
}
.city-rate-table th {
background: #f3f4f6;
color: var(--color-text-strong);
}
.city-rate-table td {
height: 56px;
}
.city-rate-table tbody tr:hover {
background: #f8fafc;
}
.task-detail-title .sms-send-title__icon {
background: var(--color-brand);
border-color: var(--color-brand);
color: var(--color-accent);
height: 48px;
width: 48px;
}
.task-detail-title h2 {
font-size: 28px;
letter-spacing: 0;
}
.task-detail-title p {
color: #8a93a3;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
margin-top: 6px;
}
.task-detail {
gap: 18px;
}
.task-detail-section {
background: #ffffff;
border: 1px solid #e9edf3;
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(18, 18, 26, 0.04);
gap: 18px;
padding: 22px;
}
.task-detail-section > h3,
.task-section-title h3 {
font-size: 18px;
gap: 8px;
}
.task-section-title {
border-bottom: 1px solid #eef1f5;
padding-bottom: 14px;
}
.task-info-grid {
gap: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid #edf0f5;
border-radius: var(--radius-md);
}
.task-info-grid > div {
background: #ffffff;
border: 0;
border-bottom: 1px solid #edf0f5;
border-right: 1px solid #edf0f5;
border-radius: 0;
box-shadow: none;
min-height: 82px;
padding: 16px 18px;
transform: none;
}
.task-info-grid > div:nth-child(4n) {
border-right: 0;
}
.task-info-grid > div:hover {
background: #fbfcff;
border-color: #edf0f5;
box-shadow: none;
transform: none;
}
.task-info-grid span,
.task-stat-card span,
.task-total-progress span,
.overall-rate-card span {
color: #7b8494;
font-weight: var(--font-weight-medium);
}
.task-info-grid strong {
color: #18202f;
font-size: 14px;
line-height: 1.55;
}
.task-info-full {
background: #fbfcff !important;
border-bottom: 0 !important;
border-right: 0 !important;
grid-column: 1 / -1;
min-height: 92px !important;
}
.task-info-full strong {
color: #263244;
font-size: 14px;
font-weight: var(--font-weight-medium);
}
.task-stat-grid {
gap: 12px;
}
.task-stat-card {
background: #ffffff;
border: 1px solid #e7ebf2;
border-radius: var(--radius-md);
box-shadow: 0 1px 2px rgba(18, 18, 26, 0.04);
min-height: 106px;
padding: 18px;
}
.task-stat-card::before {
height: 100%;
inset: 0 auto 0 0;
width: 3px;
}
.task-stat-card strong {
font-size: 30px;
letter-spacing: 0;
}
.task-total-progress {
background: #fbfcff;
border-color: #e7ebf2;
border-radius: var(--radius-md);
padding: 18px;
}
.task-total-progress .batch-progress__track {
height: 10px;
}
.overall-rate-card {
background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), transparent 58%), #ffffff;
border-color: #d9e6ff;
border-radius: var(--radius-md);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
min-height: 150px;
}
.overall-rate-card strong {
font-size: 48px;
}
.carrier-rate-grid {
gap: 12px;
}
.carrier-rate-card {
border-color: #e7ebf2;
border-radius: var(--radius-md);
box-shadow: 0 1px 2px rgba(18, 18, 26, 0.04);
padding: 18px;
}
.city-rate-table {
min-width: 760px;
}
.city-rate-table th {
background: #f7f8fa;
border-bottom: 1px solid #e7ebf2;
color: #5f6878;
font-size: 12px;
}
.city-rate-table td {
height: 58px;
}
.send-detail-table-card {
overflow: hidden;
padding: 0;
}
.send-detail-table {
min-width: 1420px;
}
.mms-send-detail-table {
min-width: 1560px;
}
.send-detail-table th {
background: #e5e7eb;
color: var(--color-text-strong);
height: 58px;
}
.send-detail-main-row td {
background: #ffffff;
height: 72px;
}
.send-detail-content-row td {
background: #ffffff;
border-top: 0;
height: auto;
padding: 0 var(--space-4) var(--space-5);
}
.send-detail-message {
color: var(--color-text-muted);
display: block;
line-height: 1.65;
max-width: 380px;
}
.send-detail-content-block {
align-items: flex-start;
background: #fbfcff;
border: 1px solid #edf0f5;
border-radius: var(--radius-md);
display: flex;
gap: var(--space-3);
line-height: 1.7;
padding: var(--space-3) var(--space-4);
}
.send-detail-content-block span {
color: var(--color-text-muted);
flex: 0 0 auto;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.send-detail-content-block p {
color: var(--color-text-muted);
margin: 0;
}
.send-detail-app-name,
.send-detail-count,
.send-detail-time,
.send-detail-region {
display: grid;
gap: 2px;
}
.send-detail-time,
.send-detail-region {
color: var(--color-text-muted);
}
.send-detail-time small,
.send-detail-region small,
.send-detail-count small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.send-detail-count strong {
color: var(--color-text-strong);
}
.send-detail-receipt-code {
color: var(--color-success);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-xs);
}
.send-detail-carrier {
color: #374151;
font-weight: var(--font-weight-semibold);
}
.uplink-table-card {
overflow: hidden;
padding: 0;
}
.uplink-table-card .ui-table th {
background: #e5e7eb;
color: var(--color-text-strong);
height: 54px;
}
.uplink-table-card .ui-table td {
height: 78px;
}
.uplink-content {
color: var(--color-text-strong);
display: -webkit-box;
line-height: 1.6;
min-width: 320px;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.uplink-detail {
display: grid;
gap: var(--space-5);
}
.uplink-detail-hint {
color: var(--color-text-muted);
margin: 0;
}
.uplink-match-list {
display: grid;
gap: var(--space-5);
}
.uplink-match-card {
background: #f1f3f5;
border: 1px solid #e5e7eb;
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.uplink-match-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.uplink-match-grid div,
.uplink-match-content {
display: grid;
gap: var(--space-2);
}
.uplink-match-grid span,
.uplink-match-content span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.uplink-match-grid strong,
.uplink-match-content p {
color: var(--color-text-strong);
}
.uplink-match-content p {
line-height: 1.75;
margin: 0;
}
.uplink-match-actions {
display: flex;
justify-content: flex-end;
}
.mms-uplink-pagination {
align-items: center;
border-top: 1px solid #edf0f5;
color: var(--color-text-muted);
display: flex;
justify-content: space-between;
padding: 16px 22px;
}
.mms-uplink-pagination > div {
display: flex;
gap: 10px;
}
.mms-uplink-detail .task-detail-section:first-child {
background: #ffffff;
}
.mms-uplink-match-card {
background: #f4f6f8;
}
.mms-uplink-image {
display: grid;
gap: var(--space-2);
}
.mms-uplink-image span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.mms-uplink-image img {
aspect-ratio: 16 / 7;
border-radius: var(--radius-sm);
object-fit: cover;
width: 100%;
}
+824
View File
@@ -0,0 +1,824 @@
/* Owner: signature and drainage experiences. Extracted from global.css with original cascade order preserved. */
.sms-app-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(3, minmax(260px, 1fr));
}
.sms-app-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-5);
padding: var(--space-6);
}
.sms-app-card h2 {
font-size: var(--font-size-xl);
}
.sms-app-card dl {
display: grid;
gap: var(--space-4);
margin: 0;
}
.sms-app-card dl div {
align-items: center;
display: flex;
justify-content: space-between;
}
.sms-app-card dt {
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
}
.sms-app-card dd {
color: var(--color-text-strong);
margin: 0;
text-align: right;
}
.sms-app-card dd span {
color: var(--color-selected);
}
.sms-app-param-table {
border: 1px solid var(--color-border);
display: grid;
}
.sms-app-param-table div {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr);
min-height: 58px;
}
.sms-app-param-table div + div {
border-top: 1px solid var(--color-border);
}
.sms-app-param-table span,
.sms-app-param-table strong {
align-items: center;
display: flex;
padding: 0 var(--space-5);
}
.sms-app-param-table span {
background: #f1f3f5;
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
}
.sms-app-param-table strong {
background: #ffffff;
color: var(--color-text-strong);
font-weight: var(--font-weight-medium);
}
.signature-page-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.signature-search-row {
max-width: 360px;
}
.signature-list {
display: grid;
gap: var(--space-5);
}
.signature-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
}
.signature-card::before {
content: '';
inset: 0 auto 0 0;
position: absolute;
width: 5px;
}
.signature-card--green::before {
background: var(--color-success);
}
.signature-card--blue::before {
background: var(--color-selected);
}
.signature-card--amber::before {
background: var(--color-warning);
}
.signature-card--red::before {
background: var(--color-danger);
}
.signature-card--gray::before {
background: var(--color-text-subtle);
}
.signature-summary {
align-items: center;
display: grid;
gap: var(--space-5);
grid-template-columns: 28px 1fr 1fr 1fr 1fr 1fr 1fr 110px;
min-height: 112px;
padding: var(--space-5) var(--space-6);
}
.signature-summary > button {
background: transparent;
border: 0;
color: var(--color-text-muted);
display: inline-flex;
padding: 0;
}
.signature-summary div:not(.signature-actions) {
display: grid;
gap: var(--space-2);
}
.signature-summary span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.signature-summary strong {
color: var(--color-text-strong);
}
.signature-actions {
display: grid;
gap: var(--space-3);
}
.drainage-panel {
background: #f1f3f5;
border-top: 1px solid var(--color-border);
display: grid;
gap: var(--space-5);
padding: var(--space-5) var(--space-6);
}
.drainage-panel h2 {
font-size: var(--font-size-lg);
}
.drainage-table {
display: grid;
}
.drainage-table__head,
.drainage-table__row {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto);
min-height: 58px;
}
.drainage-table__head {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
border-bottom: 1px solid var(--color-border);
}
.drainage-table__row {
border-bottom: 1px solid var(--color-border);
}
.drainage-table__url {
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drainage-row-actions {
display: flex;
gap: var(--space-2);
}
.drainage-panel__footer {
display: flex;
justify-content: flex-end;
}
.admin-enterprise-signature-list .signature-summary {
gap: var(--space-2);
grid-template-columns:
22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr)
repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-height: 64px;
min-width: 1160px;
padding-inline: var(--space-3);
}
.admin-enterprise-signature-list .signature-actions {
display: flex;
gap: 6px;
justify-self: end;
min-width: 224px;
width: 224px;
}
.admin-enterprise-signature-list .signature-actions .ui-button,
.admin-enterprise-signature-list .drainage-row-actions .ui-button {
height: var(--control-height-sm);
justify-content: center;
min-height: var(--control-height-sm);
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
.signature-summary .signature-summary__regular-value {
color: var(--color-text-strong);
font-weight: 400;
}
.admin-enterprise-signature-list {
gap: 0;
overflow-x: auto;
}
.admin-enterprise-signature-list .signature-card {
border-radius: 0;
border-top: 0;
}
.admin-enterprise-signature-list .signature-card:last-of-type {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.enterprise-signature-table__head {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
color: var(--color-text-muted);
display: grid;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
gap: var(--space-2);
grid-template-columns:
22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr)
repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
min-height: 46px;
min-width: 1160px;
padding-inline: var(--space-3);
}
.enterprise-signature-table__signature {
min-width: 0;
}
.enterprise-signature-table__signature strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.enterprise-signature-pending-summary {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
gap: var(--space-2);
min-height: 46px;
padding: var(--space-2) var(--space-4);
}
.enterprise-signature-pending-summary strong {
color: var(--color-primary);
font-size: var(--font-size-xl);
font-variant-numeric: tabular-nums;
}
.enterprise-signature-pending-summary .ui-button {
margin-left: auto;
}
.carrier-report-count {
align-items: center;
border-radius: var(--radius-sm);
display: inline-grid;
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-semibold);
gap: 1px;
justify-content: center;
justify-items: center;
line-height: 1.2;
min-width: 64px;
padding: 4px 6px;
}
.carrier-report-count small {
font-size: 11px;
font-weight: var(--font-weight-medium);
opacity: 0.82;
}
.carrier-report-count--success {
background: var(--color-success-soft);
color: var(--color-success);
}
.carrier-report-count--warning {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.carrier-report-count--danger {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.carrier-report-count--info {
background: var(--color-info-soft);
color: var(--color-info);
}
.carrier-report-count--neutral {
background: var(--color-surface-subtle);
color: var(--color-text-subtle);
}
.signature-report-status {
display: grid;
gap: var(--space-4);
}
.signature-report-status__context {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
}
.signature-report-status__context span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.signature-report-status__carriers {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.signature-report-status__carrier {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
min-width: 0;
overflow: hidden;
}
.signature-report-status__carrier > header {
align-items: center;
background: var(--color-surface-subtle);
display: flex;
justify-content: space-between;
min-height: 54px;
padding: var(--space-3) var(--space-4);
}
.signature-report-status__carrier > header > span:last-child {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.signature-report-status__list {
max-height: 280px;
min-height: 190px;
overflow: auto;
padding: 0 var(--space-4);
}
.signature-report-status__row {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr) 168px;
min-height: 64px;
}
.signature-report-status__row:last-child {
border-bottom: 0;
}
.signature-report-status__row strong {
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signature-report-status__empty {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
padding: var(--space-7) 0;
text-align: center;
}
@media (max-width: 920px) {
.signature-report-status__carriers {
grid-template-columns: 1fr;
}
.signature-report-status__list {
min-height: 0;
}
}
.signature-retirement-heatmap__total {
color: var(--color-text-strong);
font-weight: 700;
background: var(--color-surface-muted);
}
.carrier-report-summary {
align-items: flex-start;
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.carrier-report-summary small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 24px;
white-space: nowrap;
}
.admin-enterprise-template-list {
display: grid;
gap: var(--space-3);
}
.admin-enterprise-template-row {
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(220px, 1.15fr) minmax(280px, 1.6fr) minmax(136px, 0.65fr) 188px;
min-width: 0;
padding: var(--space-5);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.admin-enterprise-template-row:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.admin-enterprise-template-row__identity,
.admin-enterprise-template-row__content,
.admin-enterprise-template-row__meta {
min-width: 0;
}
.admin-enterprise-template-row__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.admin-enterprise-template-row__title > strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.admin-enterprise-template-row dl {
display: grid;
gap: 5px;
margin: var(--space-3) 0 0;
}
.admin-enterprise-template-row dl div {
display: grid;
gap: var(--space-2);
grid-template-columns: 42px minmax(0, 1fr);
}
.admin-enterprise-template-row dt,
.admin-enterprise-template-row__content > span,
.admin-enterprise-template-row__meta > span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.admin-enterprise-template-row dd {
color: var(--color-text-strong);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-enterprise-template-row__content p {
color: var(--color-text-strong);
display: -webkit-box;
line-height: 1.65;
margin: var(--space-2) 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.admin-enterprise-template-row__content small {
color: var(--color-text-muted);
}
.admin-enterprise-template-row__meta {
display: grid;
gap: var(--space-2);
}
.admin-enterprise-template-row__meta strong {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.admin-enterprise-template-row__actions {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-enterprise-template-row__actions .ui-button {
justify-content: center;
min-width: 0;
}
@media (max-width: 1180px) {
.admin-enterprise-template-row {
grid-template-columns: minmax(210px, 1fr) minmax(260px, 1.35fr) 168px;
}
.admin-enterprise-template-row__meta {
grid-column: 1;
}
.admin-enterprise-template-row__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
}
@media (max-width: 860px) {
.admin-enterprise-template-row {
align-items: stretch;
grid-template-columns: 1fr;
}
.admin-enterprise-template-row__meta,
.admin-enterprise-template-row__actions {
grid-column: auto;
grid-row: auto;
}
}
.system-log-filters__actions {
align-items: center;
display: flex;
gap: var(--space-3);
}
.detail-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid div {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.detail-grid span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.detail-grid strong {
color: var(--color-text-strong);
line-height: 1.7;
}
.detail-grid__wide {
grid-column: 1 / -1;
}
.signature-modal-title h2 {
font-size: var(--font-size-2xl);
}
.signature-modal-title p {
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.signature-form {
display: grid;
gap: var(--space-8);
}
.signature-form section {
display: grid;
gap: var(--space-5);
}
.signature-form h3 {
border-left: 5px solid var(--color-selected);
padding-left: var(--space-3);
}
.signature-form-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signature-form-grid__wide {
grid-column: 1 / -1;
}
.report-requirement-heading {
align-items: center;
display: flex;
justify-content: space-between;
}
.report-requirement-heading h3 {
margin: 0;
}
.report-field-source {
color: var(--color-text-muted);
display: block;
line-height: 1.6;
margin-top: var(--space-2);
}
.report-requirement-explanation > p {
background: var(--color-selected-soft);
border: 1px solid #bfdbfe;
line-height: 1.7;
padding: var(--space-4);
}
.report-source-group {
border-left: 3px solid var(--color-selected);
margin-top: var(--space-5);
padding-left: var(--space-4);
}
.report-source-channel {
background: var(--color-bg-subtle);
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
}
.report-source-channel ul {
color: var(--color-text-muted);
line-height: 1.8;
margin-bottom: 0;
}
.signature-upload {
align-items: center;
border: 2px dashed var(--color-border);
color: var(--color-text-muted);
display: grid;
gap: var(--space-2);
justify-items: center;
min-height: 170px;
padding: var(--space-5);
text-align: center;
}
.signature-upload > span:first-child {
justify-self: start;
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.signature-upload svg {
color: var(--color-text-muted);
}
.signature-upload strong {
color: var(--color-selected);
}
.signature-upload small {
color: var(--color-text-muted);
}
.signature-upload--compact {
min-height: 118px;
}
.signature-file-line {
align-items: center;
display: flex;
gap: var(--space-3);
}
.signature-file-line > span {
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
}
.signature-file-line small {
color: var(--color-text-muted);
}
.drainage-edit-form .signature-upload {
min-height: 112px;
}
.drainage-form-note {
align-items: flex-start;
}
.drainage-form-note ol {
margin: 0;
padding-left: var(--space-5);
}
.drainage-file-picker {
display: grid;
gap: var(--space-2);
}
.drainage-file-picker > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.drainage-file-picker > div {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: auto 1fr;
}
.drainage-file-picker em {
background: var(--color-surface-subtle);
color: var(--color-text-muted);
font-style: normal;
padding: var(--space-3) var(--space-4);
}
.mms-signature-table-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
padding: 0;
}
.mms-signature-table-card .ui-table th {
background: #e1e5ea;
color: #7b8494;
font-size: var(--font-size-sm);
height: 58px;
}
.mms-signature-table-card .ui-table td {
color: var(--color-text);
height: 86px;
}
.mms-signature-table-card .ui-table th:first-child,
.mms-signature-table-card .ui-table td:first-child {
padding-left: var(--space-6);
}
+833
View File
@@ -0,0 +1,833 @@
/* Owner: SMS and MMS template experiences. Extracted from global.css with original cascade order preserved. */
.mms-signature-name {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.mms-signature-actions {
align-items: center;
display: flex;
gap: var(--space-3);
}
.mms-signature-actions .ui-button:disabled {
background: #f5f6f8;
border-color: #e5e7eb;
color: var(--color-text-subtle);
opacity: 1;
}
.mms-signature-delete {
border-color: rgba(239, 68, 68, 0.22);
color: var(--color-danger);
}
.mms-signature-delete:hover {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.36);
color: var(--color-danger);
}
.mms-pagination {
align-items: center;
display: flex;
gap: var(--space-2);
justify-content: center;
padding: var(--space-3) 0;
}
.mms-pagination button {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
height: 36px;
min-width: 36px;
padding: 0 var(--space-3);
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.mms-pagination button:hover:not(:disabled),
.mms-pagination button.active {
background: var(--color-selected);
border-color: var(--color-selected);
color: var(--color-text-inverse);
}
.mms-pagination button:disabled {
color: var(--color-text-subtle);
}
.mms-template-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.mms-template-header .sms-send-title {
align-items: flex-start;
}
.mms-template-header p {
color: var(--color-text-muted);
margin-top: var(--space-2);
}
.mms-template-toolbar {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(0, 1fr) auto;
}
.mms-template-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(320px, 1fr));
}
.mms-template-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
grid-template-rows: auto auto;
min-height: 0;
overflow: hidden;
position: relative;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.mms-template-card:hover {
border-color: rgba(37, 99, 235, 0.2);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.mms-template-card::before {
content: '';
inset: 0 auto 0 0;
position: absolute;
width: 5px;
}
.mms-template-card--green::before {
background: var(--color-success);
}
.mms-template-card--blue::before {
background: var(--color-selected);
}
.mms-template-card--gray::before {
background: var(--color-text-subtle);
}
.mms-template-card__body {
display: grid;
gap: 10px;
padding: var(--space-5) var(--space-5) var(--space-4);
}
.mms-template-meta {
display: grid;
gap: 7px;
padding-right: 128px;
}
.mms-template-card h2 {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
}
.mms-template-card h3 {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.28;
margin-top: 1px;
}
.mms-template-code {
color: var(--color-text-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-sm);
line-height: 1.28;
margin-bottom: 0;
}
.mms-template-card img {
aspect-ratio: 16 / 7.2;
border-radius: var(--radius-sm);
display: block;
object-fit: cover;
width: 100%;
}
.mms-template-content {
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: var(--color-text-muted);
display: -webkit-box;
line-height: 1.45;
margin: 0;
overflow: hidden;
}
.mms-template-app-tag {
background: var(--color-selected-soft);
border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: var(--radius-full);
color: var(--color-selected);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
padding: 5px 10px;
position: absolute;
right: var(--space-5);
top: var(--space-5);
z-index: 1;
}
.mms-template-status {
display: grid;
gap: 6px;
margin-top: 2px;
}
.mms-template-status > span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.mms-template-status > div {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mms-template-status section {
align-items: center;
display: inline-flex;
gap: 5px;
min-width: 0;
}
.mms-template-status small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.mms-template-card__footer {
align-items: center;
background: #f4f6f8;
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-2);
justify-content: space-between;
min-height: 48px;
padding: 0 var(--space-5);
}
.mms-template-card__footer > span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.mms-template-card__footer div {
align-items: center;
display: flex;
gap: var(--space-5);
}
.mms-template-card__footer button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
display: inline-flex;
gap: 4px;
font-weight: var(--font-weight-semibold);
padding: 0;
}
.mms-template-card__footer button:nth-child(2) {
color: var(--color-text-strong);
}
.mms-template-card__footer button:last-child {
color: var(--color-danger);
}
.mms-template-modal-title h2 {
font-size: var(--font-size-2xl);
}
.mms-template-modal-title p {
color: var(--color-text-muted);
line-height: 1.55;
margin-top: var(--space-2);
max-width: 980px;
}
.mms-template-form {
display: grid;
gap: var(--space-5);
}
.mms-template-form-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mms-frame-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.mms-frame-header div {
align-items: baseline;
display: flex;
gap: var(--space-2);
}
.mms-frame-header strong {
color: var(--color-text-strong);
}
.mms-frame-header span {
color: var(--color-text-muted);
}
.mms-frame-list {
display: grid;
gap: var(--space-5);
}
.mms-frame {
background: #eef0f2;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.mms-frame__top {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: auto 124px 1fr;
}
.mms-frame__top > strong {
color: var(--color-text-strong);
}
.mms-frame__top > button {
background: transparent;
border: 0;
color: var(--color-danger);
justify-self: end;
padding: 0;
}
.mms-frame-type .ui-field__label {
display: none;
}
.mms-frame-type .ui-select button {
background: var(--color-surface);
}
.mms-frame .ui-textarea {
background: var(--color-surface);
border-color: transparent;
min-height: 106px;
}
.mms-file-drop {
align-items: center;
background: var(--color-surface);
border: 2px dashed var(--color-border);
color: var(--color-text-muted);
display: grid;
gap: var(--space-3);
justify-items: center;
min-height: 132px;
padding: var(--space-5);
text-align: center;
}
.mms-file-drop div {
align-items: center;
display: flex;
gap: var(--space-3);
}
.mms-file-drop strong {
color: var(--color-text-strong);
}
.mms-file-drop small {
color: var(--color-text-muted);
}
.mms-save-button {
background: #7c3aed;
border-color: #7c3aed;
}
.mms-save-button:hover {
background: #6d28d9;
border-color: #6d28d9;
}
.mms-preview {
display: grid;
gap: var(--space-4);
}
.mms-preview img {
aspect-ratio: 16 / 9;
border-radius: var(--radius-md);
object-fit: cover;
width: 100%;
}
.mms-preview p {
color: var(--color-text-muted);
line-height: 1.75;
}
.mms-preview-frames {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.mms-preview-frames span {
align-items: center;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-text-muted);
display: inline-flex;
gap: var(--space-1);
min-height: 30px;
padding: 0 var(--space-3);
}
.template-toolbar {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(0, 1fr) auto;
}
.template-card-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
min-width: 0;
}
.template-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
min-height: 300px;
min-width: 0;
overflow: hidden;
padding: var(--space-6);
position: relative;
}
.template-card::before {
content: '';
inset: 0 auto 0 0;
position: absolute;
width: 5px;
}
.template-card--green::before {
background: var(--color-success);
}
.template-card--blue::before {
background: var(--color-selected);
}
.template-card--red::before {
background: var(--color-danger);
}
.template-card h2 {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
}
.template-hash {
color: var(--color-text-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
margin: 0;
}
.template-content {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
line-height: 1.65;
margin: 0;
}
.template-vars {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.template-vars span {
color: var(--color-text-muted);
}
.template-vars strong,
.template-current-vars strong {
background: #e8f0ff;
border-radius: var(--radius-sm);
color: var(--color-selected);
font-weight: var(--font-weight-medium);
padding: 5px 10px;
}
.template-card-footer {
align-items: center;
border-top: 1px solid var(--color-border);
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: space-between;
margin-top: auto;
padding-top: var(--space-4);
}
.template-card-footer span {
color: var(--color-text-muted);
}
.template-card-footer div {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.template-card-footer button {
background: transparent;
border: 0;
color: var(--color-selected);
padding: 0;
}
.template-card-footer button:last-child {
color: var(--color-danger);
}
.template-form {
display: grid;
gap: var(--space-5);
}
.template-form .ui-textarea {
min-height: 220px;
}
.template-form-meta {
align-items: center;
display: flex;
justify-content: space-between;
}
.template-form-meta button {
background: transparent;
border: 0;
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
padding: 0;
}
.template-form-meta span {
color: var(--color-text-muted);
}
.template-variable-panel {
border: 1px solid var(--color-border);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.template-variable-panel h3 {
font-size: var(--font-size-lg);
}
.template-variable-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.template-variable-buttons button {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
min-height: 36px;
padding: 0 var(--space-4);
}
.template-variable-buttons button:hover {
border-color: var(--color-selected);
color: var(--color-selected);
}
.template-custom-variable {
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr) 96px;
}
.template-info-tip {
align-items: center;
background: var(--color-selected-soft);
border-left: 4px solid var(--color-selected);
color: var(--color-text);
display: flex;
gap: var(--space-3);
line-height: 1.7;
padding: var(--space-4);
}
.template-info-tip svg {
color: var(--color-selected);
flex: 0 0 auto;
}
.template-current-vars {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.task-detail-title h2 {
font-size: 22px;
}
.task-stat-layout {
display: grid;
gap: 14px;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.4fr);
}
.task-progress-card {
background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), transparent 64%), #ffffff;
border: 1px solid #dbe7ff;
border-radius: var(--radius-md);
display: grid;
gap: 14px;
padding: 20px;
}
.task-progress-card span,
.task-stat-list span {
color: #7b8494;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.task-progress-card strong {
color: var(--color-selected);
font-size: 42px;
line-height: 1;
}
.task-progress-card small {
color: #7b8494;
}
.task-stat-list {
background: #ffffff;
border: 1px solid #e7ebf2;
border-radius: var(--radius-md);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
}
.task-stat-list > div {
align-content: center;
border-right: 1px solid #edf0f5;
display: grid;
gap: 10px;
min-height: 138px;
padding: 20px;
}
.task-stat-list > div:last-child {
border-right: 0;
}
.task-stat-list strong {
color: #18202f;
font-size: 28px;
line-height: 1;
}
.overall-rate-card {
min-height: 188px;
}
.overall-rate-split {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(120px, 1fr));
width: min(420px, 100%);
}
.overall-rate-split div {
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(209, 213, 219, 0.72);
border-radius: var(--radius-md);
display: grid;
gap: 4px;
justify-items: center;
padding: 12px;
}
.overall-rate-split b {
color: #18202f;
font-size: 18px;
}
.rate-tone-low,
.rate-tone-low .batch-progress__bar {
--rate-color: var(--color-danger);
}
.rate-tone-medium,
.rate-tone-medium .batch-progress__bar {
--rate-color: var(--color-warning);
}
.rate-tone-high,
.rate-tone-high .batch-progress__bar {
--rate-color: var(--color-success);
}
.overall-rate-card.rate-tone-low {
background: linear-gradient(180deg, rgba(220, 38, 38, 0.09), transparent 58%), #ffffff;
border-color: rgba(220, 38, 38, 0.28);
}
.overall-rate-card.rate-tone-medium {
background: linear-gradient(180deg, rgba(217, 119, 6, 0.1), transparent 58%), #ffffff;
border-color: rgba(217, 119, 6, 0.28);
}
.overall-rate-card.rate-tone-high {
background: linear-gradient(180deg, rgba(22, 163, 74, 0.09), transparent 58%), #ffffff;
border-color: rgba(22, 163, 74, 0.26);
}
.overall-rate-card.rate-tone-low strong,
.carrier-rate-card.rate-tone-low > div:first-child span,
.rate-text.rate-tone-low {
color: var(--color-danger) !important;
}
.overall-rate-card.rate-tone-medium strong,
.carrier-rate-card.rate-tone-medium > div:first-child span,
.rate-text.rate-tone-medium {
color: var(--color-warning) !important;
}
.overall-rate-card.rate-tone-high strong,
.carrier-rate-card.rate-tone-high > div:first-child span,
.rate-text.rate-tone-high {
color: var(--color-success) !important;
}
.rate-text {
font-weight: var(--font-weight-semibold);
}
.carrier-rate-card .batch-progress__bar,
.city-rate-table .batch-progress__bar {
background: var(--rate-color, var(--color-success));
}
.detail-message {
color: var(--color-text-muted);
display: block;
max-width: 330px;
}
.receipt {
color: var(--color-success);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.receipt--failed {
color: var(--color-danger);
}
.content-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.6fr);
}
.soft-panel {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
min-width: 0;
padding: var(--space-5);
}
.soft-panel h3 {
margin-bottom: var(--space-3);
}
.summary-list {
display: grid;
gap: var(--space-3);
}
.summary-list div {
align-items: center;
display: flex;
justify-content: space-between;
}
.summary-list span {
color: var(--color-text-muted);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
/* Owner: channel metrics, groups and routing. Extracted from global.css with original cascade order preserved. */
.channel-selected-field-index {
align-items: center;
background: var(--color-selected);
border-radius: var(--radius-sm);
color: white;
display: inline-flex;
height: 30px;
justify-content: center;
width: 30px;
}
.channel-selected-field-order {
display: flex;
margin-left: auto;
}
.channel-selected-field-order button,
.channel-selected-field-controls > button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-text-muted);
cursor: pointer;
display: inline-flex;
justify-content: center;
padding: var(--space-1);
}
.channel-selected-field-order button:disabled {
cursor: default;
opacity: 0.3;
}
.channel-selected-field-controls {
align-items: center;
display: flex;
gap: var(--space-4);
}
.channel-selected-field-controls label {
align-items: center;
cursor: pointer;
display: inline-flex;
gap: var(--space-2);
}
.channel-selected-field-controls > button {
color: var(--color-danger);
margin-left: auto;
}
.mms-channel-page .page-heading {
align-items: center;
}
.mms-channel-filter {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(260px, 1fr) 180px 180px auto;
padding: var(--space-5);
}
.mms-channel-list {
overflow: hidden;
padding: 0;
}
.mms-channel-list .ui-table-wrap {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mms-channel-list .ui-table {
min-width: 900px;
}
.mms-channel-list .ui-table th,
.mms-channel-list .ui-table td {
padding: var(--space-4);
vertical-align: middle;
}
.mms-channel-list .ui-table tbody tr:nth-child(even) {
background: var(--color-surface-subtle);
}
.mms-channel-identity {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.mms-channel-identity span,
.mms-channel-identity small {
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mms-channel-identity small {
font-size: var(--font-size-xs);
}
.mms-channel-carrier {
align-items: flex-start;
display: grid;
gap: var(--space-2);
justify-items: start;
}
.mms-channel-quality {
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: var(--space-3);
}
.mms-channel-metric {
display: grid;
gap: 3px;
}
.mms-channel-metric small {
color: var(--color-text-muted);
}
.mms-channel-metric strong {
color: var(--color-text-strong);
}
.mms-channel-metric--success strong {
color: var(--color-success);
}
.mms-channel-metric--warning strong {
color: #ea580c;
}
.mms-channel-metric--danger strong {
color: var(--color-danger);
}
.mms-channel-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.mms-channel-modal-title h2,
.mms-channel-modal-title p {
margin: 0;
}
.mms-channel-modal-title p {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
margin-top: var(--space-2);
}
.mms-channel-form {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mms-channel-form__wide {
grid-column: 1 / -1;
}
.sms-channel-group-page .page-heading {
align-items: center;
}
.channel-group-filter {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
}
.channel-group-filter__actions {
display: flex;
gap: var(--space-3);
}
.channel-group-list {
display: grid;
gap: var(--space-5);
}
.channel-group-config-list {
display: grid;
gap: var(--space-3);
}
.channel-group-config-item {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1.15fr) minmax(210px, 0.9fr) minmax(200px, 0.95fr) minmax(220px, 1.2fr) minmax(
82px,
auto
);
min-width: 0;
padding: var(--space-4);
}
.channel-group-config-item__identity {
align-items: center;
display: flex;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__icon {
align-items: center;
background: #eef5ff;
border: 1px solid #cfe2ff;
border-radius: var(--radius-sm);
color: var(--color-selected);
display: inline-flex;
flex: 0 0 auto;
height: 38px;
justify-content: center;
width: 38px;
}
.channel-group-config-item__identity div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.channel-group-config-item__identity strong {
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__identity span:last-child,
.channel-group-config-item__metrics span,
.channel-group-config-item__policy span,
.channel-group-config-item__channels span {
color: var(--color-text-muted);
}
.channel-group-config-item__metrics {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.channel-group-config-item__metrics div {
border-left: 1px solid var(--color-border);
display: grid;
gap: var(--space-1);
padding-left: var(--space-3);
}
.channel-group-config-item__metrics strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.channel-group-config-item__policy {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__policy span {
align-items: center;
display: inline-flex;
gap: var(--space-1);
white-space: nowrap;
}
.channel-group-config-item__channels {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
min-width: 0;
}
.channel-group-config-item__channels span {
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
max-width: 160px;
overflow: hidden;
padding: var(--space-1) var(--space-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: flex-end;
justify-self: end;
min-width: 82px;
}
.channel-group-config-item__actions button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0;
}
.channel-group-config-item__actions button.is-danger {
color: var(--color-danger);
}
.channel-group-form-page {
min-width: 1040px;
}
.channel-group-form-section {
display: grid;
gap: var(--space-5);
}
.channel-group-form-section h2 {
border-bottom: 1px solid var(--color-border);
border-left: 5px solid var(--color-selected);
margin: 0;
padding: 0 0 var(--space-4) var(--space-3);
}
.channel-group-base-form {
display: grid;
gap: var(--space-6);
grid-template-columns: 190px minmax(0, 1fr);
}
.channel-group-base-form > .ui-field,
.channel-group-retry-limit,
.channel-group-radio-row,
.channel-group-switch-row {
grid-column: 2;
}
.channel-group-radio-row,
.channel-group-switch-row {
align-items: center;
display: flex;
gap: var(--space-7);
min-height: 42px;
}
.channel-group-radio-row > span,
.channel-group-switch-row > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.channel-group-radio-row label {
align-items: center;
display: inline-flex;
gap: var(--space-2);
}
.channel-group-switch-row button {
background: #d1d5db;
border: 0;
border-radius: 999px;
cursor: pointer;
height: 30px;
padding: 3px;
transition: background 0.18s ease;
width: 54px;
}
.channel-group-switch-row button i {
background: #fff;
border-radius: 50%;
box-shadow: var(--shadow-sm);
display: block;
height: 24px;
transition: transform 0.18s ease;
width: 24px;
}
.channel-group-switch-row button.is-on {
background: var(--color-selected);
}
.channel-group-switch-row button.is-on i {
transform: translateX(24px);
}
.channel-group-row-actions {
display: flex;
gap: var(--space-4);
}
.channel-group-row-actions button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0;
}
.channel-group-row-actions button.is-danger {
color: var(--color-danger);
}
.channel-group-retry-limit {
display: grid;
gap: var(--space-3);
grid-template-columns: 150px 150px minmax(220px, 1fr);
}
.channel-group-retry-limit > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
grid-column: 1 / -1;
}
.channel-group-retry-limit small {
align-self: center;
color: var(--color-text-muted);
}
.channel-route-card-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(220px, 1fr));
}
.channel-route-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
min-height: 188px;
padding: var(--space-4);
}
.channel-route-card > div {
align-items: flex-start;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}
.channel-route-card strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.channel-route-card span,
.channel-route-card small {
color: var(--color-text-muted);
}
.channel-route-card p {
color: var(--color-text);
font-weight: var(--font-weight-semibold);
line-height: 1.5;
margin: 0;
overflow-wrap: anywhere;
}
+774
View File
@@ -0,0 +1,774 @@
/* Owner: signature retirement and quality matrix. Extracted from global.css with original cascade order preserved. */
.channel-route-card footer {
align-items: center;
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-1);
padding-top: var(--space-3);
}
.channel-route-card footer button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0;
}
.channel-route-card footer button.is-danger {
color: var(--color-danger);
}
.channel-route-empty {
align-items: center;
background: var(--color-surface-subtle);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-text-muted);
display: flex;
justify-content: center;
min-height: 120px;
padding: var(--space-5);
}
.channel-route-modal {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
.channel-route-modal__channel-select {
grid-column: 1 / -1;
}
.channel-route-modal .ui-select__dropdown {
max-height: min(360px, calc(100vh - 360px));
}
.channel-route-modal .ui-select__dropdown button {
align-items: flex-start;
height: auto;
line-height: 1.45;
min-height: 42px;
padding: var(--space-2) var(--space-3);
white-space: normal;
}
.channel-route-modal__note {
align-items: center;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius-md);
color: var(--color-text);
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.channel-route-modal__note svg {
color: var(--color-selected);
flex: 0 0 auto;
}
.channel-group-form-footer {
align-items: center;
display: flex;
gap: var(--space-4);
justify-content: center;
padding: var(--space-4) 0;
}
.channel-group-form-footer .ui-button {
min-width: 160px;
}
.recovery-status-explainer {
align-items: flex-start;
display: flex;
gap: var(--space-3);
}
.recovery-status-explainer > svg {
color: var(--color-primary);
flex: 0 0 auto;
margin-top: 2px;
}
.recovery-status-explainer strong {
color: var(--color-text-strong);
}
.recovery-status-explainer p {
color: var(--color-text-muted);
line-height: 1.7;
margin: var(--space-1) 0 0;
}
.signature-quality-card {
overflow: hidden;
}
.login-return-link {
color: var(--color-text-strong);
font-size: 14px;
font-weight: 600;
justify-self: center;
text-decoration: none;
}
.login-return-link:hover {
color: var(--color-selected);
text-decoration: underline;
}
.signature-retirement-heatmap {
padding: var(--space-5);
}
.signature-retirement-heatmap__heading,
.signature-retirement-heatmap__actions {
align-items: center;
}
.signature-retirement-heatmap__actions {
display: flex;
gap: var(--space-3);
}
.signature-retirement-heatmap__actions .ui-field {
min-width: 280px;
}
.signature-retirement-heatmap__scroll {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: auto;
}
.signature-retirement-heatmap table {
border-collapse: collapse;
min-width: 100%;
white-space: nowrap;
}
.signature-retirement-heatmap th,
.signature-retirement-heatmap td {
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
font-size: 12px;
min-width: 48px;
padding: 8px;
text-align: center;
}
.signature-retirement-heatmap th:first-child {
background: var(--color-surface);
left: 0;
min-width: 280px;
position: sticky;
text-align: left;
z-index: 1;
}
.signature-retirement-heatmap tbody th {
align-items: center;
display: flex;
gap: var(--space-2);
justify-content: space-between;
}
.signature-retirement-heatmap__identity {
display: grid;
gap: 2px;
min-width: 0;
text-align: left;
}
.signature-retirement-heatmap__identity strong {
color: var(--color-text-strong);
cursor: help;
overflow: hidden;
text-overflow: ellipsis;
}
.signature-retirement-heatmap__identity small {
color: var(--color-text-muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
.signature-retirement-heatmap td.is-zero {
background: #f3f4f6;
color: var(--color-text-muted);
font-weight: 700;
}
.signature-retirement-heatmap td.is-inapplicable {
background: repeating-linear-gradient(135deg, #f8fafc, #f8fafc 4px, #eef2f7 4px, #eef2f7 8px);
color: var(--color-text-muted);
}
.signature-retirement-heatmap td.is-rate-red {
background: #fee2e2;
color: #dc2626;
font-weight: 700;
}
.signature-retirement-heatmap td.is-rate-orange {
background: #ffedd5;
color: #ea580c;
font-weight: 700;
}
.signature-retirement-heatmap td.is-rate-yellow {
background: #fef9c3;
color: #ca8a04;
font-weight: 700;
}
.signature-retirement-heatmap td.is-rate-blue {
background: #dbeafe;
color: #2563eb;
font-weight: 700;
}
.signature-retirement-heatmap td.is-rate-green {
background: #dcfce7;
color: #16a34a;
font-weight: 700;
}
.signature-retirement-heatmap td.is-rate-deep-green {
background: #d1fae5;
color: #047857;
font-weight: 700;
}
.ui-table__long-text {
display: block;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: normal;
word-break: normal;
}
.signature-retirement-message-card {
overflow: hidden;
}
.signature-retirement-message-filter {
align-items: end;
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(4, minmax(180px, 1fr));
margin-bottom: var(--space-4);
}
.signature-retirement-message-filter__actions {
display: flex;
gap: var(--space-2);
}
.signature-retirement-suppression-modes {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signature-retirement-suppression-modes label {
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.signature-retirement-suppression-modes label.is-selected {
background: var(--color-selected-soft);
border-color: var(--color-selected);
}
.signature-retirement-suppression-modes input {
margin-top: 3px;
}
.signature-retirement-suppression-modes span {
display: grid;
gap: 4px;
}
.signature-retirement-suppression-modes small {
color: var(--color-text-muted);
}
.signature-quality-card__heading {
align-items: flex-start;
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-5);
}
.signature-quality-card__heading h2,
.signature-quality-section h3 {
margin: 0;
}
.signature-quality-card__heading p,
.signature-quality-section__heading p {
margin: var(--space-1) 0 0;
}
.section-heading__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.signature-quality-card__query {
align-items: flex-end;
display: flex;
flex: 0 1 390px;
gap: var(--space-2);
}
.signature-quality-card__query .ui-field {
flex: 1;
}
.signature-quality-card__note {
background: color-mix(in srgb, var(--color-primary) 6%, white);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.6;
padding: 10px var(--space-5);
}
.signature-quality-card .ui-table-wrap {
border: 0;
border-radius: 0;
}
.signature-quality-card > .ui-pagination {
border-top: 1px solid var(--color-border);
padding: var(--space-4) var(--space-5);
}
.signature-quality-name {
display: grid;
gap: 3px;
}
.signature-quality-name strong {
color: var(--color-text-strong);
}
.signature-quality-name span,
.signature-quality-name small {
color: var(--color-text-muted);
}
.quality-number--success {
color: var(--color-success);
}
.quality-number--warning {
color: var(--color-warning);
}
.quality-number--danger {
color: var(--color-danger);
}
.signature-quality-rate {
align-items: center;
display: flex;
gap: var(--space-2);
}
.signature-quality-rate > div {
background: var(--color-surface-muted);
border-radius: 99px;
flex: 1;
height: 6px;
overflow: hidden;
}
.signature-quality-rate > div span {
background: var(--color-success);
border-radius: inherit;
display: block;
height: 100%;
}
.signature-quality-rate strong {
min-width: 50px;
text-align: right;
}
.signature-quality-drawer__backdrop {
background: rgb(15 23 42 / 42%);
inset: 0;
position: fixed;
z-index: 1200;
}
.signature-quality-drawer {
animation: signature-quality-drawer-in 180ms ease-out;
background: var(--color-bg);
box-shadow: -18px 0 48px rgb(15 23 42 / 18%);
display: flex;
flex-direction: column;
height: 100%;
margin-left: auto;
max-width: min(1120px, calc(100vw - 72px));
width: 88vw;
}
@keyframes signature-quality-drawer-in {
from {
opacity: 0;
transform: translateX(28px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.signature-quality-drawer__header {
align-items: flex-start;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-5);
justify-content: space-between;
padding: var(--space-5) var(--space-6);
}
.signature-quality-drawer__header p {
color: var(--color-primary);
font-size: 13px;
margin: 0 0 var(--space-2);
}
.signature-quality-drawer__header h2 {
margin: 0;
}
.signature-quality-drawer__header span {
color: var(--color-text-muted);
display: block;
margin-top: var(--space-2);
}
.signature-quality-drawer__header > button {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
display: inline-flex;
justify-content: center;
padding: var(--space-2);
}
.signature-quality-drawer__header > button:hover {
background: var(--color-surface-muted);
color: var(--color-text-strong);
}
.signature-quality-drawer__body {
display: grid;
gap: var(--space-5);
overflow: auto;
padding: var(--space-5) var(--space-6) var(--space-7);
}
.signature-quality-overview {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
min-width: 0;
}
.signature-quality-metric {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.signature-quality-metric span {
color: var(--color-text-muted);
font-size: 13px;
}
.signature-quality-metric strong {
color: var(--color-text-strong);
font-size: 24px;
}
.signature-quality-metric--success strong {
color: var(--color-success);
}
.signature-quality-metric--warning strong {
color: var(--color-warning);
}
.signature-quality-metric--danger strong {
color: var(--color-danger);
}
.signature-quality-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
min-width: 0;
padding: var(--space-5);
}
.signature-quality-section__heading {
margin-bottom: var(--space-4);
}
.signature-quality-section__heading p {
color: var(--color-text-muted);
font-size: 13px;
}
.signature-carrier-grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.signature-carrier-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.signature-carrier-card > div {
align-items: center;
display: flex;
justify-content: space-between;
}
.signature-carrier-card > div strong {
color: var(--color-text-strong);
}
.signature-carrier-card dl {
display: grid;
gap: var(--space-2);
margin: var(--space-4) 0 0;
}
.signature-carrier-card dl div {
align-items: center;
display: flex;
justify-content: space-between;
}
.signature-carrier-card dt {
color: var(--color-text-muted);
}
.signature-carrier-card dd {
color: var(--color-text-strong);
font-weight: 600;
margin: 0;
}
.signature-quality-matrix {
background: var(--color-surface-muted);
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-primary));
border-radius: var(--radius-lg);
max-width: 100%;
overflow: auto;
width: 100%;
}
.signature-quality-matrix table {
border-collapse: collapse;
min-width: 760px;
width: 100%;
}
.signature-quality-matrix th,
.signature-quality-matrix td {
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
padding: 10px;
text-align: left;
vertical-align: top;
}
.signature-quality-matrix th:last-child,
.signature-quality-matrix td:last-child {
border-right: 0;
}
.signature-quality-matrix tr:last-child th,
.signature-quality-matrix tr:last-child td {
border-bottom: 0;
}
.signature-quality-matrix thead th {
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
color: var(--color-text-muted);
font-size: 13px;
position: sticky;
top: 0;
z-index: 2;
}
.signature-quality-matrix tbody th {
background: var(--color-surface);
color: var(--color-text-strong);
min-width: 210px;
position: sticky;
left: 0;
z-index: 1;
}
.signature-quality-matrix__metric {
background: var(--color-surface);
border-left: 3px solid var(--color-border);
border-radius: var(--radius-sm);
display: grid;
gap: 7px;
min-width: 150px;
padding: 10px 12px;
}
.signature-quality-matrix__metric--success {
border-left-color: var(--color-success);
}
.signature-quality-matrix__metric--warning {
border-left-color: var(--color-warning);
}
.signature-quality-matrix__metric--danger {
border-left-color: var(--color-danger);
}
.signature-quality-matrix__metric > div {
align-items: center;
display: flex;
gap: var(--space-2);
justify-content: space-between;
}
.signature-quality-matrix__metric strong {
color: var(--color-text-strong);
}
.signature-quality-matrix__metric small {
color: var(--color-text-muted);
font-size: 12px;
}
.signature-quality-matrix__metric em {
color: var(--color-warning);
font-size: 12px;
font-style: normal;
}
.signature-quality-matrix__drainage-label {
color: var(--color-text-muted);
font-size: 13px;
min-width: 88px;
}
.signature-quality-matrix__rate {
font-weight: 600;
}
.signature-quality-matrix__empty {
color: var(--color-text-subtle);
}
.signature-quality-matrix__zero {
color: var(--color-text-strong);
font-weight: 600;
}
.signature-quality-drawer__footnote {
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.7;
margin: 0;
}
@media (max-width: 900px) {
.signature-retirement-message-filter,
.signature-retirement-suppression-modes {
grid-template-columns: 1fr;
}
.signature-retirement-heatmap__heading,
.signature-retirement-heatmap__actions {
align-items: stretch;
flex-direction: column;
}
.signature-retirement-heatmap__actions .ui-field {
min-width: 0;
width: 100%;
}
.signature-quality-card__heading,
.signature-quality-card__query {
align-items: stretch;
flex-direction: column;
}
.signature-quality-card__query {
flex-basis: auto;
width: 100%;
}
.signature-quality-drawer {
max-width: none;
width: 100%;
}
.signature-quality-overview,
.signature-carrier-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.signature-quality-drawer__header,
.signature-quality-drawer__body {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.signature-quality-overview,
.signature-carrier-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,541 @@
/* Owner: downstream delivery, report tasks and MMS operations. Extracted from global.css with original cascade order preserved. */
.downstream-delivery-toolbar {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-4);
justify-content: space-between;
padding: var(--space-4) var(--space-5);
}
.downstream-delivery-toolbar p {
color: var(--color-text-muted);
line-height: 1.6;
margin: 0;
}
.downstream-delivery-toolbar p strong {
color: var(--color-text-strong);
}
.downstream-requeue-confirm,
.downstream-requeue-result {
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.downstream-requeue-confirm {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.downstream-requeue-confirm > div,
.downstream-requeue-result > div {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.downstream-requeue-confirm p,
.downstream-requeue-confirm small,
.downstream-requeue-confirm strong,
.downstream-requeue-result p,
.downstream-requeue-result small {
margin: 0;
}
.downstream-requeue-confirm strong {
line-height: 1.6;
}
.downstream-requeue-result--success {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
}
.downstream-requeue-result--partial {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.downstream-requeue-result--failed {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.downstream-requeue-result p,
.downstream-requeue-result li {
line-height: 1.6;
overflow-wrap: anywhere;
}
.downstream-requeue-result ul {
margin: 0;
max-height: 180px;
overflow: auto;
padding-left: var(--space-5);
}
.downstream-delivery-toolbar > div,
.downstream-delivery-list__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-2);
}
.downstream-delivery-list {
overflow-x: auto;
}
.downstream-delivery-list__header,
.downstream-delivery-list__row {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 36px 140px minmax(150px, 1fr) minmax(165px, 1.05fr) 126px 54px minmax(170px, 1.2fr) 136px;
min-width: 1080px;
padding: var(--space-4);
}
.downstream-delivery-list__header {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.downstream-delivery-list__row {
border-top: 1px solid var(--color-border);
min-height: 104px;
}
.downstream-delivery-list__row:first-of-type {
border-top: 0;
}
.downstream-delivery-list__row:hover {
background: color-mix(in srgb, var(--color-selected-soft) 28%, transparent);
}
.downstream-delivery-list__select,
.downstream-delivery-list__retry {
text-align: center;
}
.downstream-delivery-list__meta,
.downstream-delivery-list__owner,
.downstream-delivery-list__retry {
display: grid;
gap: 6px;
}
.downstream-delivery-list__meta strong,
.downstream-delivery-list__owner strong,
.downstream-delivery-list__retry strong {
color: var(--color-text-strong);
}
.downstream-delivery-list__meta span,
.downstream-delivery-list__owner span,
.downstream-delivery-list__retry span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.downstream-delivery-list__message strong {
color: var(--color-text-strong);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-sm);
overflow-wrap: anywhere;
}
.downstream-delivery-list__error {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md);
color: #9a3412;
display: -webkit-box;
line-height: 1.55;
overflow: hidden;
padding: var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.downstream-delivery-list__error.is-empty {
background: var(--color-bg-subtle);
border-color: var(--color-border);
color: var(--color-text-subtle);
}
.downstream-delivery-list__actions {
justify-content: flex-end;
}
.downstream-delivery-list__empty {
color: var(--color-text-muted);
padding: 48px var(--space-5);
text-align: center;
}
@media (max-width: 760px) {
.downstream-delivery-toolbar {
align-items: stretch;
flex-direction: column;
}
.downstream-delivery-toolbar > div {
justify-content: flex-end;
}
}
.report-page-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.report-task-time {
color: var(--color-text-muted);
display: grid;
font-size: var(--font-size-xs);
gap: 4px;
line-height: 1.45;
}
.report-task-time span {
white-space: nowrap;
}
.admin-remark-cell {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
display: -webkit-box;
line-height: 1.6;
max-width: 360px;
min-width: 220px;
overflow: hidden;
padding: var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.admin-remark-cell--empty {
color: var(--color-text-subtle);
}
.report-task-generate {
display: grid;
gap: var(--space-5);
}
.report-task-generate section,
.report-task-detail .admin-task-card {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.report-task-generate h3,
.report-receipt-preview h3 {
color: var(--color-text-strong);
font-size: var(--font-size-md);
font-weight: 700;
margin: 0 0 var(--space-4);
}
.report-task-preview {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.report-task-preview > div,
.report-receipt-preview > div {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.report-task-preview span,
.report-receipt-preview span {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-xs);
margin-bottom: var(--space-2);
}
.report-task-preview strong,
.report-receipt-preview strong {
color: var(--color-text-strong);
font-size: var(--font-size-xl);
}
.report-receipt-modal {
align-items: start;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr);
}
.report-receipt-modal .ui-field {
grid-column: 1 / -1;
}
.report-upload-drop {
align-content: center;
background: var(--color-bg-subtle);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-2);
min-height: 180px;
padding: var(--space-6);
place-items: center;
text-align: center;
}
.report-upload-drop svg {
color: var(--color-primary);
}
.report-upload-drop strong {
color: var(--color-text-strong);
font-size: var(--font-size-md);
}
.report-upload-drop span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.report-receipt-preview {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: grid;
gap: var(--space-3);
padding: var(--space-5);
}
.admin-app-route-card > .ui-field {
max-width: 100%;
min-width: 0;
width: 100%;
}
.downstream-attempt-timeline {
border-top: 0 !important;
display: grid !important;
gap: var(--space-4) !important;
grid-template-columns: 1fr !important;
padding: var(--space-2) 0 0 !important;
position: relative;
}
.downstream-attempt-timeline::before {
background: var(--color-border);
bottom: 24px;
content: '';
left: 19px;
position: absolute;
top: 28px;
width: 2px;
}
.downstream-attempt-card {
align-items: start;
display: grid;
gap: var(--space-4);
grid-template-columns: 40px minmax(0, 1fr);
position: relative;
}
.downstream-attempt-marker {
align-items: center;
background: var(--color-bg-subtle);
border: 2px solid var(--color-border);
border-radius: 50%;
color: var(--color-text-muted);
display: flex;
font-size: var(--font-size-sm);
font-weight: 700;
height: 40px;
justify-content: center;
position: relative;
width: 40px;
z-index: 1;
}
.downstream-attempt-marker--success {
background: #ecfdf3;
border-color: #86efac;
color: #15803d;
}
.downstream-attempt-marker--danger {
background: #fef2f2;
border-color: #fca5a5;
color: #b91c1c;
}
.downstream-attempt-marker--info {
background: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
.downstream-attempt-card__body {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.downstream-attempt-card__body > header {
align-items: center;
display: flex;
justify-content: space-between;
}
.downstream-attempt-card dl {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 0;
}
.downstream-attempt-card dl > div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.downstream-attempt-card dt {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.downstream-attempt-card dd {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
margin: 0;
overflow-wrap: anywhere;
}
.downstream-attempt-card__identifiers dd {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.downstream-attempt-result {
align-items: center;
background: var(--color-surface);
border-radius: var(--radius-sm);
display: flex;
gap: var(--space-4);
justify-content: space-between;
padding: var(--space-3);
}
.downstream-attempt-result--error {
background: #fef2f2;
color: #b91c1c;
}
.downstream-attempt-timeline > p {
color: var(--color-text-muted);
margin: var(--space-3) 0 0;
}
.admin-mms-task-page .page-heading {
align-items: center;
}
.admin-mms-task-table-card .ui-table {
min-width: 1360px;
}
.admin-mms-task-table-card .ui-table-wrap {
border: 0;
border-radius: 0;
}
.admin-mms-task-table-card {
overflow: hidden;
padding: 0;
}
.admin-mms-task-table-card .mms-task-content {
max-width: 420px;
}
.admin-mms-task-table-card .mms-task-content p {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
overflow: hidden;
}
.admin-mms-task-table-card .mms-task-actions {
justify-content: flex-end;
}
.admin-mms-task-table-card .mms-task-actions .ui-button {
padding: 0 9px;
}
.admin-mms-task-detail .mms-detail-template small {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-top: var(--space-2);
}
.admin-mms-city-list {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4);
padding: var(--space-5);
}
.admin-mms-city-list > div {
display: grid;
gap: var(--space-2);
grid-template-columns: 120px minmax(140px, 1fr) 80px;
}
.admin-mms-city-list strong {
color: var(--color-text-strong);
}
.admin-mms-city-list span {
color: var(--color-text-muted);
text-align: right;
}
.admin-mms-city-list b {
color: var(--color-selected);
text-align: right;
}
.admin-mms-city-list .ui-progress-bar {
grid-column: 1 / -1;
}
File diff suppressed because it is too large Load Diff
+490
View File
@@ -0,0 +1,490 @@
/* Owner: client signature and drainage materials. Extracted from global.css with original cascade order preserved. */
.client-signature-page {
gap: 18px;
}
.client-signature-heading,
.client-signature-title,
.client-drainage-panel__head {
align-items: center;
display: flex;
justify-content: space-between;
}
.client-signature-title {
justify-content: flex-start;
gap: 13px;
}
.client-signature-title__icon {
align-items: center;
background: #e9f6f0;
border: 1px solid #d3ebe1;
border-radius: 12px;
color: #187352;
display: inline-flex;
height: 46px;
justify-content: center;
width: 46px;
}
.client-signature-title h1,
.client-signature-title p,
.client-drainage-panel h3,
.client-drainage-panel p,
.client-signature-form-section h3,
.client-signature-form-section p {
margin: 0;
}
.client-signature-title h1 {
color: var(--text-strong);
font-size: 24px;
line-height: 1.25;
}
.client-signature-title p,
.client-drainage-panel p,
.client-signature-form-section p {
color: var(--text-muted);
font-size: 13px;
margin-top: 5px;
}
.client-signature-overview {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: grid;
grid-template-columns: repeat(4, 1fr);
overflow: hidden;
}
.client-signature-overview > div {
display: grid;
gap: 7px;
padding: 18px 22px;
position: relative;
}
.client-signature-overview > div + div::before {
background: var(--border);
content: '';
height: 34px;
left: 0;
position: absolute;
top: 20px;
width: 1px;
}
.client-signature-overview span {
color: var(--text-muted);
font-size: 13px;
}
.client-signature-overview strong {
color: var(--text-strong);
font-size: 25px;
line-height: 1;
}
.client-signature-toolbar {
align-items: end;
display: grid;
gap: 12px;
grid-template-columns: minmax(240px, 1fr) 190px 160px auto;
}
.client-signature-list-shell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.client-signature-list-head,
.client-signature-list-row {
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: 24px minmax(120px, 1.05fr) minmax(110px, 0.85fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
min-width: 1030px;
}
.client-signature-list-head {
background: #f7f9f8;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 12px 18px;
}
.client-signature-row-wrap {
content-visibility: auto;
contain-intrinsic-size: 62px;
}
.client-signature-row-wrap + .client-signature-row-wrap {
border-top: 1px solid var(--border);
}
.client-signature-list-row {
min-height: 62px;
padding: 10px 18px;
}
.client-signature-list-row > span {
color: var(--text-secondary);
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-signature-list-row > strong {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-signature-expand {
align-items: center;
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
justify-content: center;
padding: 3px;
}
.client-signature-expand:hover {
color: var(--primary);
}
.client-carrier-status-group {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.client-carrier-status {
align-items: center;
background: #f8faf9;
border: 1px solid #e3e9e6;
border-radius: 8px;
display: grid;
gap: 6px;
grid-template-columns: auto 7px minmax(0, 1fr);
min-height: 34px;
padding: 5px 8px;
}
.client-carrier-status strong {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
min-width: 0;
white-space: nowrap;
}
.client-carrier-status__dot {
background: #b5c0bb;
border-radius: 50%;
height: 7px;
width: 7px;
}
.client-carrier-status--available {
background: #f1faf6;
border-color: #cfe8dc;
}
.client-carrier-status--available strong {
color: #176b49;
}
.client-carrier-status--available .client-carrier-status__dot {
background: #24a06b;
box-shadow: 0 0 0 3px rgba(36, 160, 107, 0.12);
}
.client-signature-inline-reason,
.client-signature-reason,
.client-drainage-reason {
background: #fff8f0;
color: #9a4d12;
font-size: 13px;
}
.client-signature-inline-reason {
border-top: 1px solid #f5dfc8;
padding: 9px 56px;
}
.client-signature-reason {
border: 1px solid #f5dfc8;
border-radius: 8px;
display: grid;
gap: 4px;
padding: 12px 14px;
}
.client-drainage-panel {
background: #f8fbfa;
border-top: 1px solid var(--border);
padding: 18px 56px 22px;
}
.client-drainage-panel__head {
margin-bottom: 14px;
}
.client-drainage-panel h3 {
align-items: center;
color: var(--text-strong);
display: flex;
font-size: 15px;
gap: 7px;
}
.client-drainage-table {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
overflow-x: auto;
}
.client-drainage-table__head,
.client-drainage-table__row {
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: minmax(220px, 1.35fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
min-width: 940px;
}
.client-drainage-table__head {
background: #f5f8f7;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
padding: 10px 14px;
}
.client-drainage-table__row {
border-top: 1px solid var(--border);
min-height: 56px;
padding: 9px 14px;
}
.client-drainage-target {
color: var(--primary);
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-drainage-reason {
grid-column: 1 / -1;
margin: 0 -14px -9px;
padding: 8px 14px;
}
.client-signature-list-empty,
.client-signature-empty-hint {
color: var(--text-muted);
font-size: 13px;
margin: 0;
padding: 28px;
text-align: center;
}
.client-signature-form-section {
background: #f8fbfa;
border: 1px solid var(--border);
border-radius: 10px;
display: grid;
gap: 14px;
padding: 16px;
}
.client-signature-form-section .client-signature-empty-hint {
padding: 10px;
text-align: left;
}
@media (max-width: 1180px) {
.client-signature-list-shell {
overflow-x: auto;
}
}
@media (max-width: 780px) {
.enterprise-flow-card {
min-height: 0;
padding: var(--space-4);
}
.enterprise-stepper {
justify-content: flex-start;
margin-bottom: var(--space-8);
overflow-x: auto;
padding-bottom: var(--space-2);
}
.enterprise-stepper__item {
flex: 0 0 auto;
gap: var(--space-2);
}
.enterprise-stepper__dot {
height: 36px;
width: 36px;
}
.enterprise-stepper__arrow {
flex: 0 0 auto;
margin: 0 var(--space-2);
}
.enterprise-form-panel,
.enterprise-upload {
min-width: 0;
width: 100%;
}
.enterprise-upload {
padding: var(--space-4);
text-align: center;
}
.enterprise-upload strong {
max-width: 100%;
overflow-wrap: anywhere;
}
.enterprise-upload .file-actions {
justify-content: center;
}
.enterprise-address-selects > div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.enterprise-address-selects .ui-field {
min-width: 0;
width: 100%;
}
.client-signature-heading,
.client-drainage-panel__head {
align-items: stretch;
display: grid;
gap: 14px;
}
.client-signature-overview {
grid-template-columns: repeat(2, 1fr);
}
.client-signature-overview > div:nth-child(3)::before {
display: none;
}
.client-signature-toolbar {
grid-template-columns: 1fr;
}
.client-drainage-panel {
padding: 18px;
}
}
@media (max-width: 780px) {
.overview-hero {
align-items: stretch;
display: grid;
padding: var(--space-5);
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.content-grid,
.plan-grid,
.chart-grid,
.overview-grid,
.overview-grid--three,
.quick-grid,
.sms-send-layout,
.send-form-row {
grid-template-columns: 1fr;
}
.sms-preview-card {
position: static;
}
.receiver-table__head,
.receiver-table__row {
grid-template-columns: 64px minmax(0, 1fr) 64px;
}
.batch-filter-grid {
gap: var(--space-4);
grid-template-columns: 1fr;
}
.batch-pagination {
align-items: stretch;
display: grid;
gap: var(--space-3);
}
.task-info-grid,
.task-stat-grid,
.carrier-rate-grid,
.sms-app-grid,
.template-card-grid {
grid-template-columns: 1fr;
}
.template-toolbar,
.template-custom-variable {
grid-template-columns: 1fr;
}
.task-detail-title h2 {
font-size: 26px;
}
.admin-uplink-filter,
.admin-recharge-filter,
.admin-recharge-filter__actions,
.phone-segment-overview,
.phone-segment-tabs,
.admin-drainage-toolbar,
.channel-route-card-grid,
.detail-grid,
.admin-mms-city-list > div,
.admin-uplink-info-grid,
.admin-uplink-match-grid {
grid-template-columns: 1fr;
}
.admin-recharge-pagination {
align-items: stretch;
display: grid;
justify-content: stretch;
}
}
@@ -0,0 +1,745 @@
/* Owner: shared responsive rules and downstream requeue workbench. Extracted from global.css with original cascade order preserved. */
/* Shared responsive layouts for report filters and compact record rows. */
.client-http-credential-row {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 1fr 1.5fr 100px 1fr auto;
padding: var(--space-4);
}
.client-http-credential-row code {
min-width: 0;
overflow-wrap: anywhere;
}
.admin-report-target-row {
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(0, 1fr) 220px;
padding: var(--space-4);
}
@media (max-width: 780px) {
.login-page {
padding: var(--space-4);
}
.login-panel {
padding: 28px 22px 24px;
}
.login-brand {
margin-bottom: var(--space-6);
}
.login-captcha-row {
grid-template-columns: minmax(0, 1fr) 112px;
gap: var(--space-2);
}
.admin-dashboard-main-grid,
.admin-metric-grid,
.admin-workload-grid,
.enterprise-summary-grid,
.enterprise-query-grid,
.system-log-filters,
.channel-report-filter-grid,
.channel-group-filter,
.channel-group-config-item,
.channel-group-base-form,
.channel-group-retry-limit,
.report-receipt-modal,
.phone-segment-query,
.client-http-credential-row,
.admin-report-target-row,
.admin-app-form-grid,
.admin-app-route-grid {
grid-template-columns: minmax(0, 1fr);
}
.admin-app-protocol-header {
align-items: stretch;
display: grid;
gap: var(--space-4);
}
.admin-app-protocol-header > .admin-switch {
justify-self: start;
}
.admin-metric-grid,
.enterprise-summary-grid,
.admin-workload-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.enterprise-query-actions,
.phone-segment-query__actions {
align-items: stretch;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.enterprise-query-actions .ui-button,
.phone-segment-query__actions .ui-button {
width: 100%;
}
.channel-report-page,
.channel-group-form-page {
min-width: 0;
width: 100%;
}
.channel-report-heading,
.channel-report-filter-footer {
align-items: stretch;
display: grid;
gap: var(--space-4);
}
.channel-report-config-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-left: 0;
}
.channel-report-filter-footer > div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.channel-report-table {
background: transparent;
border: 0;
box-shadow: none;
overflow: visible;
}
.channel-report-table__head {
display: none;
}
.channel-report-row {
align-items: stretch;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr);
margin-bottom: var(--space-3);
min-height: 0;
padding: var(--space-4);
}
.channel-report-row > :first-child {
display: none;
}
.channel-report-name--flow {
padding-left: 0;
}
.channel-report-row > :nth-child(3),
.channel-report-row > :nth-child(4),
.channel-report-row > :nth-child(5),
.channel-report-row > :nth-child(6) {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 88px minmax(0, 1fr);
min-width: 0;
}
.channel-report-row > :nth-child(3)::before {
color: var(--color-text-muted);
content: '报备状态';
font-size: var(--font-size-xs);
}
.channel-report-row > :nth-child(4)::before {
color: var(--color-text-muted);
content: '提交报备时间';
font-size: var(--font-size-xs);
}
.channel-report-row > :nth-child(5)::before {
color: var(--color-text-muted);
content: '报备成功时间';
font-size: var(--font-size-xs);
}
.channel-report-row > :nth-child(6)::before {
color: var(--color-text-muted);
content: '上次发送成功';
font-size: var(--font-size-xs);
}
.channel-report-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
width: 100%;
}
.channel-report-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
}
.channel-signature-grid,
.channel-status-options {
grid-template-columns: minmax(0, 1fr);
}
.channel-group-base-form > .ui-field,
.channel-group-retry-limit,
.channel-group-radio-row,
.channel-group-switch-row {
grid-column: auto;
}
.channel-group-radio-row,
.channel-group-switch-row {
align-items: flex-start;
display: grid;
gap: var(--space-3);
}
.channel-group-filter__actions,
.channel-group-config-item__actions,
.channel-group-row-actions {
justify-content: flex-start;
justify-self: stretch;
}
.channel-group-config-item__metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.channel-group-retry-limit > span,
.channel-group-retry-limit small {
grid-column: auto;
}
.phone-segment-tab-content > .phone-segment-query {
margin: var(--space-4);
}
.phone-segment-heading > p {
font-size: var(--font-size-sm);
line-height: 1.55;
margin-left: 0;
}
.phone-segment-workbench__tabs .ui-tabs__list {
padding: 0 var(--space-3);
width: 100%;
}
.phone-segment-workbench__tabs .ui-tabs__tab {
flex: 1;
justify-content: center;
white-space: nowrap;
}
.phone-segment-summary {
gap: var(--space-3);
grid-template-columns: 44px minmax(0, 1fr);
margin: 0 var(--space-4);
min-height: 0;
padding: var(--space-5) 0;
}
.phone-segment-summary__icon {
height: 42px;
width: 42px;
}
.phone-segment-summary__value {
border-right: 0;
min-width: 0;
padding-right: 0;
}
.phone-segment-summary__value strong {
font-size: 26px;
}
.phone-segment-summary p {
grid-column: 1 / -1;
padding-left: 0;
}
.phone-segment-panel .ui-table tbody {
padding: 0 var(--space-4) var(--space-4);
}
.phone-segment-panel .ui-pagination {
padding: var(--space-4);
}
.sms-bulk-actions,
.sms-audit-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
/* Signature and drainage rows use true mobile cards. */
.client-signature-list-shell {
background: transparent;
border: 0;
overflow: visible;
}
.client-signature-list-head,
.client-drainage-table__head {
display: none;
}
.client-signature-row-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.client-signature-row-wrap + .client-signature-row-wrap {
border-top: 1px solid var(--border);
margin-top: var(--space-3);
}
.client-signature-list-row {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: 32px minmax(0, 1fr);
min-width: 0;
padding: var(--space-4);
}
.client-signature-list-row > :nth-child(n + 3):nth-child(-n + 7) {
align-items: center;
display: grid;
gap: var(--space-3);
grid-column: 1 / -1;
grid-template-columns: 92px minmax(0, 1fr);
overflow: visible;
white-space: normal;
}
.client-signature-list-row > :nth-child(3)::before {
color: var(--text-muted);
content: '所属应用';
font-size: 12px;
}
.client-signature-list-row > :nth-child(4)::before {
color: var(--text-muted);
content: '移动';
font-size: 12px;
}
.client-signature-list-row > :nth-child(5)::before {
color: var(--text-muted);
content: '联通';
font-size: 12px;
}
.client-signature-list-row > :nth-child(6)::before {
color: var(--text-muted);
content: '电信';
font-size: 12px;
}
.client-signature-list-row > :nth-child(7)::before {
color: var(--text-muted);
content: '更新时间';
font-size: 12px;
}
.client-signature-list-row > .table-actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
.client-signature-inline-reason,
.client-drainage-panel {
padding: var(--space-4);
}
.client-drainage-table {
background: transparent;
border: 0;
overflow: visible;
}
.client-drainage-table__row {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
display: grid;
gap: 10px;
grid-template-columns: 88px minmax(0, 1fr);
min-width: 0;
padding: var(--space-4);
}
.client-drainage-table__row > * {
grid-column: 1 / -1;
min-width: 0;
white-space: normal;
}
.client-drainage-table__row > a {
overflow-wrap: anywhere;
}
.client-drainage-reason {
margin: 0;
}
}
@media (max-width: 360px) {
.login-captcha-row,
.admin-metric-grid,
.enterprise-summary-grid,
.admin-workload-grid,
.channel-report-config-actions,
.channel-report-filter-footer > div,
.enterprise-query-actions,
.phone-segment-query__actions {
grid-template-columns: minmax(0, 1fr);
}
}
.application-unit-price {
color: var(--color-text-strong);
font-size: var(--font-size-base);
font-variant-numeric: tabular-nums;
}
.downstream-page-size {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.downstream-page-size select {
min-width: 76px;
height: 36px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
padding: 0 10px;
}
.downstream-requeue-task-heading {
gap: 20px;
}
.downstream-requeue-task-card > .downstream-requeue-task-heading {
padding: var(--space-6) var(--space-6) var(--space-5);
}
.downstream-requeue-task-heading__actions {
display: flex;
align-items: center;
gap: 8px;
}
.downstream-requeue-task-heading__actions .ui-field {
min-width: 150px;
}
.downstream-requeue-task-list {
display: grid;
margin: 0 var(--space-6);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.downstream-requeue-task-list article {
display: grid;
grid-template-columns: minmax(190px, 0.9fr) minmax(260px, 1.35fr) minmax(250px, 1fr) auto;
gap: 22px;
align-items: center;
padding: 18px var(--space-5);
border-bottom: 1px solid var(--border);
}
.downstream-requeue-task-list article:last-of-type {
border-bottom: 0;
}
.downstream-requeue-task-list > .muted {
margin: 0;
padding: var(--space-6);
text-align: center;
}
.downstream-requeue-task-card > .ui-pagination {
padding: var(--space-5) var(--space-6) var(--space-6);
}
.downstream-requeue-task-list article > div {
min-width: 0;
}
.downstream-requeue-task-list__identity,
.downstream-requeue-task-list__scope,
.downstream-requeue-task-list__progress {
display: grid;
gap: 5px;
}
.downstream-requeue-task-list__identity > strong {
color: var(--text-strong);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.downstream-requeue-task-list__identity > span,
.downstream-requeue-task-list__identity > small,
.downstream-requeue-task-list__scope > span,
.downstream-requeue-task-list__progress > small {
color: var(--text-muted);
}
.downstream-requeue-task-list__scope > strong {
overflow: hidden;
color: var(--text);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.downstream-requeue-task-list__progress > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.downstream-requeue-task-list__meter {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: var(--surface-subtle, #eef2f6);
}
.downstream-requeue-task-list__meter > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--brand, #2f6f91);
transition: width 0.2s ease;
}
.downstream-requeue-task-list__actions {
display: flex !important;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.downstream-requeue-preview {
display: grid;
gap: 20px;
}
.downstream-requeue-preview__summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.downstream-requeue-preview__summary > div {
display: grid;
gap: 4px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-subtle, #f8fafc);
}
.downstream-requeue-preview__summary > div.is-primary {
border-color: color-mix(in srgb, var(--brand, #2f6f91) 28%, var(--border));
background: color-mix(in srgb, var(--brand, #2f6f91) 7%, white);
}
.downstream-requeue-preview__summary span,
.downstream-requeue-preview__summary small {
color: var(--text-muted);
}
.downstream-requeue-preview__summary strong {
color: var(--text-strong);
font-size: 24px;
line-height: 1.15;
}
.downstream-requeue-preview__meta {
margin: 0;
}
.downstream-requeue-preview__distribution {
display: grid;
gap: 8px;
}
.downstream-requeue-preview__distribution > span {
color: var(--text-muted);
font-size: 13px;
}
.downstream-requeue-preview__distribution > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.downstream-requeue-reason {
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-subtle, #f8fafc);
}
.downstream-requeue-reason .ui-textarea {
min-height: 124px;
background: var(--color-surface);
line-height: 1.65;
}
.downstream-requeue-warning {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px;
border: 1px solid #fed7aa;
border-radius: 10px;
background: var(--warning-soft, #fff7ed);
color: var(--warning-text, #9a3412);
}
.downstream-requeue-warning > div {
display: grid;
gap: 4px;
}
.downstream-requeue-warning p {
margin: 0;
color: inherit;
line-height: 1.6;
}
.downstream-requeue-task-detail {
display: grid;
gap: 20px;
}
.downstream-requeue-detail-alert {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 10px;
background: var(--warning-soft, #fff7ed);
color: var(--warning-text, #9a3412);
}
.downstream-requeue-detail-section {
display: grid;
gap: 14px;
}
.downstream-requeue-detail-filter {
display: grid;
grid-template-columns: 180px minmax(240px, 1fr) auto;
gap: 10px;
align-items: end;
}
.downstream-requeue-detail-items {
display: grid;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.downstream-requeue-detail-items > div {
display: grid;
grid-template-columns: minmax(160px, 1fr) 125px 130px minmax(220px, 1.5fr) 155px;
gap: 12px;
align-items: center;
padding: 11px 14px;
border-top: 1px solid var(--border);
}
.downstream-requeue-detail-items > div:first-child {
border-top: 0;
}
.downstream-requeue-detail-items__head {
background: var(--surface-subtle, #f8fafc);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.downstream-requeue-detail-items time {
color: var(--text-muted);
font-size: 12px;
}
@media (max-width: 1100px) {
.downstream-requeue-task-list article {
grid-template-columns: 1fr 1.4fr;
}
.downstream-requeue-task-list__actions {
justify-content: flex-start;
}
.downstream-requeue-preview__summary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 780px) {
.downstream-requeue-task-card > .downstream-requeue-task-heading {
padding: var(--space-5) var(--space-4) var(--space-4);
}
.downstream-requeue-task-heading,
.downstream-requeue-task-heading__actions {
align-items: stretch;
flex-direction: column;
}
.downstream-requeue-task-list {
margin: 0 var(--space-4);
}
.downstream-requeue-task-list article,
.downstream-requeue-preview__summary,
.downstream-requeue-detail-filter {
grid-template-columns: 1fr;
}
.downstream-requeue-task-card > .ui-pagination {
padding: var(--space-4);
}
.downstream-requeue-detail-items {
border: 0;
overflow: visible;
gap: 10px;
}
.downstream-requeue-detail-items__head {
display: none !important;
}
.downstream-requeue-detail-items > div {
grid-template-columns: 1fr;
gap: 6px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
}
}
+15
View File
@@ -0,0 +1,15 @@
/* Ordered business-domain stylesheet entry. Keep imports in source-cascade order. */
@import './01-operations-dashboard.css';
@import './02-client-sending.css';
@import './03-client-records.css';
@import './04-signatures.css';
@import './05-templates.css';
@import './06-auth-enterprise.css';
@import './07-admin-operations.css';
@import './08-reporting.css';
@import './09-channels.css';
@import './10-signature-quality.css';
@import './11-deliveries-reporting.css';
@import './12-admin-configuration.css';
@import './13-client-signatures.css';
@import './14-responsive-requeue.css';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -12,7 +12,7 @@ const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR'
}); });
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' }); const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) => const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file), /^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?|css)$/.test(file),
); );
if (!files.length) { if (!files.length) {
@@ -20,12 +20,15 @@ if (!files.length) {
process.exit(0); process.exit(0);
} }
const executable = const args = mode === 'lint' ? files.filter((file) => !file.endsWith('.css')) : ['--check', ...files];
process.platform === 'win32' if (mode === 'lint' && !args.length) {
? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd` console.log('No changed code files require lint.');
: mode === 'lint' process.exit(0);
? 'eslint' }
: 'prettier'; const executable = process.execPath;
const args = mode === 'lint' ? files : ['--check', ...files]; const moduleEntry = resolve(
const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' }); root,
mode === 'lint' ? 'node_modules/eslint/bin/eslint.js' : 'node_modules/prettier/bin/prettier.cjs',
);
const result = spawnSync(executable, [moduleEntry, ...args], { cwd: root, stdio: 'inherit' });
process.exit(result.status ?? 1); process.exit(result.status ?? 1);
+157
View File
@@ -0,0 +1,157 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import postcss from 'postcss';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export function analyzeCss(css) {
const ast = postcss.parse(css);
const result = { rules: 0, selectors: 0, declarations: 0, important: 0, selectorList: [] };
ast.walkRules((rule) => {
result.rules += 1;
const selectors = rule.selectors ?? [rule.selector];
result.selectors += selectors.length;
result.selectorList.push(...selectors);
});
ast.walkDecls((declaration) => {
result.declarations += 1;
if (declaration.important) result.important += 1;
});
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) {
const legacy = path.join(repoRoot, 'src/styles/global.css');
if (fs.existsSync(legacy)) throw new Error('src/styles/global.css 已完成迁移,禁止重新创建');
}
function read(relativePath) {
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() {
const configuredBase = process.env.QUALITY_BASE_REF;
let base = configuredBase || 'HEAD';
if (!configuredBase) {
try {
base =
execFileSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() || 'HEAD';
} catch {
base = 'HEAD';
}
}
const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--', '*.css'], {
cwd: root,
encoding: 'utf8',
});
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--', '*.css'], {
cwd: root,
encoding: 'utf8',
});
return { base, files: [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))] };
}
function baseCss(base, file) {
try {
return execFileSync('git', ['show', `${base}:${file}`], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return '';
}
}
function verify() {
verifyGlobalCssAbsent(root);
const baseline = JSON.parse(read('tools/quality/css-governance-baseline.json'));
const entry = read(baseline.entry);
const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) =>
path.posix.join(path.posix.dirname(baseline.entry), match[1]).replace(/\\/g, '/'),
);
if (JSON.stringify(imports) !== JSON.stringify(baseline.importOrder)) {
throw new Error('业务样式入口顺序与治理基线不一致,可能改变级联结果');
}
const totals = { rules: 0, selectors: 0, declarations: 0, important: 0 };
const selectorList = [];
for (const module of baseline.modules) {
const css = read(module.file);
const metrics = analyzeCss(css);
for (const key of Object.keys(totals)) totals[key] += metrics[key];
selectorList.push(...metrics.selectorList);
if (metrics.important > module.important) {
throw new Error(`${module.file} 新增了未登记的 !important`);
}
}
for (const key of Object.keys(totals)) {
if (totals[key] !== baseline.legacySource[key]) {
throw new Error(`迁移后 CSS ${key}=${totals[key]},与原 global.css 基线 ${baseline.legacySource[key]} 不一致`);
}
}
if (JSON.stringify(selectorList) !== JSON.stringify(baseline.legacySource.selectorList)) {
throw new Error('迁移后选择器及顺序与原 global.css 基线不一致');
}
const changes = changedCssFiles();
const migratedFiles = new Set(baseline.modules.map((item) => item.file));
for (const file of changes.files) {
if (migratedFiles.has(file) || !fs.existsSync(path.join(root, file))) continue;
const current = read(file);
const previous = baseCss(changes.base, file);
const oldBroad = new Set(broadBusinessSelectors(previous));
const newBroad = broadBusinessSelectors(current).filter((selector) => !oldBroad.has(selector));
if (newBroad.length) throw new Error(`${file} 新增宽泛业务标签选择器:${newBroad.join(', ')}`);
if (analyzeCss(current).important > analyzeCss(previous).important) {
throw new Error(`${file} 新增了未登记的 !important`);
}
}
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 digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
console.log(
`CSS governance verified: ${totals.rules} rules, ${totals.selectors} selectors, ${totals.declarations} declarations, ${duplicateCount} duplicate selector occurrences, order ${digest}; ${changes.files.length} changed CSS files checked from ${changes.base}.`,
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) verify();
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
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; }'), {
rules: 2,
selectors: 3,
declarations: 2,
important: 1,
selectorList: ['.page', '.page a', '.page button'],
});
});
test('broad business tag selectors are rejected while rooted selectors are allowed', () => {
assert.deepEqual(broadBusinessSelectors('button { color: red; } .report-page button { color: blue; }'), ['button']);
});
test('formatting changes do not affect AST growth metrics', () => {
assert.deepEqual(analyzeCss('.page{color:red}'), analyzeCss('.page {\n color: red;\n}\n'));
});
test('global.css cannot be recreated after migration', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-gate-'));
fs.mkdirSync(path.join(directory, 'src/styles'), { recursive: true });
assert.doesNotThrow(() => verifyGlobalCssAbsent(directory));
fs.writeFileSync(path.join(directory, 'src/styles/global.css'), '.new-page {}');
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
fs.rmSync(directory, { recursive: true, force: true });
});