feat: refine client status and review views

This commit is contained in:
hectorzhao
2026-08-27 17:32:05 +08:00
parent d6edb88b76
commit 01cffa4758
31 changed files with 528 additions and 117 deletions
@@ -45,6 +45,7 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
},
smsBatchTask: {
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([]),
},
riskHitRecord: {
createMany: jest.fn(),
@@ -152,6 +153,21 @@ describe('RiskReviewService', () => {
}));
});
it('adds the associated batch task number to SMS review rows', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([{ id: 'review-task-1', taskNo: 'REVIEW-001' }]);
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'batch-1', taskNo: 'BATCH-001', riskTaskId: 'review-task-1' }]);
const service = new RiskReviewService(prisma as never);
await expect(service.listTasks(undefined, 'pending_review')).resolves.toEqual([
expect.objectContaining({ batchTask: { id: 'batch-1', taskNo: 'BATCH-001' } }),
]);
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith({
where: { riskTaskId: { in: ['review-task-1'] } },
select: { id: true, taskNo: true, riskTaskId: true },
});
});
it('filters SMS review tasks by their submission time', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([]);
+8 -2
View File
@@ -202,8 +202,8 @@ export class RiskReviewService {
});
}
listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
return this.prisma.smsSendTask.findMany({
async listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
const tasks = await this.prisma.smsSendTask.findMany({
where: {
tenantId,
status,
@@ -231,6 +231,12 @@ export class RiskReviewService {
},
orderBy: { createdAt: 'desc' },
});
const batchTasks = tasks.length ? await this.prisma.smsBatchTask.findMany({
where: { riskTaskId: { in: tasks.map((task) => task.id) } },
select: { id: true, taskNo: true, riskTaskId: true },
}) : [];
const batchByRiskTaskId = new Map(batchTasks.map((task) => [task.riskTaskId, { id: task.id, taskNo: task.taskNo }]));
return tasks.map((task) => ({ ...task, batchTask: batchByRiskTaskId.get(task.id) ?? null }));
}
listPendingTasks() {
+35
View File
@@ -223,14 +223,47 @@ export class SmsSignatureService {
updatedAt: true,
},
},
reportTasks: {
select: { channelId: true, carrier: true, status: true, approvalScope: true, reportType: true, drainageItemId: true },
},
_count: { select: { reportMaterials: true } },
},
orderBy: { updatedAt: 'desc' },
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
take: query.pageSize,
});
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
}).then((count) => count > 0);
return signatures.map((signature) => {
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [...new Map(routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted')
.map((channel) => [channel.id, channel])).values()];
const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature');
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = applicationChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
}));
const drainageCarrierReportSummary = Object.fromEntries(signature.drainageItems.map((item) => {
const targets = applicationChannels.filter((channel) => hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const tasks = signature.reportTasks.filter((task) => task.reportType === 'drainage' && task.drainageItemId === item.id);
return [item.id, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = carrierTargets.map((channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
}))];
}));
return {
id: signature.id,
tenantId: signature.tenantId,
@@ -247,6 +280,8 @@ export class SmsSignatureService {
application: signature.application,
materials: signature.materials,
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
carrierReportSummary,
drainageCarrierReportSummary,
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
drainageInfo: {
links: signature.drainageItems.map((item) => ({
@@ -0,0 +1,216 @@
# 批量发送任务人工审核状态优化方案
## 1. 文档目的
本文档记录客户端创建批量发送任务并进入运营人工审核期间的当前代码行为、已确认问题、建议修复范围和验收标准,供主任务实施与测试使用。
本文结论来自 2026-08-27 对当前工作区代码的静态核对,不代表测试环境或预生产环境的实时运行结果。实施完成后仍需通过接口、数据库和页面进行完整验证。
## 2. 当前代码行为
客户端创建批量发送任务并命中人工审核规则时,系统会创建两类关联记录:
1. `SmsSendTask`:运营审核任务。
- 风控判定结果为 `pending_review`
- 任务状态为 `pending_review`
- 运营端“短信审核”页面默认查询该状态,可执行通过或驳回。
2. `SmsBatchTask`:实际批量发送任务。
- 状态同样为 `pending_review`
- `riskTaskId` 指向对应的 `SmsSendTask`
- 客户端批量任务页面和运营端短信任务进度页面均可查询到该记录。
短信明细也会创建,并通过 `reviewTaskId` 或批量任务的 `riskTaskId` 与审核任务关联。在审核通过前,短信不应进入实际发送流程。
主要代码位置:
- `api/src/risk-review/risk-review.service.ts`:风控判定及 `SmsSendTask` 创建。
- `api/src/send-chain/send-batch-entry.service.ts``SmsBatchTask`、短信明细创建及审核任务关联。
- `api/src/risk-review/admin-risk-review.controller.ts`:运营审核任务查询与审核入口。
- `api/src/send-chain/send-review-continuation.service.ts`:审核决定后的发送流程续接。
- `src/apps/admin/AdminSmsAuditPage.tsx`:运营端短信审核页面。
- `src/apps/admin/AdminSmsTaskProgressPage.tsx`:运营端短信任务进度页面。
- `src/apps/client/ClientBatchTasksPage.tsx`:客户端批量任务页面。
## 3. 已确认问题
### 3.1 客户端审核状态显示错误
`ClientBatchTasksPage.tsx` 的状态归一化逻辑没有识别 `pending_review`,所有未单独处理的状态都会落入 `sending`,因此待人工审核任务被错误显示为“发送中”。
### 3.2 运营端任务进度状态显示错误
`src/apps/admin/sms-task-progress/taskModel.ts` 同样没有将 `pending_review` 作为独立任务状态,导致运营端短信任务进度页面也将待审核任务显示为“发送中”。
### 3.3 审核字段在客户端映射时丢失
后端任务类型和接口结果已包含以下字段:
- `auditStatus`
- `reviewReason`
- `rejectReason`
- `riskTaskId`
但客户端页面的 `mapTask()` 没有保留这些字段,因此客户无法看到任务正在审核、触发原因或驳回原因。
### 3.4 客户端展示了不可用的“终止”操作
客户端将 `pending_review` 映射为 `sending` 后,会启用“终止”按钮;但客户端取消接口只允许取消后端状态为 `scheduled` 的任务。对待审核任务点击该按钮必然返回失败。
### 3.5 存在孤立审核任务风险
当前流程先调用风控服务创建 `SmsSendTask`,之后才继续余额校验、余额冻结和 `SmsBatchTask` 创建。如果后续步骤失败,可能留下没有实际批量任务与短信明细的审核记录。
该问题属于流程可靠性风险,建议与前端状态修复分开实施和提交。
## 4. 优化目标
1. 客户端和运营端都能准确识别并显示“待人工审核”。
2. 审核期间不得误导用户认为短信正在发送。
3. 页面操作权限必须与后端状态机一致。
4. 审核原因、驳回原因及关联审核任务应可追踪。
5. 审核通过或驳回后,审核任务、批量任务、短信明细和余额状态保持一致。
6. 后续消除审核任务先创建导致的孤立记录风险。
## 5. 建议实施方案
### 5.1 第一阶段:最小充分修复
#### 5.1.1 客户端批量任务页面
-`pending_review` 增加为独立展示状态。
- 标签显示“待人工审核”,使用警告色。
- 审核期间发送进度保持为零,不显示为正在发送。
- 在详情中展示 `reviewReason`
- 审核驳回后展示 `rejectReason`
- `pending_review` 状态隐藏或禁用“终止”按钮。
- 只有后端状态为 `scheduled` 时才允许调用客户端取消接口。
#### 5.1.2 运营端短信任务进度页面
-`pending_review` 增加为独立展示状态。
- 标签显示“待人工审核”,使用警告色。
- 审核期间不展示发送中进度语义。
- 保留 `rawStatus``auditStatus``reviewReason``rejectReason``riskTaskId`
- 待审核任务不提供普通“终止发送”操作。
- 条件允许时增加“前往审核”入口,定位到对应的 `SmsSendTask`
#### 5.1.3 运营端短信审核页面
- 同时展示审核任务号和关联批量任务号。
- 展示企业、应用、号码数量、短信内容、触发规则和审核原因。
- 审核完成后允许跳转到批量任务进度详情。
- 若暂不实现跨页面跳转,至少保证审核任务号、批量任务号及关联关系可见。
#### 5.1.4 统一前端状态模型
建议将客户端和运营端重复的状态归一化逻辑提取到共享模块,统一维护:
- 状态显示名称。
- 标签颜色。
- 进度语义。
- 允许执行的操作。
- 是否显示审核原因或失败原因。
禁止继续使用“未识别状态默认等于发送中”的策略。未识别状态应显示“未知状态”,并保留后端原始状态值,便于发现状态协议漂移。
### 5.2 建议统一状态映射
| 后端状态 | 页面显示 | 主要允许操作 |
| --- | --- | --- |
| `pending_review` | 待人工审核 | 查看 |
| `scheduled` | 等待定时发送 | 客户端可取消 |
| `ready``queued` | 等待发送 | 查看 |
| `sending``submitted` | 发送中 | 按权限终止 |
| `completed``finished``done` | 已完成 | 查看 |
| `rejected` | 审核驳回 | 查看审核原因 |
| `canceled``cancelled``terminated` | 已取消或已终止 | 查看 |
| `failed` | 发送失败 | 查看失败原因 |
| 未识别状态 | 未知状态(附原始值) | 查看 |
### 5.3 第二阶段:流程可靠性优化
前端状态修复完成后,再处理孤立审核任务风险。建议选择以下一种方案:
1. 将审核任务、批量任务、短信明细及可纳入数据库的余额操作放入一致事务边界。
2. 如果余额服务或队列操作无法进入同一事务,则增加失败补偿:
- 将已创建的审核任务标记为 `canceled` 或专用失败状态。
- 写入失败原因。
- 释放已冻结资源。
- 确保运营端默认待审核列表不再展示该任务。
同时应保证状态联动:
- 审核通过:`SmsSendTask` 完成审核,`SmsBatchTask` 转为 `ready``scheduled`,符合条件的短信进入发送流程。
- 审核驳回:审核任务、批量任务和短信明细同步转为拒绝终态,并释放冻结余额。
- 审核取消或创建失败:关联记录全部进入不可继续审核、不可继续发送的明确终态。
- 已完成、已驳回、已取消的审核任务不得再次执行审核决定。
## 6. 建议测试用例
### 6.1 创建与可见性
1. 创建命中人工审核规则的立即发送批量任务。
2. 创建命中人工审核规则的定时发送批量任务。
3. 验证数据库同时存在关联的 `SmsSendTask``SmsBatchTask`
4. 验证客户端批量任务页面能够看到任务并显示“待人工审核”。
5. 验证运营端短信审核页面能够看到对应审核任务。
6. 验证运营端短信任务进度页面能够看到批量任务并显示“待人工审核”。
### 6.2 审核期间行为
1. 验证任务没有进入短信发送队列。
2. 验证发送数量和发送进度保持为零。
3. 验证客户端不能对 `pending_review` 调用定时任务取消接口。
4. 验证客户端和运营端均能查看审核原因。
5. 验证页面不再显示“发送中”。
### 6.3 审核通过
1. 立即发送任务审核通过后进入 `ready` 或后续合法发送状态。
2. 定时任务审核通过后进入 `scheduled`
3. 验证短信只入队一次,不发生重复发送。
4. 验证审核任务、批量任务、短信明细状态一致。
5. 验证余额冻结及最终扣费状态正确。
### 6.4 审核驳回
1. 审核任务转为 `rejected`
2. 批量任务和短信明细同步进入拒绝终态。
3. 客户端和运营端均显示“审核驳回”及原因。
4. 验证冻结余额正确释放。
5. 验证任务不会进入发送队列。
### 6.5 异常与补偿
1. 模拟余额不足。
2. 模拟余额冻结失败。
3. 模拟批量任务创建失败。
4. 模拟短信明细创建失败。
5. 验证失败后不存在仍可由运营人员通过的孤立审核任务。
6. 验证重复提交审核决定具有幂等保护。
## 7. 验收标准
本次最小修复满足以下条件即可验收:
1. 客户端和运营端任务进度页面都将 `pending_review` 显示为“待人工审核”。
2. 审核期间不显示为“发送中”,发送进度不增长。
3. 客户端待审核任务不再出现不可用的“终止”操作。
4. 客户端详情能够显示审核原因和驳回原因。
5. 运营审核页面能够找到并处理对应审核任务。
6. 审核通过和驳回后,两端状态能够正确刷新。
7. 新增或更新前后端自动化测试,覆盖状态映射、操作权限和审核状态联动。
8. 相关测试用例及 `docs/testing-progress.md` 在主任务实施时同步更新。
## 8. 推荐实施顺序
1. 增加共享状态类型和状态映射。
2. 修复客户端批量任务页面。
3. 修复运营端任务进度页面。
4. 补充审核原因和关联任务展示。
5. 增加前端单元测试和后端状态联动测试。
6. 执行真实接口、数据库、页面和队列验证。
7. 单独设计并实施事务或失败补偿机制。
第一阶段应作为独立提交,避免将低风险展示修复与高影响流程事务改造混在同一个提交中。
@@ -2169,3 +2169,12 @@
- 验收必须保持正价,分别执行单企业100/150 TPS和至少两个独立企业合计200 TPS,按非补发首次供应商Submit、回执、上行、计费、主备补发、业务拦截及全队列排空对账;触发拒绝、连接错误、持续积压、数据库异常或账务不一致立即停止。多Gateway P2不在本阶段范围。
- Gateway到主API的回环HTTP连接池空闲时长不得超过API服务端keep-alive生命周期;API默认keep-alive 120秒、headers timeout 125秒,并由发布环境显式设置和校验,防止负载期复用已被Node关闭的连接而把本可受理的Submit误回Result 9。
- 同一Gateway下游会话的connected、heartbeat、submit和deliver状态回调允许并发到达;连接上限按不同`connectionId`计数,同一`connectionId`的首次状态必须幂等upsert,不得因查询/创建竞态误报超过连接数并主动断开客户连接。不同连接超过应用上限时仍返回403并由Gateway断开。
## 2026-08-27 客户端展示、三网报备与人工审核状态优化
- 客户端首页和账户余额页的金额使用继承系统字体的普通大号黑色字;客户端登录页提供“返回官网”。
- 批量任务、发送详情、上行短信默认查询北京时间近 7 天;客户端右上角不展示运营审核任务入口。
- 发送详情的发送状态和回执状态统一显示中文,未识别值显示“状态未知”;应用 CMPP 状态与运营端统一为已连接、已断开、未开通。
- 客户端签名与引流列表不展示使用场景、已提交资料或审核状态,只展示移动、联通、电信三网可用性;全部通过和部分通过均显示“报备通过”,数据来自当前应用路由和真实通道报备任务汇总。
- 人工审核批量任务把`pending_review`独立显示为“待人工审核”,审核期间进度为0;客户端仅允许取消`scheduled`任务,并展示审核原因和驳回原因。运营审核页展示审核任务号与关联批量任务号。
- 运营端签名质量的通道活跃度热力图支持通道名搜索;短信任务进度支持后端任务状态查询。
+15
View File
@@ -4923,3 +4923,18 @@ npm run verify:phase8
| TC-LG-STEP46-47-007 | 客户端批量任务显示 UTC ISO 时间 | 提交时间和定时时间统一转为 Asia/Shanghai `YYYY-MM-DD HH:mm:ss`,不显示原始 `T...Z` 字符串 |
执行记录:Gateway `resultoutbox` 定向测试及全包测试通过;API 45套528项通过;API构建、前端TypeScript与Vite生产构建通过。390×844 Chromium 渲染回归中,无模板直发按钮可用,日期弹层为366×476且操作区Y=431~471,XLSX明确拒绝,批次时间显示为北京时间,控制台无错误。
## TC-UI-REVIEW-20260827 客户端展示、三网报备与人工审核状态
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-UI-REVIEW-001 | 查看客户端登录、首页和账户余额 | 登录页存在“返回官网”;金额为普通大号黑字;客户端右上角无待审核任务按钮 |
| TC-UI-REVIEW-002 | 首次打开批量任务、发送详情和上行短信 | 三个日期区间均为北京时间近7天(含当天) |
| TC-UI-REVIEW-003 | 查看不同状态的短信发送记录和回执 | 用户态状态为中文;未知协议值显示“状态未知” |
| TC-UI-REVIEW-004 | 对比两端同一应用的CMPP状态 | 两端均按已连接、已断开、未开通显示 |
| TC-UI-REVIEW-005 | 查看签名及引流三网状态 | 不显示使用场景、已提交资料、审核状态;三网状态来自真实路由和报备任务,部分通过与全部通过均映射为“报备通过” |
| TC-UI-REVIEW-006 | 在通道签名活跃度热力图输入通道名 | 仅保留匹配通道的维度行;企业、应用、签名搜索仍有效 |
| TC-UI-REVIEW-007 | 在运营端短信任务进度选择任务状态 | 请求携带精确状态且结果仅含该状态;重置恢复全部状态 |
| TC-UI-REVIEW-008 | 创建命中人工审核规则的批量任务 | 两端显示“待人工审核”、进度为0;客户端无终止按钮,详情显示审核原因,运营审核页可见关联批量任务号 |
| TC-UI-REVIEW-009 | 审核通过或驳回批量任务 | 状态按真实后端刷新;驳回时展示原因,不重复入队或发送 |
| TC-UI-REVIEW-010 | 接口对接某个日志子接口失败 | 接口概览仍可用且提示中文,不直接显示`Internal server error` |
+8
View File
@@ -4046,3 +4046,11 @@ git diff --check
- 移动端日期选择器使用视口内固定弹层和内部滚动,操作区置底;批量任务提交/定时时间统一为Asia/Shanghai格式。
- 门禁通过:API 45套528项,Gateway全包(含新增Outbox重试与无messageId上行用例),API正式构建,前端TypeScript检查和Vite生产构建。390×844 Chromium回归的直发、XLSX拦截、定时弹层和批次时间均通过,控制台错误/警告0。
- 本轮没有部署、重启服务、发送短信或处理测试机1974条积压;没有访问或修改预生产/生产。因此当前是本地代码与自动化/渲染回归通过,测试机真实CMPP闭环需在后续部署授权后再验收。
## 2026-08-27 客户端展示、三网报备与人工审核状态优化(本地门禁)
- 完成用户明确的10项页面优化,并按`batch-task-manual-review-status-optimization-20260827.md`第一阶段实现人工审核状态修复;第二阶段孤立审核任务事务/补偿不在本提交范围。
- 客户端签名接口按运营端同一应用路由、通道运营商范围和报备任务计算三网汇总,只输出汇总,不暴露通道或报备任务明细。
- 人工审核状态改用共享前端映射;未知状态不再默认“发送中”。客户端仅`scheduled`可取消,运营审核列表返回并展示关联批量任务号。
- 本地前端TypeScript、API TypeScript正式构建和Vite生产构建通过;短信配置、风控审核、发送链3套214项API测试通过。客户端登录页真实渲染确认“返回官网”存在。
- 测试环境发布、数据库/API/页面/队列验收尚待本轮后续完成;预生产未访问。
+1
View File
@@ -29,6 +29,7 @@ export type RiskReviewTask = {
reviewedBy?: { id: string; username: string; displayName: string } | null;
riskHits?: Array<{ id: string; ruleName: string; reason: string }>;
_count?: { messageRecords: number };
batchTask?: { id: string; taskNo: string } | null;
};
export type RiskRuleItem = {
+2 -1
View File
@@ -247,7 +247,7 @@ export type ClientSmsSignature = {
};
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials'
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials' | 'carrierReportSummary' | 'drainageCarrierReportSummary'
> & {
pendingReport?: boolean;
reportChangedAt?: string;
@@ -333,6 +333,7 @@ export type SmsBatchTask = {
auditStatus?: string | null;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
progressTotal: number;
progressSent?: number;
progressDelivered?: number;
+1
View File
@@ -100,6 +100,7 @@ export function LoginPage({ portal }: LoginPageProps) {
</div>
{error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com"></a> : null}
</div>
</section>
<Modal
+3 -3
View File
@@ -295,7 +295,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
@@ -325,9 +325,9 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
aria-label={`${title}搜索通道、企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业应用或签名'}
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
+2
View File
@@ -143,6 +143,7 @@ export function AdminSmsAuditPage() {
<div className="sms-audit-cell-stack">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<small>{record.application?.name ?? record.applicationId ?? '-'}</small>
<small>{record.batchTask?.taskNo ?? '-'}</small>
</div>
),
},
@@ -241,6 +242,7 @@ export function AdminSmsAuditPage() {
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
<div><span></span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
@@ -17,6 +17,7 @@ export function AdminSmsTaskProgressPage() {
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [status, setStatus] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
@@ -35,6 +36,7 @@ export function AdminSmsTaskProgressPage() {
keyword: keyword || undefined,
enterpriseKeyword: enterprise === 'all' ? undefined : enterprise,
applicationKeyword: application === 'all' ? undefined : application,
status: status === 'all' ? undefined : status,
createdAtFrom: submittedDateRange.start || undefined,
createdAtTo: submittedDateRange.end || undefined,
page: targetPage,
@@ -84,6 +86,7 @@ export function AdminSmsTaskProgressPage() {
setKeyword('');
setEnterprise('all');
setApplication('all');
setStatus('all');
setSubmittedDateRange({});
}
@@ -112,6 +115,18 @@ export function AdminSmsTaskProgressPage() {
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
keyword={keyword}
status={status}
statusOptions={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '等待定时发送', value: 'scheduled' },
{ label: '排队中', value: 'queued' },
{ label: '发送中', value: 'sending' },
{ label: '已完成', value: 'finished' },
{ label: '失败', value: 'failed' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已终止', value: 'canceled' },
]}
submittedDateRange={submittedDateRange}
onApplicationChange={setApplication}
onEnterpriseChange={(value) => {
@@ -119,6 +134,7 @@ export function AdminSmsTaskProgressPage() {
setApplication('all');
}}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
onQuery={() => {
if (page !== 1) setPage(1);
else loadTasks(1);
@@ -7,10 +7,13 @@ type SmsTaskFilterProps = {
enterprise: string;
enterpriseOptions: Array<{ label: string; value: string }>;
keyword: string;
status: string;
statusOptions: Array<{ label: string; value: string }>;
submittedDateRange: DateRangeValue;
onApplicationChange: (value: string) => void;
onEnterpriseChange: (value: string) => void;
onKeywordChange: (value: string) => void;
onStatusChange: (value: string) => void;
onQuery: () => void;
onReset: () => void;
onSubmittedDateRangeChange: (value: DateRangeValue) => void;
@@ -22,10 +25,13 @@ export function SmsTaskFilter({
enterprise,
enterpriseOptions,
keyword,
status,
statusOptions,
submittedDateRange,
onApplicationChange,
onEnterpriseChange,
onKeywordChange,
onStatusChange,
onQuery,
onReset,
onSubmittedDateRangeChange,
@@ -40,6 +46,7 @@ export function SmsTaskFilter({
value={enterprise}
/>
<Select label="选择应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<Select label="任务状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交时间" onChange={onSubmittedDateRangeChange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
@@ -102,7 +102,7 @@ export function SmsTaskTable({
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(record.status) ? 'terminated' : 'sending'}`} style={{ width: `${progress}%` }} />
</div>
</div>
</td>
@@ -111,7 +111,7 @@ export function SmsTaskTable({
<div className="admin-task-actions">
<Button icon={<Eye size={15} />} onClick={() => onOpenDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
disabled={!['sending', 'submitted'].includes(record.rawStatus)}
icon={<StopCircle size={15} />}
onClick={() => onTerminate(record)}
size="sm"
@@ -88,9 +88,19 @@ export function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: ()
<span><strong>{formatNumber(task.submittedSuccess)}</strong></span>
<span><strong>{formatNumber(task.successCount)}</strong></span>
</div>
{task.status === 'pending_review' ? <p className="muted"></p> : null}
</div>
</section>
{(task.reviewReason || task.rejectReason || task.riskTaskId) ? <section className="admin-task-card">
<h3></h3>
<dl className="admin-task-info-list">
{task.riskTaskId ? <div><dt></dt><dd>{task.riskTaskId}</dd></div> : null}
{task.reviewReason ? <div><dt></dt><dd>{task.reviewReason}</dd></div> : null}
{task.rejectReason ? <div><dt></dt><dd>{task.rejectReason}</dd></div> : null}
</dl>
</section> : null}
<section className="admin-task-card">
<h3><BarChart3 size={18} /></h3>
<div className="admin-task-template-block">
+9 -21
View File
@@ -1,20 +1,11 @@
import type { SmsBatchTask, SmsMessageRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import type { CarrierStat, RegionStat, SmsTask, TaskStatus } from './taskTypes';
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
export const statusLabels: Record<TaskStatus, string> = {
sending: '发送中',
completed: '已完成',
terminated: '已终止',
failed: '失败',
};
export const statusLabels = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.label])) as Record<TaskStatus, string>;
export const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
sending: 'info',
completed: 'success',
terminated: 'neutral',
failed: 'danger',
};
export const statusTones = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.tone])) as Record<TaskStatus, 'warning' | 'info' | 'success' | 'neutral' | 'danger'>;
export const sendTypeLabels = {
immediate: '立即发送',
@@ -51,13 +42,6 @@ export function messageStatusLabel(status: string) {
}[status] ?? status;
}
function normalizeTaskStatus(status: string): TaskStatus {
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
if (['failed', 'rejected'].includes(status)) return 'failed';
return 'sending';
}
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
}
@@ -139,11 +123,15 @@ export function mapTask(task: SmsBatchTask): SmsTask {
scheduledAt: task.scheduledAt,
submittedCount,
submittedSuccess: submittedCount,
sentCount: Math.min(task.phoneTotal, processedCount),
sentCount: task.status === 'pending_review' ? 0 : Math.min(task.phoneTotal, processedCount),
successCount,
failedCount,
status: normalizeTaskStatus(task.status),
status: normalizeBatchTaskStatus(task.status),
rawStatus: task.status,
auditStatus: task.auditStatus,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
};
@@ -1,4 +1,6 @@
export type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
import type { BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
export type TaskStatus = BatchTaskDisplayStatus;
export type SendType = 'immediate' | 'scheduled';
export type CarrierStat = {
@@ -33,6 +35,10 @@ export type SmsTask = {
failedCount: number;
status: TaskStatus;
rawStatus: string;
auditStatus?: string | null;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
carriers: CarrierStat[];
regions: RegionStat[];
};
+2 -2
View File
@@ -29,10 +29,10 @@ const statusToneMap: Record<LinkStatus, 'success' | 'warning' | 'danger' | 'info
};
function normalizeStatus(application: ClientSmsApplication): LinkStatus {
if (application.status !== 'active') {
if (application.status !== 'active' || application.interfaceEnabled === false || application.cmppStatus === 'inactive') {
return 'inactive';
}
return application.cmppStatus ?? 'inactive';
return application.cmppStatus === 'connected' ? 'connected' : 'disconnected';
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
+22 -31
View File
@@ -19,9 +19,8 @@ import {
type TableColumn,
} from '@/components/ui';
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
import { batchTaskStatusMeta, canClientCancelBatchTask, normalizeBatchTaskStatus, type BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
type BatchTask = {
id: string;
@@ -37,19 +36,11 @@ type BatchTask = {
failedCount: number;
totalCount: number;
templateContent: string;
status: BatchTaskStatus;
};
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
sending: 'info',
terminated: 'danger',
};
const statusLabelMap: Record<BatchTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
terminated: '已终止',
status: BatchTaskDisplayStatus;
rawStatus: string;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
};
function splitSignature(content: string) {
@@ -72,13 +63,8 @@ function getDeliveredCount(task: BatchTask) {
return task.deliveredCount;
}
function normalizeTaskStatus(status: string): BatchTaskStatus {
if (['completed', 'done'].includes(status)) return 'completed';
if (['cancelled', 'terminated', 'rejected', 'failed'].includes(status)) return 'terminated';
return 'sending';
}
function mapTask(task: SmsBatchTask): BatchTask {
const displayStatus = normalizeBatchTaskStatus(task.status);
return {
id: task.taskNo || task.id,
backendId: task.id,
@@ -88,12 +74,16 @@ function mapTask(task: SmsBatchTask): BatchTask {
wordCount: [...task.content].length,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
sentCount: task.progressSent ?? task.submittedTotal ?? 0,
sentCount: displayStatus === 'pending_review' ? 0 : task.progressSent ?? task.submittedTotal ?? 0,
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
totalCount: task.progressTotal || task.phoneTotal,
templateContent: task.content,
status: normalizeTaskStatus(task.status),
status: displayStatus,
rawStatus: task.status,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
};
}
@@ -106,7 +96,7 @@ export function ClientBatchTasksPage() {
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
@@ -166,7 +156,7 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-task-id">
<strong>{record.id}</strong>
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
<Tag tone={batchTaskStatusMeta[record.status].tone}>{batchTaskStatusMeta[record.status].label}{record.status === 'unknown' ? `${record.rawStatus}` : ''}</Tag>
</div>
),
},
@@ -215,15 +205,14 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-actions">
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
{canClientCancelBatchTask(record.rawStatus) ? <Button
icon={<StopCircle size={14} />}
onClick={() => terminateTask(record.id)}
size="sm"
variant="ghost"
>
</Button>
</Button> : null}
</div>
),
},
@@ -325,7 +314,7 @@ export function ClientBatchTasksPage() {
>
{selectedTask ? (
<div className="task-detail">
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
<DetailSection title="基本信息" extra={<Tag tone={batchTaskStatusMeta[selectedTask.status].tone}>{batchTaskStatusMeta[selectedTask.status].label}{selectedTask.status === 'unknown' ? `${selectedTask.rawStatus}` : ''}</Tag>}>
<DetailInfoGrid
items={[
{ label: '发送批次号', value: selectedTask.id },
@@ -345,6 +334,8 @@ export function ClientBatchTasksPage() {
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '模板内容', value: selectedTask.templateContent, full: true },
...(selectedTask.reviewReason ? [{ label: '审核原因', value: selectedTask.reviewReason, full: true }] : []),
...(selectedTask.rejectReason ? [{ label: '驳回原因', value: selectedTask.rejectReason, full: true }] : []),
]}
/>
</DetailSection>
@@ -354,7 +345,7 @@ export function ClientBatchTasksPage() {
label="发送进度"
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
percent={getProgress(selectedTask)}
status={selectedTask.status}
status={selectedTask.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(selectedTask.status) ? 'terminated' : 'sending'}
stats={[
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
+3 -3
View File
@@ -33,18 +33,18 @@ export function ClientBillingPage() {
}, [page]);
return (
<section className="page-stack">
<section className="page-stack client-billing-page">
<div className="page-heading">
<div><p className="eyebrow"></p><h1></h1></div>
</div>
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
<div><span></span><strong className="client-amount-value"><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
</div>
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
<div><span></span><strong className="client-amount-value"><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
</div>
</div>
<div className="surface section-stack">
+4 -4
View File
@@ -101,7 +101,7 @@ export function ClientHome() {
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<small> <MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></small>
</div>
<div className="surface metric-card">
@@ -111,12 +111,12 @@ export function ClientHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<small>退</small>
</div>
</div>
@@ -175,7 +175,7 @@ export function ClientHome() {
</div>
<div>
<span></span>
<strong>{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
<strong className="client-amount-value client-amount-value--compact">{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
</div>
</div>
<div>
+18 -2
View File
@@ -45,10 +45,22 @@ export function ClientHttpApiPage() {
setLoading(true);
setError('');
try {
const [nextConfig, nextCredentials, nextWebhooks, nextRequests, nextDeliveries] = await Promise.all([
setConfig(null);
setCredentials([]);
setWebhooks([]);
setRequests([]);
setDeliveries([]);
const results = await Promise.allSettled([
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
]);
const [configResult, credentialsResult, webhooksResult, requestsResult, deliveriesResult] = results;
if (configResult.status === 'rejected') throw configResult.reason;
const nextConfig = configResult.value;
const nextCredentials = credentialsResult.status === 'fulfilled' ? credentialsResult.value : [];
const nextWebhooks = webhooksResult.status === 'fulfilled' ? webhooksResult.value : [];
const nextRequests = requestsResult.status === 'fulfilled' ? requestsResult.value : [];
const nextDeliveries = deliveriesResult.status === 'fulfilled' ? deliveriesResult.value : [];
setConfig(nextConfig);
setCredentials(nextCredentials);
setWebhooks(nextWebhooks);
@@ -56,7 +68,11 @@ export function ClientHttpApiPage() {
setDeliveries(nextDeliveries);
setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
} catch (failure) { setError(failure instanceof Error ? failure.message : 'HTTP接口资料加载失败'); }
if (results.some((result) => result.status === 'rejected')) setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
} catch (failure) {
const message = failure instanceof Error ? failure.message : '';
setError(message.toLowerCase().includes('internal server error') ? '接口资料暂时加载失败,请稍后重试。' : message || 'HTTP接口资料加载失败');
}
finally { setLoading(false); }
}
+21 -3
View File
@@ -11,6 +11,7 @@ import {
Tag,
type DateRangeValue,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
const statusLabelMap: Record<string, string> = {
delivered: '成功',
@@ -21,6 +22,14 @@ const statusLabelMap: Record<string, string> = {
failed: '失败',
rejected: '失败',
timeout: '超时',
pending_review: '待审核',
scheduled: '等待定时发送',
submit_queued: '等待提交',
submit_failed: '提交失败',
processing: '处理中',
sending: '发送中',
canceled: '已取消',
cancelled: '已取消',
};
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
@@ -41,16 +50,25 @@ function getDate(value?: string | null) {
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus ?? '-',
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
time: latest?.deliveredAt ?? record.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
if (!status) return '-';
const normalized = status.trim().toUpperCase();
return {
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
}
export function ClientSendDetailPage() {
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [applicationId, setApplicationId] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [page, setPage] = useState(1);
@@ -194,7 +212,7 @@ export function ClientSendDetailPage() {
<td><strong>{record.phoneNumber}</strong></td>
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
<td><span className="send-detail-region">{region}</span></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
<td>
{receipt.time ? (
+13 -32
View File
@@ -19,15 +19,13 @@ const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
pageSize: 10,
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success', pending: 'info', rejected: 'danger', draft: 'warning',
};
const statusLabel: Record<string, string> = {
approved: '审核通过', pending: '资料审核中', rejected: '需修改', draft: '待提交',
};
type ClientDrainageInfo = ClientSmsSignatureView['drainageInfo']['links'][number];
type Carrier = 'mobile' | 'unicom' | 'telecom';
function CarrierAvailability({ summary }: { summary?: { status: string; approved: number; total: number } }) {
const available = Boolean(summary && (summary.approved > 0 || ['approved', 'partial_success'].includes(summary.status)));
return <Tag tone={available ? 'success' : 'neutral'}>{available ? '报备通过' : '暂不可用'}</Tag>;
}
function reportFileRef(value: unknown): FileRef | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -95,7 +93,6 @@ function SignatureModal({
}) {
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
const [name, setName] = useState(signature?.name ?? '');
const [purpose, setPurpose] = useState(signature?.purpose ?? '');
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [values, setValues] = useState<Record<string, unknown>>(signature?.reportValues ?? {});
const [uploadingCode, setUploadingCode] = useState('');
@@ -132,7 +129,7 @@ function SignatureModal({
setSaving(true);
setError('');
try {
const body = { applicationId: applicationId || undefined, name: name.trim(), purpose: purpose.trim(), drainageInfo: { signatureReportValues: values } };
const body = { applicationId: applicationId || undefined, name: name.trim(), drainageInfo: { signatureReportValues: values } };
if (signature) await clientApi.updateSignature(signature.id, body);
else {
const created = await clientApi.createSignature(body);
@@ -181,7 +178,6 @@ function SignatureModal({
required
value={name}
/>
<Input label="使用场景" onChange={(event) => setPurpose(event.target.value)} placeholder="例如:验证码、订单通知" value={purpose ?? ''} />
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
@@ -263,7 +259,6 @@ export function ClientSignaturesPage() {
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
const [keyword, setKeyword] = useState('');
const [applicationFilter, setApplicationFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [signatureModal, setSignatureModal] = useState<ClientSmsSignatureView | 'new'>();
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
@@ -282,7 +277,6 @@ export function ClientSignaturesPage() {
clientApi.getSignatureWorkspace({
keyword: keyword.trim() || undefined,
applicationId: applicationFilter || undefined,
status: statusFilter || undefined,
page: targetPage,
pageSize,
}),
@@ -299,7 +293,7 @@ export function ClientSignaturesPage() {
useEffect(() => {
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [applicationFilter, keyword, page, refreshVersion, statusFilter]);
}, [applicationFilter, keyword, page, refreshVersion]);
const filteredItems = workspace.items;
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
@@ -328,7 +322,6 @@ export function ClientSignaturesPage() {
const resetFilters = () => {
setKeyword('');
setApplicationFilter('');
setStatusFilter('');
setPage(1);
setRefreshVersion((version) => version + 1);
};
@@ -341,23 +334,15 @@ export function ClientSignaturesPage() {
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal('new')}></Button>
</header>
<section className="client-signature-overview" aria-label="签名审核概览">
<div><span></span><strong>{workspace.summary.total}</strong></div>
<div><span></span><strong>{workspace.summary.pending}</strong></div>
<div><span></span><strong>{workspace.summary.approved}</strong></div>
<div><span></span><strong>{workspace.summary.rejected}</strong></div>
</section>
<div className="client-signature-toolbar">
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名、用途或应用" prefix={<Search size={17} />} value={keyword} />
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名或应用" prefix={<Search size={17} />} value={keyword} />
<Select onChange={(event) => { setApplicationFilter(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
<Select onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<section className="client-signature-list-shell">
<div className="client-signature-list-head"><span /><span></span><span></span><span>使</span><span></span><span></span><span></span><span></span></div>
<div className="client-signature-list-head"><span /><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="client-signature-list-empty">...</p> : null}
{!loading && !visibleItems.length ? <p className="client-signature-list-empty"></p> : null}
{visibleItems.map((signature) => {
@@ -369,24 +354,20 @@ export function ClientSignaturesPage() {
<button aria-label={expanded ? '收起引流信息' : '展开引流信息'} className="client-signature-expand" onClick={() => toggleExpanded(signature.id)} type="button">{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button>
<strong>{signature.name}</strong>
<span>{signature.application?.name ?? '未绑定'}</span>
<span>{signature.purpose || '-'}</span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
<span>{signature.submittedMaterialCount} </span>
{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}
<span>{formatDate(signature.updatedAt)}</span>
<div className="table-actions">
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={signature.id} targetType="signature" />
</div>
</div>
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong></strong>{signature.rejectReason}</div> : null}
{expanded ? <div className="client-drainage-panel">
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> </h3><p>使</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost"></Button></div>
{links.length ? <div className="client-drainage-table">
<div className="client-drainage-table__head"><span></span><span>访</span><span></span><span></span><span></span></div>
<div className="client-drainage-table__head"><span></span><span>访</span><span></span><span></span><span></span><span></span><span></span></div>
{links.map((item) => <div className="client-drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><span>{formatDate(item.updatedAt)}</span>
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.drainageCarrierReportSummary?.[item.id]?.[carrier]} />)}<span>{formatDate(item.updatedAt)}</span>
<div className="table-actions"><Button disabled={item.auditStatus === 'pending'} icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost"></Button><Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'drainage', id: item.id, name: item.siteName })} size="sm" variant="danger"></Button></div>
{item.rejectReason ? <p className="client-drainage-reason"><strong></strong>{item.rejectReason}</p> : null}
</div>)}
</div> : <p className="client-signature-empty-hint"></p>}
</div> : null}
+2 -1
View File
@@ -14,6 +14,7 @@ import {
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
@@ -28,7 +29,7 @@ export function ClientUplinkMessagesPage() {
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
+2 -2
View File
@@ -455,7 +455,7 @@ export function AppShell({
) : null}
</div>
) : null}
<div className="notice-menu-wrap">
{auditNotifications.length ? <div className="notice-menu-wrap">
<button
aria-expanded={noticeOpen}
aria-haspopup="menu"
@@ -481,7 +481,7 @@ export function AppShell({
)) : <p></p>}
</div>
) : null}
</div>
</div> : null}
<div className="user-menu-wrap">
<button
aria-expanded={userMenuOpen}
+1 -1
View File
@@ -288,7 +288,7 @@
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(240px, 1.2fr) auto;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.admin-task-filter__actions {
+32 -6
View File
@@ -580,6 +580,22 @@
justify-content: flex-end;
}
.client-amount-value,
.client-amount-value .money-text {
color: #111827 !important;
font-family: inherit !important;
font-size: 30px;
font-style: normal;
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: 0;
}
.client-amount-value--compact,
.client-amount-value--compact .money-text {
font-size: 22px;
}
.send-submit-hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
@@ -6614,6 +6630,16 @@
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);
}
@@ -8724,7 +8750,7 @@
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: 24px minmax(120px, 1.1fr) minmax(110px, 1fr) minmax(120px, 1fr) 104px 76px 142px minmax(144px, auto);
grid-template-columns: 24px minmax(120px, 1.1fr) minmax(110px, 1fr) 92px 92px 92px 142px minmax(144px, auto);
min-width: 1050px;
}
@@ -8834,8 +8860,8 @@
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: minmax(130px, 1fr) minmax(240px, 1.8fr) 104px 142px minmax(144px, auto);
min-width: 820px;
grid-template-columns: minmax(120px, 1fr) minmax(200px, 1.7fr) 92px 92px 92px 142px minmax(144px, auto);
min-width: 980px;
}
.client-drainage-table__head {
@@ -9374,9 +9400,9 @@
}
.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(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 {
+29
View File
@@ -0,0 +1,29 @@
export type BatchTaskDisplayStatus = 'pending_review' | 'scheduled' | 'waiting' | 'sending' | 'completed' | 'rejected' | 'canceled' | 'failed' | 'unknown';
export const batchTaskStatusMeta: Record<BatchTaskDisplayStatus, { label: string; tone: 'warning' | 'info' | 'success' | 'danger' | 'neutral' }> = {
pending_review: { label: '待人工审核', tone: 'warning' },
scheduled: { label: '等待定时发送', tone: 'warning' },
waiting: { label: '等待发送', tone: 'info' },
sending: { label: '发送中', tone: 'info' },
completed: { label: '已完成', tone: 'success' },
rejected: { label: '审核驳回', tone: 'danger' },
canceled: { label: '已取消', tone: 'neutral' },
failed: { label: '发送失败', tone: 'danger' },
unknown: { label: '未知状态', tone: 'neutral' },
};
export function normalizeBatchTaskStatus(status: string): BatchTaskDisplayStatus {
if (status === 'pending_review') return 'pending_review';
if (status === 'scheduled') return 'scheduled';
if (['ready', 'queued', 'created'].includes(status)) return 'waiting';
if (['sending', 'submitted'].includes(status)) return 'sending';
if (['completed', 'finished', 'done'].includes(status)) return 'completed';
if (status === 'rejected') return 'rejected';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'canceled';
if (status === 'failed') return 'failed';
return 'unknown';
}
export function canClientCancelBatchTask(status: string) {
return status === 'scheduled';
}
+12
View File
@@ -20,3 +20,15 @@ export function formatDateTime(value?: string | Date | null) {
);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
const beijingDateFormatter = new Intl.DateTimeFormat('en-CA', {
day: '2-digit', month: '2-digit', timeZone: 'Asia/Shanghai', year: 'numeric',
});
export function recentBeijingDateRange(days = 7) {
const now = Date.now();
return {
start: beijingDateFormatter.format(new Date(now - (Math.max(1, days) - 1) * 86_400_000)),
end: beijingDateFormatter.format(new Date(now)),
};
}