diff --git a/api/src/tenants/tenants.controller.ts b/api/src/tenants/tenants.controller.ts index 88477b6..4ba7c97 100644 --- a/api/src/tenants/tenants.controller.ts +++ b/api/src/tenants/tenants.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { CreateTenantDto, TenantsService } from './tenants.service'; +import { CreateTenantDto, TenantsService, UpdateTenantDto } from './tenants.service'; @ApiTags('tenants') @Controller('admin/tenants') @@ -21,4 +21,19 @@ export class TenantsController { create(@Body() body: CreateTenantDto) { return this.tenants.create(body); } + + @Put(':id') + update(@Param('id') id: string, @Body() body: UpdateTenantDto) { + return this.tenants.update(id, body); + } + + @Post(':id/status') + changeStatus(@Param('id') id: string, @Body() body: { status: string }) { + return this.tenants.changeStatus(id, body.status); + } + + @Delete(':id') + delete(@Param('id') id: string) { + return this.tenants.delete(id); + } } diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index bd4ff0c..eed3bc6 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -7,6 +7,12 @@ export interface CreateTenantDto { status?: string; } +export interface UpdateTenantDto { + name?: string; + code?: string; + status?: string; +} + @Injectable() export class TenantsService { constructor(private readonly prisma: PrismaService) {} @@ -31,4 +37,26 @@ export class TenantsService { }, }); } + + update(id: string, data: UpdateTenantDto) { + return this.prisma.tenant.update({ + where: { id }, + data: { + name: data.name, + code: data.code, + status: data.status, + }, + }); + } + + changeStatus(id: string, status: string) { + return this.prisma.tenant.update({ + where: { id }, + data: { status }, + }); + } + + delete(id: string) { + return this.changeStatus(id, 'deleted'); + } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 2b8da58..2ab868a 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1192,6 +1192,17 @@ - 锁定状态必须写入后端持久化字段,后续登录直接拒绝。 - 登录成功后清空失败次数和锁定状态。 +### 2026-07-02 纯 mock 菜单真实化补充 + +除文档或菜单明确标注为待开发的彩信能力外,第一版所有可进入菜单不得以 mock/static/localStorage 作为系统功能完成标准: + +1. 客户端充值套餐、账单流水、批量任务、短信发送、短信签名、短信模板必须调用真实 API;签名/模板新增后进入真实审核状态,发送任务调用真实发送链路。 +2. 运营端数据统计、账务账户、发送监控、短信审核、短信记录、安全控制、手机号段库、报备字段库、通道组、通道报备字段、报备任务和报备记录必须调用真实 API。 +3. 运营端企业管理使用真实租户、账户、应用、签名、模板接口;企业新增、编辑、启用/禁用、删除必须写真实租户表,删除采用软删除或归档,不允许纯前端删除。 +4. 企业签名和企业模板运营端列表只展示真实短信配置数据;彩信签名、彩信模板、彩信通道、彩信记录、彩信任务进度等仍归入待开发,不得用静态样例作为第一版短信验收结果。 +5. 后端接口暂缺编辑/删除能力时,前端不得用本地状态模拟成功;应只开放真实能力,缺失能力记录为待补接口。 +6. API 不可用、数据库不可用或依赖服务不可用时,页面展示错误态或空态;测试记录标记阻塞或失败,不能用静态兜底数据假装通过。 + 第一步请先不要大规模写业务代码,先输出并创建阶段 0 Spike 的最小工程计划,包括: 1. 目录结构建议。 2. NestJS 与 Go Gateway 的队列消息格式。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index bd3cb4f..b5c62ed 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -2499,3 +2499,15 @@ npm run verify:phase8 | TC-USER-ADMIN-004 | 运营端编辑用户、启用/禁用、删除、修改密码。 | 编辑和改密调用真实 API;启停/删除有确认弹窗;删除后列表不展示且不可登录;均写系统日志。 | | TC-USER-CLIENT-001 | 企业管理员在客户端用户管理新增同企业用户。 | 创建成功;用户自动归属当前企业;不可创建平台管理员;写系统日志。 | | TC-USER-CLIENT-002 | 客户端编辑、启用/禁用、删除、修改密码。 | 调用真实 `/api/client/users` API;仅影响当前企业用户;启停/删除有确认弹窗。 | + +### 17.10 非彩信菜单真实后端清理 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端充值套餐、账单流水、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态套餐、任务、模板、签名或最近发送记录。 | +| TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 | +| TC-MOCK-CLEAN-003 | 运营端创建企业、编辑企业、禁用/启用企业、删除企业,再刷新页面和重新登录客户端。 | Tenant 状态持久化;列表刷新后状态不丢;禁用/删除企业阻断客户端业务访问;动作写系统日志。 | +| TC-MOCK-CLEAN-004 | 客户端提交签名材料文件、创建模板并提交审核,运营端查看企业签名和企业模板列表。 | 文件元数据和材料关联写入后端;签名/模板进入真实审核状态;运营端列表可查到同一条记录。 | +| TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 | +| TC-MOCK-CLEAN-006 | 运营端短信记录按手机号、状态、日期和内容查询,打开详情。 | 数据来自 `sms_message_records`;详情展示真实 messageId、状态、失败原因;无数据时为空态。 | +| TC-MOCK-CLEAN-007 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 | diff --git a/docs/testing-plan.md b/docs/testing-plan.md index 7af019a..d4add65 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -85,3 +85,9 @@ Windows 本项目推荐使用根脚本 `npm run test:gateway`,脚本会临时 - 发送 Worker 的 Redis 限速和 BullMQ 投递可在 unit/light integration 中使用 mock;真实 Redis 链路仍需由 `spike:bullmq`、API smoke 或端到端验证覆盖。 - 前端暂未新增测试框架;`npm run build` 只作为构建 smoke,不代表页面业务通过。新增页面能力必须调用真实 API;前端本地状态、localStorage、静态数组、兜底数据不能作为系统功能验收通过依据。 - Gateway 不连接真实运营商 SMSC;使用 gocmpp 适配测试和内部模拟器测试。 + +## 5. 纯 mock 菜单回归要求 + +- 每次新增或修改菜单页后,必须用源码搜索确认非彩信/非待开发页面不存在 `clientService`、`adminService`、`initial*` 静态业务数组、业务 localStorage 兜底或 API 失败后静默回退。 +- 对于后端暂未提供的业务动作,前端只允许展示不可用、待补接口或空态;不得用 `useState` 模拟创建、删除、审核、充值、发送、报备成功。 +- 彩信相关页面当前可保留待开发占位,但测试报告必须单独标注,不得混入短信第一版已完成范围。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 4ca5437..759aae0 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -354,3 +354,39 @@ npm run build - 前端 build:通过,仍有既有大 chunk warning。 - `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890`。 - 真实数据库迁移、浏览器端登录 smoke 需要在生产验证环境执行 `prisma migrate deploy` 后补充记录。 + +## 2026-07-02 非彩信纯 mock 菜单真实化 + +### 本轮修复范围 + +- 客户端:充值套餐、账单流水、批量任务、短信发送、短信签名、短信模板改为调用真实 API;签名材料使用真实文件元数据和材料关联接口;发送任务调用真实批量任务接口。 +- 运营端:数据统计、账务账户、发送监控、敏感词、全局黑名单、企业黑名单、手机号段库、报备字段库、通道组、通道报备字段、报备任务、报备记录、短信审核、短信记录改为真实 API。 +- 企业管理:客户列表、客户表单、客户详情由 `adminEnterpriseMock`/localStorage 改为真实租户、账户、应用、签名、模板接口;后端补充租户编辑、状态变更和删除接口。 +- 通道管理:删除静态通道兜底,API 失败展示错误态。 +- 企业认证审核:删除静态认证兜底,API 失败展示错误态。 +- 企业签名/企业模板运营端列表只展示真实短信配置数据;彩信相关菜单继续作为待开发边界,不计入第一版短信验收。 + +### 已执行命令 + +```bash +npm --prefix api test +npm --prefix api run build +npm run build +npm run verify:phase8 +``` + +### 当前结果 + +- API Jest:10 个 test suite 通过,49 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有大 chunk warning。 +- `npm run verify:phase8` 通过: + - Gateway 队列契约 4 个示例通过。 + - Go Gateway 测试通过。 + - BullMQ 15000 条消息、并发 500、端到端 TPS 681.47,满足 500 TPS。 + - Prisma generate、API build、前端 build 均通过。 +- 源码搜索剩余静态业务数据集中在彩信待开发页面、彩信审核页面、企业应用彩信 tab,以及 `src/api/session.ts` 的登录 session 持久化;非彩信主菜单的 `clientService`/`adminService` 业务路径已清理。 + +### 待复测 + +- 浏览器 smoke 和真实文件上传 smoke 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。 diff --git a/index.html b/index.html index 6c1ad74..d54d08d 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + CMPP 短信平台 diff --git a/logo/fav.ico b/logo/fav.ico new file mode 100644 index 0000000..d3475ba Binary files /dev/null and b/logo/fav.ico differ diff --git a/logo/logo1.png b/logo/logo1.png new file mode 100644 index 0000000..33fe077 Binary files /dev/null and b/logo/logo1.png differ diff --git a/logo/logo2.png b/logo/logo2.png new file mode 100644 index 0000000..eb2dd55 Binary files /dev/null and b/logo/logo2.png differ diff --git a/public/logo/fav.ico b/public/logo/fav.ico new file mode 100644 index 0000000..d3475ba Binary files /dev/null and b/public/logo/fav.ico differ diff --git a/public/logo/logo1.png b/public/logo/logo1.png new file mode 100644 index 0000000..33fe077 Binary files /dev/null and b/public/logo/logo1.png differ diff --git a/public/logo/logo2.png b/public/logo/logo2.png new file mode 100644 index 0000000..eb2dd55 Binary files /dev/null and b/public/logo/logo2.png differ diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index b569fbb..79b3e7c 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -166,6 +166,170 @@ export type AccountTransaction = { createdAt: string; }; +export type BillingPlan = { + id: string; + name: string; + amountCents: number; + smsUnits: number; + unitPriceCents?: number | null; + status: string; + description?: string | null; +}; + +export type ClientSmsApplication = { + id: string; + tenantId: string; + name: string; + scene?: string | null; + status: string; +}; + +export type ClientSmsSignature = { + id: string; + tenantId: string; + applicationId?: string | null; + name: string; + purpose?: string | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + materials?: Array>; +}; + +export type ClientSmsTemplate = { + id: string; + tenantId: string; + applicationId: string; + signatureId?: string | null; + name: string; + content: string; + category?: string | null; + auditStatus: string; + rejectReason?: string | null; + createdAt: string; + updatedAt: string; + variables?: Array<{ name: string; example?: string | null; required?: boolean }>; + application?: { id: string; name: string }; + signature?: { id: string; name: string }; +}; + +export type SmsBatchTask = { + id: string; + tenantId: string; + taskNo: string; + applicationId?: string | null; + templateId?: string | null; + content: string; + category?: string | null; + phoneTotal: number; + status: string; + auditStatus?: string | null; + reviewReason?: string | null; + rejectReason?: string | null; + progressTotal: number; + progressSent: number; + progressDelivered: number; + progressFailed: number; + scheduledAt?: string | null; + createdAt: string; + application?: { id: string; name: string }; +}; + +export type SmsMessageRecord = { + id: string; + tenantId: string; + batchTaskId?: string | null; + applicationId?: string | null; + channelId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + amountCents: number; + status: string; + errorMessage?: string | null; + queuedAt: string; + application?: { id: string; name: string }; +}; + +export type DictionaryItem = Record & { + id: string; + status?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type ChannelGroup = DictionaryItem & { + code: string; + name: string; + description?: string | null; + items?: Array>; +}; + +export type ChannelReportField = DictionaryItem & { + channelId: string; + code: string; + name: string; + fieldType: string; + required: boolean; + description?: string | null; + sortOrder?: number; +}; + +export type ReportTask = DictionaryItem & { + tenantId: string; + signatureId: string; + channelId: string; + status: string; + signature?: { id: string; name: string }; + channel?: { id: string; name: string; code: string }; +}; + +export type ReportRecord = DictionaryItem & { + taskId: string; + channelId: string; + action: string; + statusBefore?: string | null; + statusAfter?: string | null; + reason?: string | null; +}; + +export type FileObject = { + id: string; + tenantId?: string | null; + bucket: string; + objectKey: string; + fileName: string; + contentType: string; + sizeBytes: string | number; + purpose: string; + createdAt: string; +}; + +export type RiskReviewTask = { + id: string; + tenantId: string; + applicationId?: string | null; + templateId?: string | null; + taskNo: string; + content: string; + category?: string | null; + phoneTotal: number; + uniquePhoneTotal: number; + duplicateRatio: number; + illegalRatio: number; + blacklistHitRatio: number; + variableIssues?: unknown; + status: string; + riskDecision: string; + reviewReason?: string | null; + rejectReason?: string | null; + createdAt: string; + reviewedAt?: string | null; + riskHits?: Array<{ id: string; ruleName: string; reason: string }>; +}; + export type TenantAccount = { id: string; tenantId: string; @@ -268,6 +432,14 @@ export const adminApi = { login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), listTenants: () => request('/admin/tenants'), + getTenant: (id: string) => request(`/admin/tenants/${id}`), + createTenant: (body: { name: string; code: string; status?: string }) => + request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), + updateTenant: (id: string, body: { name?: string; code?: string; status?: string }) => + request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeTenantStatus: (id: string, status: string) => + request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), + deleteTenant: (id: string) => request(`/admin/tenants/${id}`, { method: 'DELETE' }), listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request(withQuery('/admin/users', query)), createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), @@ -286,6 +458,8 @@ export const adminApi = { request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), + createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) => + request('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { method: 'POST', @@ -301,6 +475,8 @@ export const adminApi = { getApplicationCmppParams: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), + createChannel: (body: Partial & { passwordCipher?: string }) => + request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { method: 'POST', body: JSON.stringify(body), @@ -326,6 +502,10 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ reason }), }), + listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) => + request(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => + request(withQuery('/admin/enterprise-templates', query)), listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); @@ -342,6 +522,49 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ reason }), }), + listChannelGroups: () => request('/admin/channel-groups'), + createChannelGroup: (body: { code: string; name: string; description?: string; status?: string }) => + request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), + addChannelGroupItem: (body: Record) => + request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), + listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), + createChannelReportField: (body: Record) => + request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), + listReportTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/report-tasks', query)), + createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) => + request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), + createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => + request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), + importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => + request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), + listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), + listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => + request(withQuery('/admin/send/messages', query)), + listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), + listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), + listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), + approveRiskReviewTask: (id: string, reason?: string) => + request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), + rejectRiskReviewTask: (id: string, reason?: string) => + request(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/sensitive-words', query)), + createSensitiveWord: (body: { word: string; level?: string; status?: string }) => + request('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }), + deleteSensitiveWord: (id: string) => request(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }), + listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/global', query)), + createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => + request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), + deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), + listEnterpriseBlacklist: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), + createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => + request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), + deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), + listPhoneSegments: () => request('/admin/dictionaries/phone-segments'), + createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => + request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), + listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), + createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) => + request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), }; export const clientApi = { @@ -368,4 +591,38 @@ export const clientApi = { request('/client/billing/transactions', { tenantId }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), + listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/billing/plans', { tenantId }), + createOrder: (body: { planId?: string; amountCents?: number; smsUnits?: number; payMethod?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }), + listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/applications', { tenantId }), + listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signatures', { tenantId }), + createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/templates', query), { tenantId }), + createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/send/batch-tasks', query), { tenantId }), + cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), + createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), + createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), }; diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx index 320f23c..596da5c 100644 --- a/src/apps/LoginPage.tsx +++ b/src/apps/LoginPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { ShieldCheck } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi'; import { writeSession, type Portal } from '@/api/session'; @@ -54,10 +53,10 @@ export function LoginPage({ portal }: LoginPageProps) {
- + {isAdmin
-

{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}

-

{isAdmin ? '平台管理员登录' : '企业管理员登录'}

+

短信平台

+

{isAdmin ? '运营端登录' : '客户端登录'}

diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 6f16ded..11f0938 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,49 +1,42 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BarChart3 } from 'lucide-react'; +import { adminApi, type DashboardResponse } from '@/api/adminApi'; import { Breadcrumb, Button, Chart, Tag } from '@/components/ui'; -import { auditTrend, channelShare, customerGrowth, hourlySendTrend } from '@/mock/chartData'; -import { adminService } from '@/mock'; -import { createBarOption, createLineOption, createPieOption } from '@/theme/chartOptions'; +import { createBarOption, createPieOption } from '@/theme/chartOptions'; export function AdminAnalyticsPage() { - const overview = adminService.getOverview(); - const customers = adminService.getCustomers(); + const [dashboard, setDashboard] = useState(null); + const [tenantStats, setTenantStats] = useState>([]); + const [channelStats, setChannelStats] = useState>([]); + const [error, setError] = useState(''); - const sendTrendOption = useMemo( - () => createLineOption({ - labels: hourlySendTrend.map((item) => item.time), - series: [ - { name: '提交量', data: hourlySendTrend.map((item) => item.sent) }, - { name: '成功量', data: hourlySendTrend.map((item) => item.success) }, - ], - }), - [], - ); + function loadData() { + Promise.all([ + adminApi.getDashboard(), + adminApi.listStatistics({ groupBy: 'tenantId' }), + adminApi.listStatistics({ groupBy: 'channelId' }), + ]) + .then(([dashboardData, tenantData, channelData]) => { + setDashboard(dashboardData); + setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>); + setChannelStats((Array.isArray(channelData) ? channelData : []) as Array<{ channelId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>); + setError(''); + }) + .catch((failure: Error) => setError(failure.message || '统计数据加载失败')); + } - const auditTrendOption = useMemo( - () => createBarOption({ - labels: auditTrend.map((item) => item.day), - series: [ - { name: '通过', data: auditTrend.map((item) => item.approved) }, - { name: '驳回', data: auditTrend.map((item) => item.rejected) }, - { name: '待审', data: auditTrend.map((item) => item.pending) }, - ], - }), - [], - ); + useEffect(() => { + loadData(); + }, []); - const customerGrowthOption = useMemo( - () => createLineOption({ - labels: customerGrowth.map((item) => item.month), - series: [ - { name: '活跃客户', data: customerGrowth.map((item) => item.active) }, - { name: '新增客户', data: customerGrowth.map((item) => item.new) }, - ], - }), - [], - ); + const tenantOption = useMemo(() => createBarOption({ + labels: tenantStats.map((item) => item.tenantId ?? '未绑定企业'), + series: [{ name: '发送量', data: tenantStats.map((item) => item._count._all) }], + }), [tenantStats]); - const channelShareOption = useMemo(() => createPieOption({ data: channelShare }), []); + const channelOption = useMemo(() => createPieOption({ + data: channelStats.map((item) => ({ name: item.channelId ?? '未分配通道', value: item._count._all })), + }), [channelStats]); return (
@@ -51,24 +44,25 @@ export function AdminAnalyticsPage() {
- +
+ {error ?

{error}

: null}
- 提交量 - {overview.todaySubmissions} - 今日审核提交 + 今日发送量 + {dashboard?.today.sent.toLocaleString('zh-CN') ?? 0} + 真实消息记录
- 活跃客户 - {customers.filter((item) => item.status === 'active').length} - 当前平台客户 + 成功率 + {dashboard?.today.successRate ?? 0}% + 今日已回执
- 通道健康度 - {overview.channelHealth}% - 近 24 小时 + 待审核 + {dashboard?.pendingAuditCount ?? 0} + 企业/签名/模板/风控
@@ -76,45 +70,22 @@ export function AdminAnalyticsPage() {
-

发送趋势

-

按 3 小时聚合短信提交与成功量。

+

企业发送排行

+

按真实短信消息记录聚合。

- 今日 + 企业
- +

通道占比

-

按提交量估算。

+

按通道消息记录聚合。

通道
- -
- - -
-
-
-
-

审核趋势

-

近 7 天审核处理情况。

-
- 审核 -
- -
-
-
-
-

客户增长

-

平台客户增长与活跃趋势。

-
- 客户 -
- +
diff --git a/src/apps/admin/AdminBillingPage.tsx b/src/apps/admin/AdminBillingPage.tsx index 2d4367f..c7ccd4f 100644 --- a/src/apps/admin/AdminBillingPage.tsx +++ b/src/apps/admin/AdminBillingPage.tsx @@ -1,15 +1,13 @@ +import { useEffect, useState } from 'react'; import { Breadcrumb, Table, Tag, type TableColumn } from '@/components/ui'; -import { adminService, type Customer } from '@/mock'; +import { adminApi, type TenantAccount } from '@/api/adminApi'; -const columns: Array> = [ - { key: 'id', title: '客户编号', render: (record) => record.id }, - { key: 'name', title: '客户名称', render: (record) => record.name }, - { key: 'balance', title: '短信余额', render: (record) => `${record.balance.toLocaleString('zh-CN')} 条` }, - { - key: 'amount', - title: '预估账户价值', - render: (record) => `¥${Math.round(record.balance * 0.06).toLocaleString('zh-CN')}`, - }, +const columns: Array> = [ + { key: 'id', title: '账户编号', render: (record) => record.id }, + { key: 'name', title: '客户名称', render: (record) => record.tenant?.name ?? record.tenantId }, + { key: 'balance', title: '现金余额', render: (record) => `¥${(record.balanceCents / 100).toLocaleString('zh-CN')}` }, + { key: 'smsUnits', title: '短信余量', render: (record) => `${record.smsUnits.toLocaleString('zh-CN')} 条` }, + { key: 'creditCents', title: '授信额度', render: (record) => `¥${(record.creditCents / 100).toLocaleString('zh-CN')}` }, { key: 'status', title: '账户状态', @@ -22,6 +20,18 @@ const columns: Array> = [ ]; export function AdminBillingPage() { + const [accounts, setAccounts] = useState([]); + const [error, setError] = useState(''); + + useEffect(() => { + adminApi.listAccounts() + .then((items) => { + setAccounts(items); + setError(''); + }) + .catch((failure: Error) => setError(failure.message || '账务账户加载失败')); + }, []); + return (
@@ -29,8 +39,9 @@ export function AdminBillingPage() {
+ {error ?

{error}

: null}
- +
); diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index 0f197fd..7bbf89c 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -1,104 +1,40 @@ -import { useMemo, useState } from 'react'; -import { Edit3, Layers3, Plus, Search, Trash2, UsersRound } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Breadcrumb, Button, Input, Pagination } from '@/components/ui'; - -type ChannelGroup = { - id: string; - name: string; - count: number; - channels: string[]; -}; - -const initialGroups: ChannelGroup[] = [ - { - id: 'medical-a', - name: '学医三网专用群', - count: 16, - channels: [ - '三网行北-黄峰-三网-编号3.3', - '三网行北-黄峰(循环号用)-三网-编号3.4', - '移动映华北-上海富煌C60289-移动2.7', - '三网行北-北京富惠互联-三网-编号3.5', - '移动行北-上海悉斯4pp0131-移动2.8', - ], - }, - { - id: 'medical-b', - name: '学医三网专用群', - count: 5, - channels: [ - '三网行北-黄峰-三网-编号3.3', - '三网行北-黄峰(循环号用)-三网-编号3.4', - '移动映华北-上海富煌C60289-移动2.7', - ], - }, - { - id: 'group-a', - name: 'XXXX通道组', - count: 3, - channels: [ - '三网行北-黄峰-三网-编号3.3', - '三网行北-黄峰(循环号用)-三网-编号3.4', - ], - }, - { - id: 'group-b', - name: 'XXXX通道组', - count: 1, - channels: ['三网行北-黄峰-三网-编号3.3'], - }, - { - id: 'test-a', - name: '测试通道组A', - count: 8, - channels: [ - '三网行北-黄峰-三网-编号3.3', - '移动映华北-上海富煌C60289-移动2.7', - ], - }, - { - id: 'test-b', - name: '测试通道组B', - count: 12, - channels: ['三网行北-黄峰-三网-编号3.3'], - }, - { - id: 'test-c', - name: '测试通道组C', - count: 6, - channels: [ - '三网行北-黄峰-三网-编号3.3', - '三网行北-黄峰(循环号用)-三网-编号3.4', - ], - }, - { - id: 'test-d', - name: '测试通道组D', - count: 2, - channels: ['三网行北-黄峰-三网-编号3.3'], - }, -]; +import { useEffect, useMemo, useState } from 'react'; +import { Layers3, Plus, Search, UsersRound } from 'lucide-react'; +import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui'; +import { adminApi, type ChannelGroup } from '@/api/adminApi'; export function AdminChannelGroupsPage() { - const navigate = useNavigate(); const [groupName, setGroupName] = useState(''); - const [channelKeyword, setChannelKeyword] = useState(''); - const [groups, setGroups] = useState(initialGroups); + const [groups, setGroups] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); - const filteredGroups = useMemo(() => groups.filter((group) => { - const nameMatched = group.name.includes(groupName.trim()); - const channelMatched = group.channels.some((channel) => channel.includes(channelKeyword.trim())); - return nameMatched && channelMatched; - }), [channelKeyword, groupName, groups]); - - function resetFilters() { - setGroupName(''); - setChannelKeyword(''); + function loadData() { + adminApi.listChannelGroups() + .then((items) => { + setGroups(items); + setError(''); + }) + .catch((failure: Error) => setError(failure.message || '通道组加载失败')); } - function removeGroup(id: string) { - setGroups((current) => current.filter((group) => group.id !== id)); + useEffect(() => { + loadData(); + }, []); + + const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); + + function createGroup() { + adminApi.createChannelGroup({ code, name, status: 'active' }) + .then(() => { + setModalOpen(false); + setName(''); + setCode(''); + loadData(); + }) + .catch((failure: Error) => setError(failure.message || '通道组创建失败')); } return ( @@ -107,17 +43,17 @@ export function AdminChannelGroupsPage() {
- + {error ?

{error}

: null}
setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} /> - setChannelKeyword(event.target.value)} placeholder="请输入包含的通道" value={channelKeyword} />
- - + +
@@ -130,27 +66,37 @@ export function AdminChannelGroupsPage() { {group.name} - {group.count} + {group.items?.length ?? 0}
- {group.channels.slice(0, 5).map((channel) => ( -

{channel}

- ))} - {group.count > group.channels.length ? 还有 {group.count - group.channels.length} 个通道... : null} + {(group.items ?? []).slice(0, 5).map((item, index) => { + const channel = item.channel as { name?: string } | undefined; + return

{channel?.name ?? '未命名通道'}

; + })} + {(group.items?.length ?? 0) === 0 ?

暂无绑定通道

: null}
-
- - -
))} + + + + + + )} + onClose={() => setModalOpen(false)} + open={modalOpen} + title="添加通道组" + > +
+ setCode(event.target.value)} value={code} /> + setName(event.target.value)} value={name} /> +
+
); } diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 9cef2e9..6c03862 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,664 +1,110 @@ -import { useMemo, useState } from 'react'; -import { - ChevronDown, - ChevronLeft, - ChevronRight, - ChevronUp, - Eye, - FileSliders, - FileUp, - GripVertical, - Pencil, - Plus, - Search, - Settings2, - Trash2, -} from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; -import type { DateRangeValue } from '@/components/ui'; - -type ReportStatus = 'success' | 'failed' | 'reporting' | 'unreported' | 'withdrawn' | 'abandoned'; - -type DeliveryStats = { - successRate: number; - successCount: number; - unknownRate: number; - unknownCount: number; - failureRate: number; - failureCount: number; -}; - -type DrainageReport = { - id: string; - value: string; - status: ReportStatus; - submittedAt: string; - reportedAt?: string; - lastSentAt?: string; - stats: DeliveryStats; - remark?: string; -}; - -type SignatureReport = { - id: string; - name: string; - status: ReportStatus; - submittedAt: string; - reportedAt?: string; - lastSentAt?: string; - stats: DeliveryStats; - drainage: DrainageReport[]; - details: SignatureDetails; - remark?: string; -}; - -type SignatureDetails = { - basis: string; - companyName: string; - creditCode: string; - legalName: string; - legalId: string; - contactName: string; - contactPhone: string; - contactId: string; -}; - -type ReportDetail = - | { kind: 'signature'; report: SignatureReport } - | { kind: 'drainage'; title: string; status: ReportStatus; submittedAt: string; reportedAt?: string; lastSentAt?: string }; - -type ReportField = { - id: string; - label: string; - type: '文本' | '图片' | '文件'; -}; - -type SelectedReportField = ReportField & { - required: boolean; - mapping: string; -}; - -const channelNames: Record = { - '88827': '行北-集市三甲医院-39', - '77': '移动-行北-上海甲医院-38', - '78': '联通-行政-杭州甲医院-37', - '67': '联通-行政-上海甲医院-34', -}; - -const channelCopyStorageKey = 'cmpp-channel-copies'; - -type ChannelCopyMeta = { - sourceId: string; - name: string; -}; - -function readChannelCopyMeta(): Record { - try { - const raw = window.localStorage.getItem(channelCopyStorageKey); - return raw ? JSON.parse(raw) as Record : {}; - } catch { - return {}; - } -} - -function getChannelName(channelId: string) { - const copyMeta = readChannelCopyMeta()[channelId]; - return copyMeta?.name ?? channelNames[channelId] ?? `短信通道 ${channelId}`; -} - -const statusOptions = [ - { label: '全部状态', value: 'all' }, - { label: '报备成功', value: 'success' }, - { label: '报备失败', value: 'failed' }, - { label: '报备中', value: 'reporting' }, - { label: '未报备', value: 'unreported' }, - { label: '被清退', value: 'withdrawn' }, - { label: '放弃报备', value: 'abandoned' }, -]; - -const statusMeta: Record = { - success: { label: '报备成功', tone: 'success' }, - failed: { label: '报备失败', tone: 'danger' }, - reporting: { label: '报备中', tone: 'warning' }, - unreported: { label: '未报备', tone: 'neutral' }, - withdrawn: { label: '被清退', tone: 'danger' }, - abandoned: { label: '放弃报备', tone: 'warning' }, -}; - -const statusChoices: Array<{ value: ReportStatus; label: string; className: string }> = [ - { value: 'unreported', label: '未报备', className: 'is-neutral' }, - { value: 'reporting', label: '报备中', className: 'is-info' }, - { value: 'success', label: '报备成功', className: 'is-success' }, - { value: 'failed', label: '报备失败', className: 'is-danger' }, - { value: 'withdrawn', label: '被清退', className: 'is-danger' }, - { value: 'abandoned', label: '放弃报备', className: 'is-warning' }, -]; - -const drainageFieldPool: ReportField[] = [ - { id: 'businessScope', label: '营业范围', type: '文本' }, - { id: 'legalName', label: '法人姓名', type: '文本' }, - { id: 'legalPhone', label: '法人手机号', type: '文本' }, - { id: 'legalIdImage', label: '法人身份证图片', type: '图片' }, - { id: 'managerIdImage', label: '经办人身份证图片', type: '图片' }, - { id: 'managerPhone', label: '经办人手机号', type: '文本' }, - { id: 'managerName', label: '经办人姓名', type: '文本' }, - { id: 'creditCode', label: '统一社会信用代码', type: '文本' }, - { id: 'licenseImage', label: '营业执照图片', type: '图片' }, - { id: 'brandName', label: '品牌名称', type: '文本' }, - { id: 'drainageInfo', label: '引流信息', type: '文本' }, - { id: 'companyAddress', label: '公司地址', type: '文本' }, - { id: 'authorization', label: '授权证明', type: '文件' }, -]; - -const signatureFieldPool: ReportField[] = [ - { id: 'signatureName', label: '短信签名', type: '文本' }, - { id: 'signatureBasis', label: '签名依据', type: '文本' }, - { id: 'qualificationFile', label: '资质凭证', type: '文件' }, - { id: 'companyName', label: '公司名称', type: '文本' }, - { id: 'creditCode', label: '统一社会信用代码', type: '文本' }, - { id: 'legalName', label: '法人姓名', type: '文本' }, - { id: 'legalId', label: '法人身份证号', type: '文本' }, - { id: 'legalIdFront', label: '法人身份证人像面', type: '图片' }, - { id: 'legalIdBack', label: '法人身份证国徽面', type: '图片' }, - { id: 'managerName', label: '责任人姓名', type: '文本' }, - { id: 'managerPhone', label: '责任人手机号', type: '文本' }, - { id: 'managerId', label: '责任人身份证号', type: '文本' }, - { id: 'authorization', label: '授权委托书', type: '文件' }, -]; - -const initialSelectedDrainageFields: SelectedReportField[] = [ - { id: 'companyName', label: '公司名称', type: '文本', required: false, mapping: '' }, - { id: 'legalId', label: '法人身份证号', type: '文本', required: false, mapping: '' }, -]; - -const initialSelectedSignatureFields: SelectedReportField[] = [ - { id: 'signatureName', label: '短信签名', type: '文本', required: true, mapping: 'sign_name' }, - { id: 'qualificationFile', label: '资质凭证', type: '文件', required: true, mapping: 'license_file' }, - { id: 'companyName', label: '公司名称', type: '文本', required: true, mapping: 'enterprise_name' }, - { id: 'creditCode', label: '统一社会信用代码', type: '文本', required: true, mapping: 'credit_code' }, -]; - -const emptyStats: DeliveryStats = { - successRate: 0, - successCount: 0, - unknownRate: 0, - unknownCount: 0, - failureRate: 0, - failureCount: 0, -}; - -const initialReports: SignatureReport[] = [ - { - id: 'sig-1', - name: '中华长城签名1', - status: 'failed', - submittedAt: '2025-12-28 18:08:08', - reportedAt: '2025-12-29 12:03:01', - lastSentAt: '2025-12-30 08:13:21', - stats: { successRate: 88.2, successCount: 1130200, unknownRate: 29.1, unknownCount: 372940, failureRate: 8, failureCount: 102480 }, - details: { basis: '企业自用签名', companyName: '示例科技有限公司', creditCode: '91110000XXXXXXXXXX', legalName: '张三', legalId: '110101199001011234', contactName: '李四', contactPhone: '13800138000', contactId: '110101199002021234' }, - drainage: [ - { id: 'flow-1', value: '400-123-4567', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } }, - { id: 'flow-2', value: 'www.example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 54 } }, - { id: 'flow-3', value: 'service@example.com', status: 'success', submittedAt: '2025-12-28 18:08:08', reportedAt: '2025-12-28 18:08:08', lastSentAt: '2025-12-28 18:08:08', stats: { ...emptyStats, failureRate: 100, failureCount: 50 } }, - { id: 'flow-4', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats }, - ], - }, - { - id: 'sig-2', - name: '医长长长长长长长长长长', - status: 'success', - submittedAt: '2025-12-28 18:08:08', - reportedAt: '2025-12-29 12:03:01', - stats: emptyStats, - details: { basis: '企业自用签名', companyName: '上海医长信息科技有限公司', creditCode: '91310000XXXXXXXXXX', legalName: '王强', legalId: '310101198805061234', contactName: '赵敏', contactPhone: '13900139000', contactId: '310101199006081234' }, - drainage: [ - { id: 'flow-5', value: '18912345678', status: 'unreported', submittedAt: '2025-12-28 18:08:08', stats: emptyStats }, - ], - }, - { - id: 'sig-3', - name: '国信委科技服务', - status: 'reporting', - submittedAt: '2025-12-28 18:08:08', - reportedAt: '2025-12-29 12:03:01', - lastSentAt: '2025-12-30 08:13:21', - stats: { successRate: 78.2, successCount: 8804, unknownRate: 1.2, unknownCount: 2046, failureRate: 5.8, failureCount: 1916 }, - details: { basis: '企事业单位全称或简称', companyName: '国信委科技服务有限公司', creditCode: '91110108XXXXXXXXXX', legalName: '陈杰', legalId: '110108198812121234', contactName: '周宁', contactPhone: '13700137000', contactId: '110108199103151234' }, - drainage: [], - }, -]; - -function DateTime({ value }: { value?: string }) { - if (!value) return -; - const [date, time] = value.split(' '); - return {date}{time}; -} - -function Stats({ stats }: { stats: DeliveryStats }) { - return ( -
- 成功 {stats.successRate}%{stats.successCount.toLocaleString('zh-CN')} - 未知 {stats.unknownRate}%{stats.unknownCount.toLocaleString('zh-CN')} - 失败 {stats.failureRate}%{stats.failureCount.toLocaleString('zh-CN')} -
- ); -} - -function RowActions({ onDelete, onStatus, onView }: { onDelete: () => void; onStatus: () => void; onView: () => void }) { - return ( -
- - - -
- ); -} - -function ReadonlyUpload({ label }: { label: string }) { - return ( -
- {label} -
已上传
-
- ); -} - -function SignatureDetailModal({ report, onClose }: { report: SignatureReport; onClose: () => void }) { - const details = report.details; - return ( - 关闭} - onClose={onClose} - open - size="xl" - title={

查看签名详情

查看短信签名的详细信息

} - > -
-
-

基本信息

-
- - -
-
- -
-

公司信息

-
- - - - - - -
-
- -
-

责任人信息

-
- - - - - -
-
-
-
- ); -} - -function DrainageFieldConfigModal({ - fields, - fieldPool, - onChange, - onClose, - title = '配置报备字段', - description = '从字段池中选择字段,并配置是否必填', -}: { - fields: SelectedReportField[]; - fieldPool: ReportField[]; - onChange: (fields: SelectedReportField[]) => void; - onClose: () => void; - title?: string; - description?: string; -}) { - const [draft, setDraft] = useState(fields); - const [searchText, setSearchText] = useState(''); - const availableFields = useMemo(() => fieldPool.filter((field) => ( - !draft.some((item) => item.id === field.id) - && (!searchText || field.label.includes(searchText)) - )), [draft, fieldPool, searchText]); - - function addField(field: ReportField) { - setDraft((items) => [...items, { ...field, required: false, mapping: '' }]); - } - - function updateField(id: string, patch: Partial) { - setDraft((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item)); - } - - function moveField(index: number, offset: number) { - setDraft((items) => { - const target = index + offset; - if (target < 0 || target >= items.length) return items; - const next = [...items]; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - } - - return ( - - - - - )} - onClose={onClose} - open - size="xl" - title={

{title}

{description}

} - > -
-
-
-

字段池

- {availableFields.length} 个可选 -
- setSearchText(event.target.value)} placeholder="搜索字段..." prefix={} value={searchText} /> -
- {availableFields.map((field) => ( - - ))} - {availableFields.length === 0 ?

没有可添加的字段

: null} -
-
- -
-
-

已选择字段

可调整顺序和设置必填项

- {draft.length} 个 -
-
- {draft.map((field, index) => ( -
-
- {index + 1} - - {field.label} - {field.type} -
- - -
-
-
- - - -
- updateField(field.id, { mapping: event.target.value })} placeholder="请输入映射字段名" value={field.mapping} /> -
- ))} - {draft.length === 0 ?
请从左侧添加报备字段
: null} -
-
-
-
- ); -} - -function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: () => void }) { - return ( - } - onClose={onClose} - open - size="xl" - title={

导入报备回执

同步运营商返回的签名和引流信息报备状态。

} - > -
-
- - 选择回执文件 - 支持 Excel、CSV。导入后会按签名、引流内容和通道匹配当前报备记录。 -
-
-

匹配预览

-
可更新签名12 条
-
可更新引流信息5 条
-
需人工确认2 条
-
-