feat: 优化通道运营商与金额展示

This commit is contained in:
hectorzhao
2026-08-12 13:16:25 +08:00
parent dc358798e9
commit f350bf5ef3
20 changed files with 301 additions and 95 deletions
@@ -28,16 +28,33 @@ export class ChannelConfigurationService {
carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined, carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined,
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
}; };
const [items, total] = await Promise.all([ const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } });
this.prisma.smsChannel.findMany({ const total = candidates.length;
where, if (total === 0) return { items: [], total, page, pageSize };
include: { connectionStates: true }, const day = currentShanghaiDayRange();
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], const counts = await this.prisma.$queryRaw<Array<{ channelId: string; total: number }>>(Prisma.sql`
skip: (page - 1) * pageSize, SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total
take: pageSize, FROM "SmsSubmitRecord" submit
}), WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))})
this.prisma.smsChannel.count({ where }), AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
]); AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
GROUP BY submit."channelId"
`);
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id))
.slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => {
const item = itemById.get(id);
return item ? [item] : [];
});
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
+45
View File
@@ -174,6 +174,51 @@ function createPrismaMock() {
} }
describe('ChannelsService', () => { describe('ChannelsService', () => {
it('sorts all filtered channels by today submit count before pagination', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-low', name: '乙通道' },
{ id: 'channel-high', name: '甲通道' },
{ id: 'channel-zero', name: '丙通道' },
];
const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] }));
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce([fullChannels[0], fullChannels[1]]);
prisma.$queryRaw.mockResolvedValue([
{ channelId: 'channel-low', total: 3 },
{ channelId: 'channel-high', total: 12 },
]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 2 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']);
expect(result.total).toBe(3);
expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: ['channel-high', 'channel-low'] } },
include: { connectionStates: true },
});
});
it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-b', name: 'A通道' },
{ id: 'channel-a', name: 'A通道' },
{ id: 'channel-c', name: 'B通道' },
];
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce(candidates);
prisma.$queryRaw.mockResolvedValue([]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 10 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']);
});
it('creates channel report requirements only from the report field library', async () => { it('creates channel report requirements only from the report field library', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never); const service = new ChannelsService(prisma as never);
@@ -371,7 +371,7 @@
6. 最终失败、超时失败需要退费。 6. 最终失败、超时失败需要退费。
7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。 7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。
8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。 8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。
9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留四位小数;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。 9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示,最多保留四位小数并移除末尾无意义的 `0`;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。通道成本费率作为费率字段固定展示四位小数。
10. API 必须定时扫描提交成功但超过 72 小时仍未收到明确最终回执的短信,转为 timeout 并退还已扣金额;扫描需覆盖 `submitted``unknown`,且用条件更新避免多实例重复退款。 10. API 必须定时扫描提交成功但超过 72 小时仍未收到明确最终回执的短信,转为 timeout 并退还已扣金额;扫描需覆盖 `submitted``unknown`,且用条件更新避免多实例重复退款。
## 5. 功能需求 ## 5. 功能需求
@@ -1559,12 +1559,12 @@
## 2026-07-16 全平台金额精度要求 ## 2026-07-16 全平台金额精度要求
1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数页面及导出文件统一展示 4 位小数。利润报表中的收入同样固定展示 4 位小数 1. 企业应用客户单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数页面只读金额最多展示 4 位小数并移除末尾无意义的 0,小数部分与整数使用相同字号、颜色和字重;纯文本导出继续保留业务所需精度。通道成本费率作为费率字段固定展示 4 位小数,不执行末尾 0 裁剪
2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。 2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。
3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。 3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。
4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。 4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。
5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。 5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。
6. 所有运营端和客户端的只读金额文本,整数部分保持当前文字颜色,小数点及小数部分使用更淡的次级文字颜色;金额输入框和 CSV 等纯文本载体保持原始数值格式,不拆分字符 6. 所有运营端和客户端的只读金额文本不得拆分整数和小数样式;运营看板“今日消费”和企业应用“单价”使用所在指标或表格的正常主数字字号与深色文字
## 2026-07-16 企业应用接口参数复制与下游接入约束 ## 2026-07-16 企业应用接口参数复制与下游接入约束
@@ -1644,7 +1644,7 @@
- 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。 - 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。
- RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。 - RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。
- 运营端充值记录必须提供可截图的账户充值回执。回执只能使用真实充值订单、企业和关联账务流水数据,展示系统真实Logo、入账状态、企业名称与编码、订单号、入账时间、前后余额、入账方式和备注;不得使用前端临时数据补齐缺失字段。 - 运营端充值记录必须提供可截图的账户充值回执。回执只能使用真实充值订单、企业和关联账务流水数据,展示系统真实Logo、入账状态、企业名称与编码、订单号、入账时间、前后余额、入账方式和备注;不得使用前端临时数据补齐缺失字段。
- 回执的“本次充值金额”按实际精度显示:整数金额不显示小数部分,存在小数时仅保留有效小数位;前后余额继续遵循平台统一的四位金额精度 - 回执的“本次充值金额”和前后余额均按平台统一金额规则显示:最多保留四位小数,并移除末尾无意义的 `0`
## 2026-07-22 UI/UX A7公共Dialog契约 ## 2026-07-22 UI/UX A7公共Dialog契约
@@ -1999,6 +1999,9 @@
## 签名清退预警(2026-08-10,已发布到预生产) ## 签名清退预警(2026-08-10,已发布到预生产)
- 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级任务由一次性migration按最终口径自动转换,不再存在人工确认待办。 - 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级任务由一次性migration按最终口径自动转换,不再存在人工确认待办。
- 企业签名“报备状态”弹窗按移动、联通、电信三列分区展示真实目标通道;每个分区独立滚动、每个通道独立选择状态,三个分区共用修改原因和一次保存操作。运营商使用全局低饱和胶囊标签。
- 充值回执压缩金额区和垂直留白;常规桌面视口应在不滚动时看到完整回执,异常长备注允许内容区滚动而不得截断真实内容。
- 短信通道管理按北京时间今日真实提交尝试数降序后再分页;同量按通道名称、ID稳定排序。今日提交为0时,提交失败、送达成功、回执未知、送达失败四个比率统一显示深灰色短杠。运营商低饱和胶囊放在通道信息列底部,原“运营商 / 成本”改为“成本费率”并固定展示4位小数。
- 企业预警支持移动、联通、电信通用X天/Y条规则和企业应用特殊规则,特殊规则优先;通道预警支持通用规则和通道特殊规则,特殊规则优先。规则修改从下一检测日生效,预警快照保存命中的规则版本和阈值。 - 企业预警支持移动、联通、电信通用X天/Y条规则和企业应用特殊规则,特殊规则优先;通道预警支持通用规则和通道特殊规则,特殊规则优先。规则修改从下一检测日生效,预警快照保存命中的规则版本和阈值。
- 清退活跃量按至少有一次上游接受的业务短信去重统计。企业维度同一业务短信只计一次;通道维度按`messageRecordId + channelId`去重,同一通道断连、超时或重试产生多次提交只计一次,切换到不同通道后各通道分别计一次。提交尝试、上游接受和最终送达必须分开展示,不把`SubmitResp status=0`称为最终送达成功。 - 清退活跃量按至少有一次上游接受的业务短信去重统计。企业维度同一业务短信只计一次;通道维度按`messageRecordId + channelId`去重,同一通道断连、超时或重试产生多次提交只计一次,切换到不同通道后各通道分别计一次。提交尝试、上游接受和最终送达必须分开展示,不把`SubmitResp status=0`称为最终送达成功。
- 每天按北京时间完整自然日检测`T-X``T-1`。当前连续报备通过时间不足X个完整日时不预警;恢复达标后关闭当前预警周期,以后再次低于阈值形成新周期。每日检测必须以数据库唯一维度保证幂等,多实例或重启不得重复生成消息或Webhook。 - 每天按北京时间完整自然日检测`T-X``T-1`。当前连续报备通过时间不足X个完整日时不预警;恢复达标后关闭当前预警周期,以后再次低于阈值形成新周期。每日检测必须以数据库唯一维度保证幂等,多实例或重启不得重复生成消息或Webhook。
+8 -1
View File
@@ -3448,7 +3448,7 @@ npm run verify:phase8
| TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 | | TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 |
| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 | | TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 |
| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;分别覆盖HTTP提交、CMPP短短信和多分片长短信,启动 API 定时扫描并模拟投递建单失败后重复扫描。 | 两类短信都转为 timeout、写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`并只退款一次;HTTP产生一个明确失败Webhook,CMPP对每个请求回执的原始分片产生失败状态报告且使用各自SubmitResp Msg_Id;建单未完成时`timeoutReceiptQueuedAt`保持空并由后续扫描补齐,成功建单后不重复;任务进度刷新。 | | TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;分别覆盖HTTP提交、CMPP短短信和多分片长短信,启动 API 定时扫描并模拟投递建单失败后重复扫描。 | 两类短信都转为 timeout、写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`并只退款一次;HTTP产生一个明确失败Webhook,CMPP对每个请求回执的原始分片产生失败状态报告且使用各自SubmitResp Msg_Id;建单未完成时`timeoutReceiptQueuedAt`保持空并由后续扫描补齐,成功建单后不重复;任务进度刷新。 |
| TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额整数不显示小数,非整数仅显示有效小数,余额仍显示四位精度;正数显示已入账,负数显示已冲正。 | | TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额和余额最多显示四位小数并移除末尾无意义的0;正数显示已入账,负数显示已冲正。 |
| TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 | | TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 |
### 17.5.1 报表对账细化 ### 17.5.1 报表对账细化
@@ -4546,6 +4546,13 @@ npm run verify:phase8
| TC-UI-MONEY-001 | 遍历运营端和客户端包含余额、单价、消费、返还、充值、成本、收入和利润的页面 | 只读金额整数部分沿用主文字颜色,小数点及小数部分使用统一淡色;负号、币种符号和单位位置正确,输入框、复制值和CSV仍为完整纯文本数值 | | TC-UI-MONEY-001 | 遍历运营端和客户端包含余额、单价、消费、返还、充值、成本、收入和利润的页面 | 只读金额整数部分沿用主文字颜色,小数点及小数部分使用统一淡色;负号、币种符号和单位位置正确,输入框、复制值和CSV仍为完整纯文本数值 |
| TC-CLIENT-LOGIN-ANIMATION-001 | 打开客户端登录页并保持页面可见,再切换后台或启用减少动态效果 | Canvas动画在登录框背景平滑运行、不遮挡表单、不响应敏感输入;页面隐藏或组件卸载时停止帧循环,减少动态效果下显示静态背景 | | TC-CLIENT-LOGIN-ANIMATION-001 | 打开客户端登录页并保持页面可见,再切换后台或启用减少动态效果 | Canvas动画在登录框背景平滑运行、不遮挡表单、不响应敏感输入;页面隐藏或组件卸载时停止帧循环,减少动态效果下显示静态背景 |
| TC-ENTERPRISE-SIGNATURE-STYLE-001 | 打开企业签名管理列表 | 企业名称和企业应用名称使用常规字重,签名名称及状态层级保持原样 | | TC-ENTERPRISE-SIGNATURE-STYLE-001 | 打开企业签名管理列表 | 企业名称和企业应用名称使用常规字重,签名名称及状态层级保持原样 |
| TC-SIGNATURE-CARRIER-REPORT-011 | 打开企业签名“报备状态”弹窗,并准备三网各有多个目标通道 | 移动、联通、电信按三列独立区域同时展示;通道不再铺成一条长列表;每个区域可独立滚动,状态保存仍提交真实“签名×通道×运营商”任务并共用修改原因 |
| TC-UI-CARRIER-TAG-001 | 检查通道、签名质量、清退预警和手机号段等运营商标签 | 移动、联通、电信复用全局低饱和胶囊组件,颜色克制且文字可辨;业务状态标签不被误改为运营商颜色 |
| TC-RECHARGE-RECEIPT-002 | 在1366×768桌面视口打开普通充值和冲正回执 | 本次金额区缩小,Logo、企业、明细、备注、说明和完成按钮无需滚动即可完整看到;异常长备注允许弹窗内容区滚动且真实文本不截断 |
| TC-CHANNEL-SORT-001 | 准备超过一页且今日提交量不同的通道并翻页 | 后端先按北京时间今日提交尝试数降序排列全部筛选结果,再分页;同量按通道名称、ID稳定排序,不出现仅当前页前端排序 |
| TC-CHANNEL-QUALITY-ZERO-001 | 查看今日提交数为0的通道 | 提交失败、送达成功、回执未知、送达失败四个比率均显示深灰色`-`且不带百分号;对应数量仍为0 |
| TC-CHANNEL-LAYOUT-001 | 查看短信通道列表 | 运营商低饱和胶囊位于通道信息列最底部横排;独立列标题为“成本费率”,费率固定4位小数且整数、小数同字号同色 |
| TC-UI-MONEY-002 | 检查运营端、客户端各金额页面及运营看板今日消费、企业应用单价 | 金额整数和小数同字号同色;末尾小数全为0时不显示,非零小数最多4位并移除末尾0;今日消费和应用单价恢复正常主数字深色样式;成本费率固定4位作为例外 |
| TC-SIGNATURE-RETIREMENT-019 | 检查两张热力图日期、行首和悬停信息 | 日期从左到右为`T-1``T-30`;行首不常驻企业和企业应用,悬停签名可看到企业、企业应用;通道维度仍能识别通道和运营商 | | TC-SIGNATURE-RETIREMENT-019 | 检查两张热力图日期、行首和悬停信息 | 日期从左到右为`T-1``T-30`;行首不常驻企业和企业应用,悬停签名可看到企业、企业应用;通道维度仍能识别通道和运营商 |
| TC-SIGNATURE-RETIREMENT-020 | 分别在企业、通道热力图搜索企业、企业应用、签名并清空 | 每张热力图只过滤自身真实维度并回到第一页;三类关键字均可命中,清空恢复,另一张热力图的关键字和页码不变 | | TC-SIGNATURE-RETIREMENT-020 | 分别在企业、通道热力图搜索企业、企业应用、签名并清空 | 每张热力图只过滤自身真实维度并回到第一页;三类关键字均可命中,清空恢复,另一张热力图的关键字和页码不变 |
| TC-SIGNATURE-RETIREMENT-021 | 悬停报备前、无快照、零量和非零量格子 | 报备前显示不适用;无快照说明当日无检测;真实快照明确显示提交条数、上游接受条数、发送成功条数和成功率,发送成功等于真实最终送达而非受理成功 | | TC-SIGNATURE-RETIREMENT-021 | 悬停报备前、无快照、零量和非零量格子 | 报备前显示不适用;无快照说明当日无检测;真实快照明确显示提交条数、上游接受条数、发送成功条数和成功率,发送成功等于真实最终送达而非受理成功 |
+11 -4
View File
@@ -2,6 +2,13 @@
> 环境命名:当前 `8.160.169.106:12026`Web/API)和 `8.160.169.106:17890`(CMPP 入站)实例统一定义为“预发布环境”。历史记录中涉及该实例的验证、部署和业务页面均按预发布环境理解;`production-deploy.sh``NODE_ENV=production` 及正式生产安全/备份规范保留原有技术语义,不代表该实例为正式生产。 > 环境命名:当前 `8.160.169.106:12026`Web/API)和 `8.160.169.106:17890`(CMPP 入站)实例统一定义为“预发布环境”。历史记录中涉及该实例的验证、部署和业务页面均按预发布环境理解;`production-deploy.sh``NODE_ENV=production` 及正式生产安全/备份规范保留原有技术语义,不代表该实例为正式生产。
## 2026-08-12 企业签名弹窗、充值回执、通道列表与金额显示优化(待发布)
- 已确认企业签名报备状态采用“三列运营商分区”方案,运营商标签采用低饱和胶囊方案;本批同步调整充值回执密度、通道今日提交后端排序、零提交比率展示和成本费率布局。
- 撤回金额小数弱化规则:所有只读金额恢复同字号同色,最多展示4位并裁掉末尾0;通道成本费率固定4位。运营看板今日消费和企业应用单价恢复正常主数字深色样式。
- 不涉及数据库结构或生产数据迁移。通道服务专项 1 suite/46 项、API 全量 35 suites/453 项、前后端 TypeScript/构建、Gateway `go test ./...``go vet ./...`、4 份队列结构契约及 `git diff --check` 已通过;Vite 仅保留既有大分块 warning,测试中的 Redis unavailable 和预期异常日志均为既有受控分支,未伪造外部依赖。
- 应用内浏览器连接真实本地 NestJS、PostgreSQL、Redis 和 production preview 验收:1366×768 下三运营商报备状态弹窗横向三分区且页面无横向溢出;普通充值回执内容区 `scrollHeight=clientHeight=546`,无需滚动即可看到完成按钮;通道零提交四个比率均为深灰短杠,移动/联通/电信低饱和胶囊色互不相同;运营看板今日消费为 30px 深黑主数字,企业应用单价为深色常规字号。820px 复核弹窗改为单列且页面无横向溢出,浏览器 console error/warn 为 0。验收未保存报备状态、未充值、未启停或编辑通道、未发送短信。
## 2026-08-12 签名热力图、登录动画、金额样式与利润口径(已提交、已部署) ## 2026-08-12 签名热力图、登录动画、金额样式与利润口径(已提交、已部署)
- 功能提交 `0cd353450abb999bd9c192c6df482af5e07095b5` 已推送并部署预发布。签名热力图新增 T-1~T-30 合计并按发送量降序,观察期继续保存真实单日快照但不触发预警;企业签名企业/应用文字改为常规字重,客户端登录框新增可降级的 Canvas 动画,金额展示统一弱化小数部分。 - 功能提交 `0cd353450abb999bd9c192c6df482af5e07095b5` 已推送并部署预发布。签名热力图新增 T-1~T-30 合计并按发送量降序,观察期继续保存真实单日快照但不触发预警;企业签名企业/应用文字改为常规字重,客户端登录框新增可降级的 Canvas 动画,金额展示统一弱化小数部分。
@@ -2004,7 +2011,7 @@ git diff --check
- 根因确认:企业应用编辑页原先按 `Math.round(元 × 100)` 保存,`0.0325 元`只能落为 3 分;PostgreSQL 的余额、流水、单价、计费和利润字段也均为 `Int` 分,无法表达万分之一元。现统一调整为 `1 元 = 10000 金额单位`,字段名中的 `Cents` 仅为兼容既有 API 保留。 - 根因确认:企业应用编辑页原先按 `Math.round(元 × 100)` 保存,`0.0325 元`只能落为 3 分;PostgreSQL 的余额、流水、单价、计费和利润字段也均为 `Int` 分,无法表达万分之一元。现统一调整为 `1 元 = 10000 金额单位`,字段名中的 `Cents` 仅为兼容既有 API 保留。
- Prisma 金额列统一升级为 `BigInt`migration `20260716150000_expand_money_precision_to_four_decimals` 将历史整数分乘以 100。迁移前已备份本地真实 PostgreSQL;抽查 `AccountTransaction、SmsApplication、SmsMessageRecord、TenantAccount` 汇总,迁移后整数值均精确为迁移前 100 倍,按新除数换算后的人民币金额不变。53 条 migration 已全部应用,Prisma schema validate 和 migrate status 均通过。 - Prisma 金额列统一升级为 `BigInt`migration `20260716150000_expand_money_precision_to_four_decimals` 将历史整数分乘以 100。迁移前已备份本地真实 PostgreSQL;抽查 `AccountTransaction、SmsApplication、SmsMessageRecord、TenantAccount` 汇总,迁移后整数值均精确为迁移前 100 倍,按新除数换算后的人民币金额不变。53 条 migration 已全部应用,Prisma schema validate 和 migrate status 均通过。
- 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;运营端与客户端的余额、授信、今日消费、今日返还、充值、短信详单、客户价、通道价和利润报表统一固定展示 4 位小数。利润 CSV 改为以“元”为表头并导出 4 位小数 - 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;该阶段曾统一固定展示 4 位小数,现已由 2026-08-12 的统一金额样式需求调整为最多 4 位并裁剪末尾无意义的 `0`,通道成本费率除外。利润 CSV 以“元”为表头并保留业务所需精度
- NestJS 对客户价、通道价、充值、授信、计费规则和计费结果增加安全整数校验;Prisma `BigInt` 响应仅在 JavaScript 安全整数范围内序列化为 number,超限直接报错,避免静默精度损失。计费单测新增 `325 × 2 = 650` 金额单位,企业应用更新单测使用 `customerUnitPrice=325` - NestJS 对客户价、通道价、充值、授信、计费规则和计费结果增加安全整数校验;Prisma `BigInt` 响应仅在 JavaScript 安全整数范围内序列化为 number,超限直接报错,避免静默精度损失。计费单测新增 `325 × 2 = 650` 金额单位,企业应用更新单测使用 `customerUnitPrice=325`
- 使用真实本地 NestJS API、PostgreSQL、Redis 和 Playwright/Chromium 编辑一条已配置通道组的企业应用:页面填写 `0.0325` 后保存,数据库核对 `SmsApplication.customerUnitPrice=325`,再次进入编辑页仍为 `0.0325`,控制台无 error;随后已恢复原单价并清理临时管理员、角色关联和操作日志。 - 使用真实本地 NestJS API、PostgreSQL、Redis 和 Playwright/Chromium 编辑一条已配置通道组的企业应用:页面填写 `0.0325` 后保存,数据库核对 `SmsApplication.customerUnitPrice=325`,再次进入编辑页仍为 `0.0325`,控制台无 error;随后已恢复原单价并清理临时管理员、角色关联和操作日志。
- API 全量 20 个 Jest 测试套件通过,其中金额与企业应用目标套件 45 条用例通过;API TypeScript build、前端 TypeScript/Vite 生产 build、Gateway `go test ./...`、Prisma validate/status 和 `git diff --check` 均通过。 - API 全量 20 个 Jest 测试套件通过,其中金额与企业应用目标套件 45 条用例通过;API TypeScript build、前端 TypeScript/Vite 生产 build、Gateway `go test ./...`、Prisma validate/status 和 `git diff --check` 均通过。
@@ -2387,7 +2394,7 @@ git diff --check
- 运营端充值记录每行新增“查看回执”操作,弹窗直接使用真实`RechargeOrder`、企业信息及订单关联的`balanceAfterCents`,据此计算入账前余额;历史记录缺少可追溯余额时显示`-`,不使用当前账户余额或前端假数据补齐。 - 运营端充值记录每行新增“查看回执”操作,弹窗直接使用真实`RechargeOrder`、企业信息及订单关联的`balanceAfterCents`,据此计算入账前余额;历史记录缺少可追溯余额时显示`-`,不使用当前账户余额或前端假数据补齐。
- 回执左上只展示系统现有`/logo/logo1.png`真实Logo;展示入账状态、本次金额、企业名称和编码、订单号、入账时间、前后余额、入账方式与备注,适合客户截图留存。 - 回执左上只展示系统现有`/logo/logo1.png`真实Logo;展示入账状态、本次金额、企业名称和编码、订单号、入账时间、前后余额、入账方式与备注,适合客户截图留存。
- “本次充值金额”采用实际精度:整数不显示小数,存在小数时移除末尾无效零;前后余额继续显示平台统一四位精度。正数显示“已入账”,负数冲正显示“已冲正”。 - “本次充值金额”采用实际精度:整数不显示小数,存在小数时移除末尾无效零;该阶段前后余额曾固定显示四位,现已随 2026-08-12 统一金额样式调整为最多四位并裁剪末尾无意义的0。正数显示“已入账”,负数冲正显示“已冲正”。
- 金额边界验证结果:`10000.0000 → 10,000``10000.2500 → 10,000.25``10000.0001 → 10,000.0001`、负数冲正`-123.4500 → 123.45`,符合主金额按实际精度展示口径。 - 金额边界验证结果:`10000.0000 → 10,000``10000.2500 → 10,000.25``10000.0001 → 10,000.0001`、负数冲正`-123.4500 → 123.45`,符合主金额按实际精度展示口径。
- 使用Node.js v24.14.0执行前端TypeScript和Vite生产构建通过,保留既有约1.93MB单chunk/579.51KB gzip警告;`git diff --check`通过。首次由系统旧Node执行时Vite不支持`??=`且错误返回0,已明确排除,未将其计为通过。 - 使用Node.js v24.14.0执行前端TypeScript和Vite生产构建通过,保留既有约1.93MB单chunk/579.51KB gzip警告;`git diff --check`通过。首次由系统旧Node执行时Vite不支持`??=`且错误返回0,已明确排除,未将其计为通过。
- 浏览器加载真实本地前端/API后进入运营登录页,页面标题正确、控制台0条error/warn;由于当前浏览器无有效会话且存在图形验证码,本轮未绕过验证码,登录后的“查看回执”点击与视觉验收仍需人工登录后补测。 - 浏览器加载真实本地前端/API后进入运营登录页,页面标题正确、控制台0条error/warn;由于当前浏览器无有效会话且存在图形验证码,本轮未绕过验证码,登录后的“查看回执”点击与视觉验收仍需人工登录后补测。
@@ -3474,7 +3481,7 @@ git diff --check
- API、Gateway、Nginx、PostgreSQL和MinIO均activeAPI/Gateway/MinIO健康、Redis PONG、Stream消费者1、`pending=0``lag=0`,运营端、客户端和公网API health均HTTP 200;部署后API/Gateway error和warning级日志为0,运行源码和前端产物均不存在`legacy-report-tasks`或“历史待确认”标记。 - API、Gateway、Nginx、PostgreSQL和MinIO均activeAPI/Gateway/MinIO健康、Redis PONG、Stream消费者1、`pending=0``lag=0`,运营端、客户端和公网API health均HTTP 200;部署后API/Gateway error和warning级日志为0,运行源码和前端产物均不存在`legacy-report-tasks`或“历史待确认”标记。
- 9条活动通道重启恢复后6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续为发布前已知的供应商`authentication`失败。本轮未修改通道账号、密码、启停状态、企业余额或客户连接,没有手工发送、补发或重投短信,也没有修改Webhook。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于业务源码提交。 - 9条活动通道重启恢复后6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续为发布前已知的供应商`authentication`失败。本轮未修改通道账号、密码、启停状态、企业余额或客户连接,没有手工发送、补发或重投短信,也没有修改Webhook。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于业务源码提交。
## 2026-08-12 利润报表收入口径调整(未提交、未发布) ## 2026-08-12 利润报表收入口径调整(历史开发记录,已随 `0cd3534` 发布)
- 利润报表“净消费”统一更名为“收入”。收入按每条最终成功短信的`billingUnits × unitPrice`发送时快照逐条计算后汇总,失败和未知短信不计收入;不再以`SmsBillingRecord.billingStatus=charged/refunded`决定利润报表收入。不同历史单价必须分别计算,不能使用当前应用单价倒算。 - 利润报表“净消费”统一更名为“收入”。收入按每条最终成功短信的`billingUnits × unitPrice`发送时快照逐条计算后汇总,失败和未知短信不计收入;不再以`SmsBillingRecord.billingStatus=charged/refunded`决定利润报表收入。不同历史单价必须分别计算,不能使用当前应用单价倒算。
- 通道维度继续仅将收入归属到短信最终提交所在通道,避免补发链路在多个通道重复计收;成本仍按各次提交的通道成本单价快照乘以成功分片数,利润=收入-成本,综合利润率=合计利润/合计收入。 - 通道维度继续仅将收入归属到短信最终提交所在通道,避免补发链路在多个通道重复计收;成本仍按各次提交的通道成本单价快照乘以成功分片数,利润=收入-成本,综合利润率=合计利润/合计收入。
@@ -3483,7 +3490,7 @@ git diff --check
- 报表专项9/9、API全量35个suite/450项通过;前端TypeScript、API TypeScript正式构建及Vite 8.1.5生产构建通过,Vite仅保留既有大chunk提示。依赖包装器因既有`msgpackr-extract`构建脚本未审批而未用于验证,改为直接调用已安装的本地Jest、TypeScript和Vite入口,未修改依赖审批或供应链配置。 - 报表专项9/9、API全量35个suite/450项通过;前端TypeScript、API TypeScript正式构建及Vite 8.1.5生产构建通过,Vite仅保留既有大chunk提示。依赖包装器因既有`msgpackr-extract`构建脚本未审批而未用于验证,改为直接调用已安装的本地Jest、TypeScript和Vite入口,未修改依赖审批或供应链配置。
- 本轮未提交、未推送、未部署,未连接或修改预生产数据,未发送、补发或重投短信。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于本需求;依赖包装器临时生成的`pnpm-lock.yaml`已精确移除。 - 本轮未提交、未推送、未部署,未连接或修改预生产数据,未发送、补发或重投短信。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于本需求;依赖包装器临时生成的`pnpm-lock.yaml`已精确移除。
## 2026-08-12 热力图观察期、登录动画与金额样式(发布) ## 2026-08-12 热力图观察期、登录动画与金额样式(历史开发记录,已随 `0cd3534` 发布)
- 修复签名报备通过后完整观察窗口内不生成快照的问题:04:00检测现在按T-1自然日保存单日提交、受理和成功量,观察期状态为`observing`,不创建预警周期、站内消息或Webhook;观察期结束后仍使用配置的15/30天窗口累计量判断预警。热力图将检测日映射到T-1活动日,每行增加30日受理短信合计并按合计降序排序。 - 修复签名报备通过后完整观察窗口内不生成快照的问题:04:00检测现在按T-1自然日保存单日提交、受理和成功量,观察期状态为`observing`,不创建预警周期、站内消息或Webhook;观察期结束后仍使用配置的15/30天窗口累计量判断预警。热力图将检测日映射到T-1活动日,每行增加30日受理短信合计并按合计降序排序。
- 企业签名管理列表中的企业、企业应用名称改为常规400字重;签名名称和状态层级不变。客户端登录页增加纯展示Canvas粒子连线动画,Canvas不接收点击、不读取输入,组件卸载时取消动画帧,系统减少动态效果或页面隐藏时停止位移。 - 企业签名管理列表中的企业、企业应用名称改为常规400字重;签名名称和状态层级不变。客户端登录页增加纯展示Canvas粒子连线动画,Canvas不接收点击、不读取输入,组件卸载时取消动画帧,系统减少动态效果或页面隐藏时停止位移。
+3 -11
View File
@@ -10,7 +10,7 @@ import {
type UnreportedSignatureItem, type UnreportedSignatureItem,
type PagedResult, type PagedResult,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { successRateClassName, successRateTone } from '@/utils/successRate'; import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
@@ -348,7 +348,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong> <strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong>
{row.channelName ? <small>{row.channelName}</small> : null} {row.channelName ? <small>{row.channelName}</small> : null}
</span> </span>
<Tag tone="neutral">{carrierLabels[row.carrier] ?? row.carrier}</Tag> <CarrierTag carrier={row.carrier} />
</th> </th>
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td> <td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => { {dates.map((dateKey) => {
@@ -508,7 +508,7 @@ function SignatureQualityDrawer({
{carriers.map((carrier) => ( {carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}> <article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<div> <div>
<Tag tone={carrierTagTone(carrier.carrier)}>{carrierLabel(carrier.carrier)}</Tag> <CarrierTag carrier={carrier.carrier} />
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong> <strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div> </div>
<dl> <dl>
@@ -639,14 +639,6 @@ function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知'; return carrierLabels[normalizeCarrier(value)] ?? '未知';
} }
function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral' {
const carrier = normalizeCarrier(value);
if (carrier === 'mobile') return 'info';
if (carrier === 'unicom') return 'accent';
if (carrier === 'telecom') return 'warning';
return 'neutral';
}
function formatDuration(value?: number | null) { function formatDuration(value?: number | null) {
if (value == null) return '—'; if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`; if (value < 1000) return `${Math.round(value)} 毫秒`;
+3 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react'; import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
@@ -18,18 +18,6 @@ type CarrierRule = DictionaryItem & {
remark?: string | null; remark?: string | null;
}; };
const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> = {
: 'success',
: 'info',
: 'warning',
};
const ruleCarrierMeta: Record<string, { label: string; tone: 'success' | 'info' | 'warning' | 'neutral' }> = {
mobile: { label: '中国移动', tone: 'success' },
unicom: { label: '中国联通', tone: 'info' },
telecom: { label: '中国电信', tone: 'warning' },
};
type PhoneSegmentSummaryProps = { type PhoneSegmentSummaryProps = {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
@@ -165,7 +153,7 @@ export function AdminPhoneSegmentsPage() {
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [ const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> }, { key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <Tag tone={carrierTone[record.carrier] ?? 'neutral'}>{record.carrier}</Tag> : '-' }, { key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <CarrierTag carrier={record.carrier} /> : '-' },
{ key: 'province', title: '省份', width: '110px', render: (record) => record.province ?? '-' }, { key: 'province', title: '省份', width: '110px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '110px', render: (record) => record.city ?? '-' }, { key: 'city', title: '城市', width: '110px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '170px', render: (record) => formatDateTime(record.createdAt) }, { key: 'createdAt', title: '创建时间', width: '170px', render: (record) => formatDateTime(record.createdAt) },
@@ -173,7 +161,7 @@ export function AdminPhoneSegmentsPage() {
], []); ], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [ const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => { const meta = ruleCarrierMeta[record.carrier ?? '']; return meta ? <Tag tone={meta.tone}>{meta.label}</Tag> : record.carrier ?? '-'; } }, { key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ? <CarrierTag carrier={record.carrier} /> : '-' },
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> }, { key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 }, { key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' }, { key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
@@ -11,7 +11,7 @@ import {
type SignatureRetirementWebhook, type SignatureRetirementWebhook,
type TenantOption, type TenantOption,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
const carriers = ['mobile', 'unicom', 'telecom'] as const; const carriers = ['mobile', 'unicom', 'telecom'] as const;
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' }; const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
@@ -112,7 +112,7 @@ export function AdminSignatureRetirementPage() {
const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [ const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [
{ key: 'title', title: '预警', width: '340px', render: (item) => <div className="ui-table__long-text"><strong>{item.title}</strong><br /><span>{item.content}</span></div> }, { key: 'title', title: '预警', width: '340px', render: (item) => <div className="ui-table__long-text"><strong>{item.title}</strong><br /><span>{item.content}</span></div> },
{ key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}<br /><small>{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}</small><br /><small>{item.channelName ?? '企业维度'}</small></> }, { key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}<br /><small>{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}</small><br /><small>{item.channelName ?? '企业维度'}</small></> },
{ key: 'carrier', title: '运营商', width: '90px', render: (item) => <Tag tone="warning">{carrierLabels[item.detection?.carrier ?? 'mobile']}</Tag> }, { key: 'carrier', title: '运营商', width: '90px', render: (item) => <CarrierTag carrier={item.detection?.carrier ?? 'mobile'} /> },
{ key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' }, { key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' },
{ key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) }, { key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) },
{ key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? <Tag></Tag> : item.isRead ? <Tag tone="info"></Tag> : <Tag tone="warning"></Tag> }, { key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? <Tag></Tag> : item.isRead ? <Tag tone="info"></Tag> : <Tag tone="warning"></Tag> },
+19 -2
View File
@@ -22,7 +22,7 @@
.sms-channel-table__row { .sms-channel-table__row {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(280px, 1.6fr) 190px; grid-template-columns: minmax(190px, 1.25fr) 110px 86px 96px minmax(280px, 1.6fr) 190px;
min-width: 940px; min-width: 940px;
} }
@@ -60,11 +60,18 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.sms-channel-identity span { .sms-channel-identity > span {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.sms-channel-identity__carriers {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.sms-channel-carrier-price { .sms-channel-carrier-price {
align-items: flex-start; align-items: flex-start;
display: grid; display: grid;
@@ -72,6 +79,12 @@
justify-items: start; justify-items: start;
} }
.sms-channel-carrier-price strong {
color: var(--color-text-strong);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.sms-channel-status-cell { .sms-channel-status-cell {
align-items: flex-start; align-items: flex-start;
display: grid; display: grid;
@@ -136,6 +149,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.sms-channel-rate .sms-channel-rate__empty {
color: #4b5563;
}
.sms-channel-actions { .sms-channel-actions {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
+12 -12
View File
@@ -1,15 +1,15 @@
import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react'; import { Copy, Eye, FileText, Pencil, Power, Send } from 'lucide-react';
import { DeleteRiskAction, MoneyText, Pagination, Tag } from '@/components/ui'; import { CarrierTag, DeleteRiskAction, Pagination, Tag } from '@/components/ui';
import { formatCents } from '@/utils/currency'; import { formatRateAmount } from '@/utils/currency';
import { successRateClassName } from '@/utils/successRate'; import { successRateClassName } from '@/utils/successRate';
import { carrierLabelMap, carrierToneMap, statusLabelMap, statusToneMap } from './channelModel'; import { statusLabelMap, statusToneMap } from './channelModel';
import type { ChannelConfirmAction, ChannelModalState, SmsChannel } from './channelTypes'; import type { ChannelConfirmAction, ChannelModalState, SmsChannel } from './channelTypes';
function RateBlock({ label, rate, count, isSuccess = false }: { label: string; rate: number; count: number; isSuccess?: boolean }) { function RateBlock({ label, rate, count, empty, isSuccess = false }: { label: string; rate: number; count: number; empty: boolean; isSuccess?: boolean }) {
return ( return (
<div className="sms-channel-rate"> <div className="sms-channel-rate">
<small>{label}</small> <small>{label}</small>
<strong className={isSuccess ? successRateClassName(rate) : undefined}>{rate}%</strong> <strong className={empty ? 'sms-channel-rate__empty' : isSuccess ? successRateClassName(rate) : undefined}>{empty ? '-' : `${rate}%`}</strong>
<span>{count.toLocaleString('zh-CN')}</span> <span>{count.toLocaleString('zh-CN')}</span>
</div> </div>
); );
@@ -44,7 +44,7 @@ export function ChannelTable({
<div className="surface sms-channel-table"> <div className="surface sms-channel-table">
<div className="sms-channel-table__head"> <div className="sms-channel-table__head">
<span></span> <span></span>
<span> / </span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span> / </span> <span> / </span>
@@ -55,10 +55,10 @@ export function ChannelTable({
<div className="sms-channel-identity"> <div className="sms-channel-identity">
<strong>{channel.name}</strong> <strong>{channel.name}</strong>
<span> ID{channel.id}</span> <span> ID{channel.id}</span>
<div className="sms-channel-identity__carriers">{channel.carriers.map((carrier) => <CarrierTag carrier={carrier} key={carrier} />)}</div>
</div> </div>
<div className="sms-channel-carrier-price"> <div className="sms-channel-carrier-price">
<div>{channel.carriers.map((carrier) => <Tag key={carrier} tone={carrierToneMap[carrier]}>{carrierLabelMap[carrier]}</Tag>)}</div> <strong>{formatRateAmount(channel.unitPrice / 10_000)} /</strong>
<strong><MoneyText>{formatCents(channel.unitPrice)} </MoneyText></strong>
</div> </div>
<div className="sms-channel-status-cell"> <div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag> <Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
@@ -68,10 +68,10 @@ export function ChannelTable({
</div> </div>
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong> <strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
<div className="sms-channel-quality"> <div className="sms-channel-quality">
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} /> <RateBlock count={channel.submitFailureCount} empty={channel.total === 0} label="提交失败" rate={channel.submitFailureRate} />
<RateBlock count={channel.successCount} isSuccess label="送达成功" rate={channel.successRate} /> <RateBlock count={channel.successCount} empty={channel.total === 0} isSuccess label="送达成功" rate={channel.successRate} />
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} /> <RateBlock count={channel.unknownCount} empty={channel.total === 0} label="回执未知" rate={channel.unknownRate} />
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} /> <RateBlock count={channel.failureCount} empty={channel.total === 0} label="送达失败" rate={channel.failureRate} />
</div> </div>
<div className="sms-channel-actions"> <div className="sms-channel-actions">
<button className="sms-channel-report-entry" onClick={() => onOpenReports(channel)} type="button"> <button className="sms-channel-report-entry" onClick={() => onOpenReports(channel)} type="button">
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Edit3, Settings2, Trash2 } from 'lucide-react'; import { Edit3, Settings2, Trash2 } from 'lucide-react';
import { Button, MoneyText, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; import { Button, Pagination, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { formatAmount } from '@/utils/currency'; import { formatAmount } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import type { ConfirmAction, SmsApp } from './applicationTypes'; import type { ConfirmAction, SmsApp } from './applicationTypes';
@@ -56,7 +56,7 @@ export function EnterpriseApplicationTable({
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise }, { key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` }, { key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` }, { key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => <MoneyText>{formatAmount(record.unitPrice)} </MoneyText> }, { key: 'unitPrice', title: '单价', width: '130px', render: (record) => <strong className="application-unit-price">{formatAmount(record.unitPrice)} </strong> },
{ {
key: 'cmppStatus', key: 'cmppStatus',
title: '客户连接状态', title: '客户连接状态',
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi'; import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Button, Modal, Select, Textarea } from '@/components/ui'; import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
import { AuditStatusTag, carrierLabel, CarrierReportTag, formatDate } from './signature.helpers'; import { AuditStatusTag, carrierLabel, CarrierReportTag, formatDate } from './signature.helpers';
import type { DrainageInfo } from './signature.types'; import type { DrainageInfo } from './signature.types';
@@ -35,6 +35,7 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) { export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
const targets = item.reportTargets ?? []; const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status]))); const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -46,10 +47,10 @@ export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: Cli
onSaved(); onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); } } catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
} }
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态"> return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="修改签名报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div> <div className="signature-report-status"><div className="signature-report-status__context"><strong>{item.name}</strong><span>{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}</span></div><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="surface admin-report-target-row" key={key}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.carrier)}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="empty-state"></div>} {targets.length ? <div className="signature-report-status__carriers">{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return <section className="signature-report-status__carrier" key={carrier}><header><CarrierTag carrier={carrier} /><span>{carrierTargets.length} </span></header><div className="signature-report-status__list">{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="signature-report-status__row" key={key}><strong title={target.channel.name}>{target.channel.name}</strong><Select aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`} onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="signature-report-status__empty">{carrierLabel(carrier)}</div>}</div></section>; })}</div> : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} /> <Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div> </div>
</Modal>; </Modal>;
+24
View File
@@ -0,0 +1,24 @@
import type { HTMLAttributes } from 'react';
const carrierMeta = {
mobile: { label: '移动', className: 'ui-carrier-tag--mobile' },
unicom: { label: '联通', className: 'ui-carrier-tag--unicom' },
telecom: { label: '电信', className: 'ui-carrier-tag--telecom' },
} as const;
export type CarrierTagValue = keyof typeof carrierMeta;
export function normalizeCarrierTag(value?: string | null): CarrierTagValue | null {
const normalized = String(value ?? '').trim().toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(normalized)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(normalized)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(normalized)) return 'telecom';
return null;
}
export function CarrierTag({ carrier, className = '', ...props }: HTMLAttributes<HTMLSpanElement> & { carrier: string }) {
const normalized = normalizeCarrierTag(carrier);
if (!normalized) return <span className={['ui-carrier-tag', 'ui-carrier-tag--neutral', className].filter(Boolean).join(' ')} {...props}>{carrier || '未知'}</span>;
const meta = carrierMeta[normalized];
return <span className={['ui-carrier-tag', meta.className, className].filter(Boolean).join(' ')} {...props}>{meta.label}</span>;
}
+3 -1
View File
@@ -19,6 +19,7 @@ type ModalProps = {
initialFocusRef?: RefObject<HTMLElement | null>; initialFocusRef?: RefObject<HTMLElement | null>;
closeGuardTitle?: string; closeGuardTitle?: string;
closeGuardDescription?: string; closeGuardDescription?: string;
className?: string;
}; };
const focusableSelector = [ const focusableSelector = [
@@ -96,6 +97,7 @@ export function Modal({
initialFocusRef, initialFocusRef,
closeGuardTitle = '放弃未保存的修改?', closeGuardTitle = '放弃未保存的修改?',
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。', closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
className,
}: ModalProps) { }: ModalProps) {
const titleId = useId(); const titleId = useId();
const guardTitleId = useId(); const guardTitleId = useId();
@@ -213,7 +215,7 @@ export function Modal({
<section <section
aria-labelledby={titleId} aria-labelledby={titleId}
aria-modal="true" aria-modal="true"
className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} className={['ui-modal__panel', `ui-modal__panel--${size}`, className].filter(Boolean).join(' ')}
ref={panelRef} ref={panelRef}
role="dialog" role="dialog"
tabIndex={-1} tabIndex={-1}
+2 -5
View File
@@ -1,8 +1,5 @@
import { Children, type ReactNode } from 'react'; import type { ReactNode } from 'react';
export function MoneyText({ children, className }: { children: ReactNode; className?: string }) { export function MoneyText({ children, className }: { children: ReactNode; className?: string }) {
const text = Children.toArray(children).map((value) => typeof value === 'string' || typeof value === 'number' ? String(value) : '').join(''); return <span className={className}>{children}</span>;
const match = text.match(/^(.*?)(\.\d+)(\s*[^\d]*)$/);
if (!match) return <span className={className}>{text}</span>;
return <span className={className}>{match[1]}<span className="money-text__fraction">{match[2]}</span>{match[3]}</span>;
} }
+6 -6
View File
@@ -4,7 +4,6 @@ import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { Button } from './Button'; import { Button } from './Button';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { MoneyText } from './MoneyText';
type RechargeReceiptDialogProps = { type RechargeReceiptDialogProps = {
open: boolean; open: boolean;
@@ -14,7 +13,7 @@ type RechargeReceiptDialogProps = {
}; };
export function formatReceiptAmount(moneyUnits: number) { export function formatReceiptAmount(moneyUnits: number) {
return formatCents(Math.abs(moneyUnits)).replace(/\.?0+$/, ''); return formatCents(Math.abs(moneyUnits));
} }
export function RechargeReceiptDialog({ export function RechargeReceiptDialog({
@@ -35,12 +34,13 @@ export function RechargeReceiptDialog({
return ( return (
<Modal <Modal
className="recharge-receipt-modal"
footer={<Button onClick={onClose}></Button>} footer={<Button onClick={onClose}></Button>}
onClose={onClose} onClose={onClose}
open={open} open={open}
title="账户充值回执" title="账户充值回执"
> >
<article className="recharge-receipt"> <article className={['recharge-receipt', isCorrection ? 'recharge-receipt--correction' : ''].filter(Boolean).join(' ')}>
<header className="recharge-receipt__header"> <header className="recharge-receipt__header">
<img <img
alt="聆界短信服务平台" alt="聆界短信服务平台"
@@ -58,7 +58,7 @@ export function RechargeReceiptDialog({
<strong> <strong>
{isCorrection ? '' : '+'} {isCorrection ? '' : '+'}
<small>¥</small> <small>¥</small>
<MoneyText>{formatReceiptAmount(record.amountCents)}</MoneyText> {formatReceiptAmount(record.amountCents)}
</strong> </strong>
</section> </section>
@@ -79,11 +79,11 @@ export function RechargeReceiptDialog({
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{balanceBefore === null ? '-' : <MoneyText>¥{formatCents(balanceBefore)}</MoneyText>}</dd> <dd>{balanceBefore === null ? '-' : <>¥{formatCents(balanceBefore)}</>}</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{balanceAfter === null || balanceAfter === undefined ? '-' : <MoneyText>¥{formatCents(balanceAfter)}</MoneyText>}</dd> <dd>{balanceAfter === null || balanceAfter === undefined ? '-' : <>¥{formatCents(balanceAfter)}</>}</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
+2
View File
@@ -1,6 +1,8 @@
export { Button } from './Button'; export { Button } from './Button';
export { Breadcrumb } from './Breadcrumb'; export { Breadcrumb } from './Breadcrumb';
export { Chart } from './Chart'; export { Chart } from './Chart';
export { CarrierTag, normalizeCarrierTag } from './CarrierTag';
export type { CarrierTagValue } from './CarrierTag';
export { DateRangeInput } from './DateRangeInput'; export { DateRangeInput } from './DateRangeInput';
export { DateTimeInput } from './DateTimeInput'; export { DateTimeInput } from './DateTimeInput';
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail'; export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
+18
View File
@@ -1075,6 +1075,24 @@
color: var(--color-accent-strong); color: var(--color-accent-strong);
} }
.ui-carrier-tag {
align-items: center;
border-radius: var(--radius-full);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
justify-content: center;
line-height: 1;
min-height: 26px;
padding: 0 12px;
white-space: nowrap;
}
.ui-carrier-tag--mobile { background: #e7ebf0; color: #536170; }
.ui-carrier-tag--unicom { background: #eeeae7; color: #6a6058; }
.ui-carrier-tag--telecom { background: #e7ece9; color: #58685e; }
.ui-carrier-tag--neutral { background: #eceff2; color: #596474; }
.ui-tabs { .ui-tabs {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
+97 -15
View File
@@ -2015,11 +2015,70 @@
font-weight: 400; font-weight: 400;
} }
.money-text__fraction { .signature-report-status {
color: var(--color-text-muted); display: grid;
font-size: 0.92em; 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 { .signature-retirement-heatmap__total {
color: var(--color-text-strong); color: var(--color-text-strong);
font-weight: 700; font-weight: 700;
@@ -7941,6 +8000,15 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.recharge-receipt-modal .ui-modal__header,
.recharge-receipt-modal .ui-modal__footer {
padding: var(--space-3) var(--space-5);
}
.recharge-receipt-modal .ui-modal__body {
padding: var(--space-3) var(--space-5);
}
.recharge-receipt { .recharge-receipt {
background: background:
linear-gradient(135deg, rgba(217, 195, 160, 0.18), transparent 42%), linear-gradient(135deg, rgba(217, 195, 160, 0.18), transparent 42%),
@@ -7964,13 +8032,13 @@
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
min-height: 74px; min-height: 52px;
padding: var(--space-6) var(--space-7) var(--space-4); padding: var(--space-3) var(--space-5) var(--space-2);
} }
.recharge-receipt__logo { .recharge-receipt__logo {
display: block; display: block;
height: 38px; height: 32px;
max-width: 190px; max-width: 190px;
object-fit: contain; object-fit: contain;
object-position: left center; object-position: left center;
@@ -8001,7 +8069,7 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
display: grid; display: grid;
justify-items: center; justify-items: center;
padding: var(--space-5) var(--space-7) var(--space-8); padding: var(--space-2) var(--space-5) var(--space-3);
} }
.recharge-receipt__amount > span, .recharge-receipt__amount > span,
@@ -8013,11 +8081,11 @@
.recharge-receipt__amount strong { .recharge-receipt__amount strong {
color: var(--color-text-strong); color: var(--color-text-strong);
font-size: clamp(36px, 7vw, 52px); font-size: clamp(28px, 4vw, 34px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: -0.035em; letter-spacing: -0.035em;
line-height: 1.15; line-height: 1.15;
margin-top: var(--space-2); margin-top: var(--space-1);
} }
.recharge-receipt__amount small { .recharge-receipt__amount small {
@@ -8029,12 +8097,12 @@
.recharge-receipt__enterprise { .recharge-receipt__enterprise {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-6) var(--space-7); padding: var(--space-3) var(--space-5);
} }
.recharge-receipt__enterprise strong { .recharge-receipt__enterprise strong {
color: var(--color-text-strong); color: var(--color-text-strong);
font-size: var(--font-size-xl); font-size: var(--font-size-lg);
} }
.recharge-receipt__enterprise small { .recharge-receipt__enterprise small {
@@ -8048,14 +8116,14 @@
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0; margin: 0;
padding: var(--space-2) var(--space-7); padding: var(--space-1) var(--space-5);
} }
.recharge-receipt__details > div { .recharge-receipt__details > div {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
min-width: 0; min-width: 0;
padding: var(--space-4) 0; padding: var(--space-2) 0;
} }
.recharge-receipt__details > div:nth-child(even) { .recharge-receipt__details > div:nth-child(even) {
@@ -8078,7 +8146,7 @@
.recharge-receipt__remark { .recharge-receipt__remark {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-5) var(--space-7); padding: var(--space-2) var(--space-5);
} }
.recharge-receipt__remark p { .recharge-receipt__remark p {
@@ -8093,10 +8161,19 @@
border-top: 1px dashed var(--color-border-strong); border-top: 1px dashed var(--color-border-strong);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
padding: var(--space-4) var(--space-7); padding: var(--space-2) var(--space-5);
text-align: center; text-align: center;
} }
.recharge-receipt--correction .recharge-receipt__amount {
padding-bottom: var(--space-2);
padding-top: var(--space-1);
}
.recharge-receipt--correction .recharge-receipt__amount strong {
font-size: 28px;
}
@media (max-width: 560px) { @media (max-width: 560px) {
.recharge-receipt__header, .recharge-receipt__header,
.recharge-receipt__enterprise, .recharge-receipt__enterprise,
@@ -9344,3 +9421,8 @@
grid-template-columns: minmax(0, 1fr); 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;
}
+5 -1
View File
@@ -2,11 +2,15 @@ export const MONEY_UNITS_PER_YUAN = 10_000;
export function formatAmount(value: number) { export function formatAmount(value: number) {
return value.toLocaleString('zh-CN', { return value.toLocaleString('zh-CN', {
minimumFractionDigits: 4, minimumFractionDigits: 0,
maximumFractionDigits: 4, maximumFractionDigits: 4,
}); });
} }
export function formatRateAmount(value: number) {
return value.toLocaleString('zh-CN', { minimumFractionDigits: 4, maximumFractionDigits: 4 });
}
export function formatCents(cents?: number | null) { export function formatCents(cents?: number | null) {
return formatAmount((cents ?? 0) / MONEY_UNITS_PER_YUAN); return formatAmount((cents ?? 0) / MONEY_UNITS_PER_YUAN);
} }