fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+17 -2
View File
@@ -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);
}
}
+28
View File
@@ -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');
}
}
@@ -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 的队列消息格式。
+12
View File
@@ -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 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 |
+6
View File
@@ -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` 模拟创建、删除、审核、充值、发送、报备成功。
- 彩信相关页面当前可保留待开发占位,但测试报告必须单独标注,不得混入短信第一版已完成范围。
+36
View File
@@ -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 Jest10 个 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 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。
+1
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo/fav.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMPP 短信平台</title>
</head>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+257
View File
@@ -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<Record<string, unknown>>;
};
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<string, unknown> & {
id: string;
status?: string;
createdAt?: string;
updatedAt?: string;
};
export type ChannelGroup = DictionaryItem & {
code: string;
name: string;
description?: string | null;
items?: Array<Record<string, unknown>>;
};
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<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listTenants: () => request<TenantOption[]>('/admin/tenants'),
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
createTenant: (body: { name: string; code: string; status?: string }) =>
request<TenantOption>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }),
updateTenant: (id: string, body: { name?: string; code?: string; status?: string }) =>
request<TenantOption>(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeTenantStatus: (id: string, status: string) =>
request<TenantOption>(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
deleteTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`, { method: 'DELETE' }),
listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request<ManagedUser[]>(withQuery('/admin/users', query)),
createUser: (body: UserPayload) => request<ManagedUser>('/admin/users', { method: 'POST', body: JSON.stringify(body) }),
updateUser: (id: string, body: Omit<UserPayload, 'password'>) => request<ManagedUser>(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
@@ -286,6 +458,8 @@ export const adminApi = {
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
method: 'POST',
@@ -301,6 +475,8 @@ export const adminApi = {
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
listChannels: () => request<AdminChannel[]>('/admin/channels'),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/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<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
request<ClientSmsTemplate[]>(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<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; description?: string; status?: string }) =>
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/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<string, unknown> }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) =>
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
approveRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }),
rejectRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)),
createSensitiveWord: (body: { word: string; level?: string; status?: string }) =>
request<DictionaryItem>('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }),
deleteSensitiveWord: (id: string) => request<DictionaryItem>(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }),
listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/global', query)),
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
listEnterpriseBlacklist: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
};
export const clientApi = {
@@ -368,4 +591,38 @@ export const clientApi = {
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<BillingPlan[]>('/client/billing/plans', { tenantId }),
createOrder: (body: { planId?: string; amountCents?: number; smsUnits?: number; payMethod?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder>('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature>(`/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<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate[]>(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<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>(`/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsMessageRecord[]>(`/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<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
};
+3 -4
View File
@@ -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) {
<main className="login-page">
<section className="login-panel">
<div className="login-brand">
<span><ShieldCheck size={28} /></span>
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
<div>
<h1>{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}</h1>
<p>{isAdmin ? '平台管理员登录' : '企业管理员登录'}</p>
<h1></h1>
<p>{isAdmin ? '运营端登录' : '客户端登录'}</p>
</div>
</div>
<div className="login-form">
+48 -77
View File
@@ -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<DashboardResponse | null>(null);
const [tenantStats, setTenantStats] = useState<Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
const [channelStats, setChannelStats] = useState<Array<{ channelId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
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 (
<section className="page-stack">
@@ -51,24 +44,25 @@ export function AdminAnalyticsPage() {
<div>
<Breadcrumb items={['数据统计']} />
</div>
<Button icon={<BarChart3 size={16} />} variant="ghost"></Button>
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span></span>
<strong>{overview.todaySubmissions}</strong>
<small></small>
<span></span>
<strong>{dashboard?.today.sent.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{customers.filter((item) => item.status === 'active').length}</strong>
<small></small>
<span></span>
<strong>{dashboard?.today.successRate ?? 0}%</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{overview.channelHealth}%</strong>
<small> 24 </small>
<span></span>
<strong>{dashboard?.pendingAuditCount ?? 0}</strong>
<small>///</small>
</div>
</div>
@@ -76,45 +70,22 @@ export function AdminAnalyticsPage() {
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"> 3 </p>
<h2></h2>
<p className="muted"></p>
</div>
<Tag tone="info"></Tag>
<Tag tone="info"></Tag>
</div>
<Chart height={320} option={sendTrendOption} />
<Chart height={320} option={tenantOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
<p className="muted"></p>
</div>
<Tag tone="accent"></Tag>
</div>
<Chart height={320} option={channelShareOption} />
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"> 7 </p>
</div>
<Tag tone="warning"></Tag>
</div>
<Chart height={320} option={auditTrendOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Tag tone="success"></Tag>
</div>
<Chart height={320} option={customerGrowthOption} />
<Chart height={320} option={channelOption} />
</div>
</div>
</section>
+22 -11
View File
@@ -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<TableColumn<Customer>> = [
{ 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<TableColumn<TenantAccount>> = [
{ 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<TableColumn<Customer>> = [
];
export function AdminBillingPage() {
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [error, setError] = useState('');
useEffect(() => {
adminApi.listAccounts()
.then((items) => {
setAccounts(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '账务账户加载失败'));
}, []);
return (
<section className="page-stack">
<div className="page-heading">
@@ -29,8 +39,9 @@ export function AdminBillingPage() {
<Breadcrumb items={['账单流水']} />
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface">
<Table columns={columns} data={adminService.getCustomers()} rowKey="id" />
<Table columns={columns} data={accounts} emptyText="暂无账户数据" rowKey="id" />
</div>
</section>
);
+58 -112
View File
@@ -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<ChannelGroup[]>([]);
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() {
<div>
<Breadcrumb items={['短信通道组管理']} />
</div>
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/channel-groups/new')}>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<section className="surface channel-group-filter">
<Input label="通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} />
<Input label="包含通道" onChange={(event) => setChannelKeyword(event.target.value)} placeholder="请输入包含的通道" value={channelKeyword} />
<div className="channel-group-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setGroupName('')} variant="ghost"></Button>
</div>
</section>
@@ -130,27 +66,37 @@ export function AdminChannelGroupsPage() {
<Layers3 size={18} />
<strong>{group.name}</strong>
</div>
<span title="包含通道数"><UsersRound size={16} />{group.count}</span>
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
</header>
<div className="channel-group-card__body">
{group.channels.slice(0, 5).map((channel) => (
<p key={channel}>{channel}</p>
))}
{group.count > group.channels.length ? <small> {group.count - group.channels.length} ...</small> : null}
{(group.items ?? []).slice(0, 5).map((item, index) => {
const channel = item.channel as { name?: string } | undefined;
return <p key={`${group.id}-${index}`}>{channel?.name ?? '未命名通道'}</p>;
})}
{(group.items?.length ?? 0) === 0 ? <p className="muted"></p> : null}
</div>
<footer>
<Button icon={<Edit3 size={16} />} onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} size="sm" variant="ghost">
</Button>
<Button icon={<Trash2 size={16} />} onClick={() => removeGroup(group.id)} size="sm" variant="danger">
</Button>
</footer>
</article>
))}
</div>
<Pagination nextDisabled={false} page={1} total={filteredGroups.length} />
</section>
<Modal
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!name || !code} onClick={createGroup}></Button>
</>
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
title="添加通道组"
>
<div className="admin-system-modal-form">
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} />
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} />
</div>
</Modal>
</div>
);
}
+91 -645
View File
@@ -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<string, string> = {
'88827': '行北-集市三甲医院-39',
'77': '移动-行北-上海甲医院-38',
'78': '联通-行政-杭州甲医院-37',
'67': '联通-行政-上海甲医院-34',
};
const channelCopyStorageKey = 'cmpp-channel-copies';
type ChannelCopyMeta = {
sourceId: string;
name: string;
};
function readChannelCopyMeta(): Record<string, ChannelCopyMeta> {
try {
const raw = window.localStorage.getItem(channelCopyStorageKey);
return raw ? JSON.parse(raw) as Record<string, ChannelCopyMeta> : {};
} 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<ReportStatus, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
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 <span className="muted">-</span>;
const [date, time] = value.split(' ');
return <span className="channel-report-date"><span>{date}</span><span>{time}</span></span>;
}
function Stats({ stats }: { stats: DeliveryStats }) {
return (
<div className="channel-report-stats">
<span> <strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span> <strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span> <strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
</div>
);
}
function RowActions({ onDelete, onStatus, onView }: { onDelete: () => void; onStatus: () => void; onView: () => void }) {
return (
<div className="channel-report-actions">
<button onClick={onView} type="button"><Eye size={16} /></button>
<button className="is-warning" onClick={onStatus} type="button"><Pencil size={16} /></button>
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={16} /></button>
</div>
);
}
function ReadonlyUpload({ label }: { label: string }) {
return (
<div className="channel-signature-upload">
<span>{label}</span>
<div></div>
</div>
);
}
function SignatureDetailModal({ report, onClose }: { report: SignatureReport; onClose: () => void }) {
const details = report.details;
return (
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={<div className="channel-signature-title"><h2></h2><p></p></div>}
>
<div className="channel-signature-detail">
<section>
<h3></h3>
<div className="channel-signature-grid">
<Select disabled label="签名依据" options={[{ label: details.basis, value: details.basis }]} value={details.basis} />
<Input label="短信签名" readOnly value={`${report.name}`} />
<ReadonlyUpload label="资质凭证" />
</div>
</section>
<section>
<h3></h3>
<div className="channel-signature-grid">
<Input label="公司名称" readOnly value={details.companyName} />
<Input label="统一社会信用代码" readOnly value={details.creditCode} />
<Input label="法人姓名" readOnly value={details.legalName} />
<Input label="法人身份证号" readOnly value={details.legalId} />
<ReadonlyUpload label="法人身份证照片-人像面" />
<ReadonlyUpload label="法人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="channel-signature-grid">
<Input label="责任人姓名" readOnly value={details.contactName} />
<Input label="责任人手机号" readOnly value={details.contactPhone} />
<Input className="channel-signature-grid__wide" label="责任人身份证号" readOnly value={details.contactId} />
<ReadonlyUpload label="责任人身份证照片-人像面" />
<ReadonlyUpload label="责任人身份证照片-国徽面" />
</div>
</section>
</div>
</Modal>
);
}
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<SelectedReportField>) {
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 (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => { onChange(draft); onClose(); }}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="channel-field-config-title"><h2>{title}</h2><p>{description}</p></div>}
>
<div className="channel-field-config">
<section className="channel-field-pool">
<div className="channel-field-section-head">
<h3></h3>
<Tag tone="neutral">{availableFields.length} </Tag>
</div>
<Input onChange={(event) => setSearchText(event.target.value)} placeholder="搜索字段..." prefix={<Search size={16} />} value={searchText} />
<div className="channel-field-pool-list">
{availableFields.map((field) => (
<button key={field.id} onClick={() => addField(field)} type="button">
<span><strong>{field.label}</strong><Tag tone="neutral">{field.type}</Tag></span>
<span> <Plus size={15} /></span>
</button>
))}
{availableFields.length === 0 ? <p></p> : null}
</div>
</section>
<section className="channel-selected-fields">
<div className="channel-field-section-head">
<div><h3></h3><p></p></div>
<Tag tone="info">{draft.length} </Tag>
</div>
<div className="channel-selected-field-list">
{draft.map((field, index) => (
<article key={field.id}>
<div className="channel-selected-field-head">
<span className="channel-selected-field-index">{index + 1}</span>
<GripVertical size={17} />
<strong>{field.label}</strong>
<Tag tone="neutral">{field.type}</Tag>
<div className="channel-selected-field-order">
<button disabled={index === 0} onClick={() => moveField(index, -1)} type="button"><ChevronUp size={16} /><span className="sr-only"></span></button>
<button disabled={index === draft.length - 1} onClick={() => moveField(index, 1)} type="button"><ChevronDown size={16} /><span className="sr-only"></span></button>
</div>
</div>
<div className="channel-selected-field-controls">
<label><input checked={field.required} onChange={() => updateField(field.id, { required: true })} type="radio" /></label>
<label><input checked={!field.required} onChange={() => updateField(field.id, { required: false })} type="radio" /></label>
<button aria-label={`删除${field.label}`} onClick={() => setDraft((items) => items.filter((item) => item.id !== field.id))} type="button"><Trash2 size={17} /></button>
</div>
<Input label="映射通道字段" onChange={(event) => updateField(field.id, { mapping: event.target.value })} placeholder="请输入映射字段名" value={field.mapping} />
</article>
))}
{draft.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
</section>
</div>
</Modal>
);
}
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: () => void }) {
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button onClick={onSubmit}></Button></>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="report-receipt-modal">
<div className="report-upload-drop">
<FileUp size={38} />
<strong></strong>
<span> ExcelCSV</span>
</div>
<div className="report-receipt-preview">
<h3></h3>
<div><span></span><strong>12 </strong></div>
<div><span></span><strong>5 </strong></div>
<div><span></span><strong>2 </strong></div>
</div>
<Textarea label="导入备注" placeholder="记录运营商工单号、回执来源或人工说明" rows={4} />
</div>
</Modal>
);
}
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
export function AdminChannelReportPage() {
const navigate = useNavigate();
const { channelId = '88827' } = useParams();
const channelName = getChannelName(channelId);
const [reports, setReports] = useState(initialReports);
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [channelId, setChannelId] = useState('');
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null);
const [nextStatus, setNextStatus] = useState<ReportStatus>('success');
const [nextRemark, setNextRemark] = useState('');
const [detail, setDetail] = useState<ReportDetail | null>(null);
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
const [signatureFieldConfigOpen, setSignatureFieldConfigOpen] = useState(false);
const [receiptOpen, setReceiptOpen] = useState(false);
const [drainageFields, setDrainageFields] = useState(initialSelectedDrainageFields);
const [signatureFields, setSignatureFields] = useState(initialSelectedSignatureFields);
const [modalOpen, setModalOpen] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [fieldType, setFieldType] = useState('string');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const filteredReports = useMemo(() => reports.filter((report) => {
const matchesKeyword = !keyword || report.name.includes(keyword) || report.drainage.some((item) => item.value.includes(keyword));
const matchesStatus = status === 'all' || report.status === status;
const date = report.submittedAt.slice(0, 10);
const matchesStart = !dateRange.start || date >= dateRange.start;
const matchesEnd = !dateRange.end || date <= dateRange.end;
return matchesKeyword && matchesStatus && matchesStart && matchesEnd;
}), [dateRange.end, dateRange.start, keyword, reports, status]);
function toggleExpanded(id: string) {
setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
function loadData(nextChannelId = channelId) {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined)])
.then(([channelItems, fieldItems]) => {
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setFields(fieldItems);
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
}
function toggleSelected(id: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
useEffect(() => {
loadData('');
}, []);
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]);
function createField() {
adminApi.createChannelReportField({ channelId, code, name, fieldType, description, status: 'active' })
.then(() => {
setModalOpen(false);
setCode('');
setName('');
setFieldType('string');
setDescription('');
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
}
function removeItem(signatureId: string, drainageId?: string) {
setReports((items) => drainageId
? items.map((item) => item.id === signatureId ? { ...item, drainage: item.drainage.filter((flow) => flow.id !== drainageId) } : item)
: items.filter((item) => item.id !== signatureId));
}
function applyStatus() {
if (!statusTarget) return;
setReports((items) => items.map((item) => {
if (item.id !== statusTarget.signatureId) return item;
if (!statusTarget.drainageId) return { ...item, status: nextStatus, remark: nextRemark };
return { ...item, drainage: item.drainage.map((flow) => flow.id === statusTarget.drainageId ? { ...flow, status: nextStatus, remark: nextRemark } : flow) };
}));
setStatusTarget(null);
setNextRemark('');
}
function openStatus(signatureId: string, currentStatus: ReportStatus, drainageId?: string, remark = '') {
setNextStatus(currentStatus);
setNextRemark(remark);
setStatusTarget({ signatureId, drainageId });
}
const columns: Array<TableColumn<ChannelReportField>> = [
{ key: 'channel', title: '通道', width: '220px', render: (record) => channels.find((item) => item.id === record.channelId)?.name ?? record.channelId },
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '160px', render: (record) => record.name },
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType },
{ key: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> },
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
];
return (
<section className="page-stack channel-report-page">
<div className="surface channel-report-hero">
<Breadcrumb items={[channelName]} />
<div className="channel-report-heading">
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<h1>{channelName}</h1>
<Button icon={<ChevronRight size={16} />} variant="ghost"></Button>
<div className="channel-report-config-actions">
<Button icon={<FileUp size={16} />} onClick={() => setReceiptOpen(true)} variant="secondary"></Button>
<Button icon={<Settings2 size={16} />} onClick={() => setFieldConfigOpen(true)} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setSignatureFieldConfigOpen(true)} variant="ghost"></Button>
</div>
<section className="page-stack admin-system-page admin-drainage-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备管理', '通道报备配置']} />
<h1></h1>
</div>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface channel-report-filter">
<div className="channel-report-filter-grid">
<Input label="签名或引流信息" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入签名、网址、电话或邮箱" value={keyword} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交报备时间" onChange={setDateRange} value={dateRange} />
</div>
<div className="channel-report-filter-footer">
<div>
<Button disabled={selectedIds.size === 0} icon={<Pencil size={16} />} onClick={() => setSelectedIds(new Set())} variant="ghost"></Button>
<Button disabled={selectedIds.size === 0} icon={<Trash2 size={16} />} onClick={() => { setReports((items) => items.filter((item) => !selectedIds.has(item.id))); setSelectedIds(new Set()); }} variant="danger"></Button>
</div>
<div>
<Button onClick={() => { setKeyword(''); setStatus('all'); setDateRange({}); }} variant="ghost"></Button>
<Button icon={<Search size={16} />}></Button>
</div>
</div>
</div>
<div className="surface channel-report-table">
<div className="channel-report-table__head">
<span />
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{filteredReports.map((report) => {
const isExpanded = expanded.has(report.id);
return (
<div className="channel-report-group" key={report.id}>
<div className="channel-report-row channel-report-row--signature">
<input aria-label={`选择${report.name}`} checked={selectedIds.has(report.id)} onChange={() => toggleSelected(report.id)} type="checkbox" />
<div className="channel-report-name">
<button aria-label={isExpanded ? '收起引流信息' : '展开引流信息'} disabled={report.drainage.length === 0} onClick={() => toggleExpanded(report.id)} type="button">
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
<span><strong>{report.name}</strong><small> <b>{report.drainage.length}</b></small></span>
</div>
<Tag tone={statusMeta[report.status].tone}>{statusMeta[report.status].label}</Tag>
<DateTime value={report.submittedAt} />
<DateTime value={report.reportedAt} />
<DateTime value={report.lastSentAt} />
<Stats stats={report.stats} />
<RowActions onDelete={() => removeItem(report.id)} onStatus={() => openStatus(report.id, report.status, undefined, report.remark)} onView={() => setDetail({ kind: 'signature', report })} />
</div>
{isExpanded ? report.drainage.map((flow) => (
<div className="channel-report-row channel-report-row--drainage" key={flow.id}>
<input aria-label={`选择${flow.value}`} type="checkbox" />
<div className="channel-report-name channel-report-name--flow"><i /> <strong>{flow.value}</strong></div>
<Tag tone={statusMeta[flow.status].tone}>{statusMeta[flow.status].label}</Tag>
<DateTime value={flow.submittedAt} />
<DateTime value={flow.reportedAt} />
<DateTime value={flow.lastSentAt} />
<Stats stats={flow.stats} />
<RowActions onDelete={() => removeItem(report.id, flow.id)} onStatus={() => openStatus(report.id, flow.status, flow.id, flow.remark)} onView={() => setDetail({ kind: 'drainage', title: flow.value, ...flow })} />
</div>
)) : null}
</div>
);
})}
{filteredReports.length === 0 ? <div className="channel-report-empty"></div> : null}
</div>
{statusTarget ? (
<Modal
footer={<><Button onClick={() => setStatusTarget(null)} variant="ghost"></Button><Button onClick={applyStatus}></Button></>}
onClose={() => setStatusTarget(null)}
open
size="md"
title={<div className="channel-status-title"><h2></h2><p></p></div>}
>
<div className="channel-status-form">
<div className="channel-status-options">
{statusChoices.map((choice) => (
<button
aria-pressed={nextStatus === choice.value}
className={`${choice.className} ${nextStatus === choice.value ? 'is-selected' : ''}`}
key={choice.value}
onClick={() => setNextStatus(choice.value)}
type="button"
>
{choice.label}
{nextStatus === choice.value ? <span></span> : null}
</button>
))}
</div>
<Textarea label="备注" onChange={(event) => setNextRemark(event.target.value)} placeholder="备注内容" rows={5} value={nextRemark} />
</div>
</Modal>
) : null}
{detail?.kind === 'signature' ? <SignatureDetailModal onClose={() => setDetail(null)} report={detail.report} /> : null}
{fieldConfigOpen ? (
<DrainageFieldConfigModal
description="这些字段会驱动客户端引流资料补充,并映射到当前通道的导出模板。"
fieldPool={drainageFieldPool}
fields={drainageFields}
onChange={setDrainageFields}
onClose={() => setFieldConfigOpen(false)}
title="个性化引流信息报备字段"
<div className="surface admin-drainage-toolbar">
<Select
onChange={(event) => {
setChannelId(event.target.value);
loadData(event.target.value);
}}
options={[{ label: '全部通道', value: '' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]}
value={channelId}
/>
) : null}
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段代码、名称或说明" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
</div>
{signatureFieldConfigOpen ? (
<DrainageFieldConfigModal
description="这些字段会驱动客户端签名资料补充,并映射到当前通道的签名报备导出模板。"
fieldPool={signatureFieldPool}
fields={signatureFields}
onChange={setSignatureFields}
onClose={() => setSignatureFieldConfigOpen(false)}
title="个性化签名报备字段"
/>
) : null}
<div className="surface admin-system-table-card admin-drainage-table-card">
<Table columns={columns} data={filteredFields} emptyText="暂无通道报备字段" rowKey="id" />
</div>
{receiptOpen ? <ReceiptImportModal onClose={() => setReceiptOpen(false)} onSubmit={() => setReceiptOpen(false)} /> : null}
{detail?.kind === 'drainage' ? (
<Modal footer={<Button onClick={() => setDetail(null)} variant="ghost"></Button>} onClose={() => setDetail(null)} open size="md" title="引流信息报备详情">
<div className="channel-report-detail">
<strong>{detail.title}</strong>
<p><span></span><Tag tone={statusMeta[detail.status].tone}>{statusMeta[detail.status].label}</Tag></p>
<p><span></span>{detail.submittedAt}</p>
<p><span></span>{detail.reportedAt ?? '-'}</p>
<p><span></span>{detail.lastSentAt ?? '-'}</p>
</div>
</Modal>
) : null}
<Modal
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !code || !name} onClick={createField}></Button></>}
onClose={() => setModalOpen(false)}
open={modalOpen}
title="新增通道报备字段"
>
<div className="admin-system-modal-form">
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} />
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select
label="字段类型"
onChange={(event) => setFieldType(event.target.value)}
options={[
{ label: '字符串', value: 'string' },
{ label: '数字', value: 'number' },
{ label: '文件', value: 'file' },
{ label: '图片', value: 'image' },
{ label: '网址', value: 'url' },
]}
value={fieldType}
/>
<Textarea label="说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
</div>
</Modal>
</section>
);
}
+8 -82
View File
@@ -103,85 +103,6 @@ const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'dan
failed: 'danger',
};
const initialChannels: SmsChannel[] = [
{
id: '88827',
name: '行北-集市三甲医院-39',
carrier: 'mobile',
unitPrice: 3.9,
status: 'normal',
total: 1587451,
successRate: 88.2,
successCount: 1281035,
unknownRate: 29.1,
unknownCount: 31648,
failureRate: 8,
failureCount: 189,
gatewayHost: '10.10.39.8',
gatewayPort: '17890',
corpCode: 'CM88827',
account: 'acct88827',
accessNo: '10690088',
},
{
id: '77',
name: '移动-行北-上海甲医院-38',
carrier: 'mobile',
unitPrice: 3.8,
status: 'stopped',
total: 12867,
successRate: 68.2,
successCount: 9982,
unknownRate: 20.5,
unknownCount: 2671,
failureRate: 16.2,
failureCount: 189,
gatewayHost: '10.10.38.8',
gatewayPort: '17890',
corpCode: 'CM00077',
account: 'acct00077',
accessNo: '10690077',
},
{
id: '78',
name: '联通-行政-杭州甲医院-37',
carrier: 'unicom',
unitPrice: 3.7,
status: 'connecting',
total: 8123,
successRate: 78.2,
successCount: 0,
unknownRate: 1.2,
unknownCount: 12,
failureRate: 5.8,
failureCount: 0,
gatewayHost: '10.10.37.8',
gatewayPort: '17890',
corpCode: 'CU00078',
account: 'acct00078',
accessNo: '10690078',
},
{
id: '67',
name: '联通-行政-上海甲医院-34',
carrier: 'telecom',
unitPrice: 16.2,
status: 'failed',
total: 154,
successRate: 0,
successCount: 0,
unknownRate: 0,
unknownCount: 0,
failureRate: 100,
failureCount: 154,
gatewayHost: '10.10.34.8',
gatewayPort: '17890',
corpCode: 'CT00067',
account: 'acct00067',
accessNo: '10690067',
},
];
function mapApiChannel(channel: AdminChannel): SmsChannel {
const statusMap: Record<string, ChannelStatus> = {
active: 'normal',
@@ -400,7 +321,8 @@ function SmsTestModal({
export function AdminChannelsPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState(initialChannels);
const [channels, setChannels] = useState<SmsChannel[]>([]);
const [error, setError] = useState('');
const [keyword, setKeyword] = useState('');
const [carrier, setCarrier] = useState('all');
const [status, setStatus] = useState('all');
@@ -411,8 +333,11 @@ export function AdminChannelsPage() {
useEffect(() => {
adminApi.listChannels()
.then((items) => setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel)))
.catch(() => undefined);
.then((items) => {
setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
}, []);
const filteredChannels = useMemo(
@@ -496,6 +421,7 @@ export function AdminChannelsPage() {
<Breadcrumb items={['短信通道管理']} />
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface sms-channel-filter">
<div className="sms-channel-filter-grid">
File diff suppressed because it is too large Load Diff
+35 -173
View File
@@ -1,82 +1,36 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ImagePlus } from 'lucide-react';
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
import {
cityOptionsByProvince,
createEnterprise,
getEnterpriseRecords,
initialEnterpriseForm,
provinceOptions,
toEnterpriseForm,
updateEnterprise,
type EnterpriseForm,
} from './adminEnterpriseMock';
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
import { adminApi } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
export function AdminCustomerFormPage() {
const navigate = useNavigate();
const { enterpriseId } = useParams();
const records = useMemo(() => getEnterpriseRecords(), []);
const editingRecord = useMemo(
() => records.find((record) => record.id === enterpriseId),
[enterpriseId, records],
);
const isEdit = Boolean(enterpriseId);
const [form, setForm] = useState<EnterpriseForm>(() => (
editingRecord ? toEnterpriseForm(editingRecord) : initialEnterpriseForm
));
const [errors, setErrors] = useState<EnterpriseFormErrors>({});
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [status, setStatus] = useState('active');
const [error, setError] = useState('');
useEffect(() => {
setForm(editingRecord ? toEnterpriseForm(editingRecord) : initialEnterpriseForm);
setErrors({});
}, [editingRecord, enterpriseId]);
const cityOptions = [
{ label: '请选择市/区', value: '' },
...(cityOptionsByProvince[form.province] ?? []),
];
function updateForm<K extends keyof EnterpriseForm>(key: K, value: EnterpriseForm[K]) {
setForm((current) => ({
...current,
[key]: value,
...(key === 'province' ? { city: '' } : {}),
}));
setErrors((current) => ({ ...current, [key]: undefined }));
}
function validateForm() {
const nextErrors: EnterpriseFormErrors = {};
if (!form.name.trim()) {
nextErrors.name = '请填写企业名称';
}
if (!form.creditCode.trim()) {
nextErrors.creditCode = '请填写统一社会信用代码';
}
if (!form.contactName.trim()) {
nextErrors.contactName = '请填写联系人姓名';
}
if (!form.contactPhone.trim()) {
nextErrors.contactPhone = '请填写手机号';
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
}
if (!enterpriseId) return;
adminApi.getTenant(enterpriseId)
.then((tenant) => {
setName(tenant.name);
setCode(tenant.code);
setStatus(tenant.status);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业信息加载失败'));
}, [enterpriseId]);
function submitForm() {
if (!validateForm()) {
return;
}
if (isEdit) {
updateEnterprise(form);
} else {
createEnterprise(form);
}
navigate('/admin/customers');
const action = isEdit && enterpriseId
? adminApi.updateTenant(enterpriseId, { name, code, status })
: adminApi.createTenant({ name, code, status });
action
.then(() => navigate('/admin/customers'))
.catch((failure: Error) => setError(failure.message || '企业保存失败'));
}
return (
@@ -84,126 +38,34 @@ export function AdminCustomerFormPage() {
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑企业' : '创建企业']} />
<p></p>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface enterprise-form-card">
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<div>
<h3></h3>
<p></p>
<p></p>
</div>
</div>
<div className="enterprise-upload-panel">
<span></span>
<button type="button">
<ImagePlus size={28} />
</button>
<p> JPGPNG 5MB</p>
</div>
<div className="form-grid form-grid--two">
<Input
error={errors.name}
label="企业名称"
onChange={(event) => updateForm('name', event.target.value)}
placeholder="请填写企业全称"
required
value={form.name}
/>
<Input
error={errors.creditCode}
hint="修改此项将同步更新该企业在系统中的所有相关记录。"
label="统一社会信用代码"
onChange={(event) => updateForm('creditCode', event.target.value)}
placeholder="请填写统一社会信用代码或纳税识别号"
required
value={form.creditCode}
/>
<Input label="企业名称" onChange={(event) => setName(event.target.value)} placeholder="请填写企业全称" required value={name} />
<Input label="企业编码" onChange={(event) => setCode(event.target.value)} placeholder="请填写唯一企业编码" required value={code} />
</div>
<div className="form-grid form-grid--two">
<Select
label="省/直辖市"
onChange={(event) => updateForm('province', event.target.value)}
options={provinceOptions}
value={form.province}
/>
<Select
label="市/区"
onChange={(event) => updateForm('city', event.target.value)}
options={cityOptions}
value={form.city}
/>
</div>
<Textarea
hint="通讯地址可以与营业执照上的地址不一致。"
label="通讯地址"
onChange={(event) => updateForm('address', event.target.value)}
placeholder="请填写详细通讯地址"
rows={4}
value={form.address}
<Select
label="企业状态"
onChange={(event) => setStatus(event.target.value)}
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={status}
/>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<div>
<h3></h3>
<p></p>
</div>
</div>
<div className="enterprise-info-tip">
便
</div>
<div className="form-grid form-grid--two">
<Input
error={errors.contactName}
label="联系人姓名"
onChange={(event) => updateForm('contactName', event.target.value)}
placeholder="请填写企业联系人姓名"
required
value={form.contactName}
/>
<Input
label="身份证号"
onChange={(event) => updateForm('contactIdCard', event.target.value)}
placeholder="请填写企业联系人身份证号"
value={form.contactIdCard}
/>
</div>
<div className="form-grid form-grid--two">
<Input
error={errors.contactPhone}
label="手机号"
onChange={(event) => updateForm('contactPhone', event.target.value)}
placeholder="请填写企业联系人手机号"
required
value={form.contactPhone}
/>
<Input
label="电子邮箱"
onChange={(event) => updateForm('contactEmail', event.target.value)}
placeholder="请填写企业联系人邮箱"
type="email"
value={form.contactEmail}
/>
</div>
</section>
<div className="enterprise-form-footer">
<Button onClick={submitForm}>{isEdit ? '保存企业' : '创建企业'}</Button>
<Button onClick={() => navigate('/admin/customers')} variant="ghost">
</Button>
<Button disabled={!name || !code} onClick={submitForm}>{isEdit ? '保存企业' : '创建企业'}</Button>
<Button onClick={() => navigate('/admin/customers')} variant="ghost"></Button>
</div>
</div>
</section>
+63 -164
View File
@@ -1,33 +1,20 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { adminApi, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import {
formatCurrency,
getEnterpriseRecords,
saveEnterpriseRecords,
statusOptions,
toggleEnterpriseStatus,
type EnterpriseRecord,
} from './adminEnterpriseMock';
type AdminCustomersPageProps = {
basePath?: string;
};
type CustomerRow = TenantOption & {
account?: TenantAccount;
};
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm}></Button>
</>
)}
onClose={onCancel}
open
title="操作确认"
>
<Modal footer={<><Button onClick={onCancel} variant="ghost"></Button><Button onClick={onConfirm}></Button></>} onClose={onCancel} open title="操作确认">
<p className="admin-confirm-text">{message}</p>
</Modal>
);
@@ -35,21 +22,32 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
const navigate = useNavigate();
const [records, setRecords] = useState<EnterpriseRecord[]>(() => getEnterpriseRecords());
const [records, setRecords] = useState<CustomerRow[]>([]);
const [queryId, setQueryId] = useState('');
const [queryName, setQueryName] = useState('');
const [queryStatus, setQueryStatus] = useState('all');
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: EnterpriseRecord } | null>(null);
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
const [error, setError] = useState('');
function deleteEnterprise(id: string) {
const nextRecords = records.filter((record) => record.id !== id);
saveEnterpriseRecords(nextRecords);
setRecords(nextRecords);
function loadData() {
Promise.all([adminApi.listTenants(), adminApi.listAccounts()])
.then(([tenants, accounts]) => {
setRecords(tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
...tenant,
account: accounts.find((account) => account.tenantId === tenant.id),
})));
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredRecords = useMemo(() => records.filter((record) => {
const matchId = filters.id ? record.id.includes(filters.id) : true;
const matchId = filters.id ? record.id.includes(filters.id) || record.code.includes(filters.id) : true;
const matchName = filters.name ? record.name.includes(filters.name) : true;
const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
return matchId && matchName && matchStatus;
@@ -57,180 +55,81 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const activeCount = records.filter((record) => record.status === 'active').length;
const disabledCount = records.filter((record) => record.status === 'disabled').length;
const todaySpend = records.reduce((sum, record) => sum + record.todaySpend, 0);
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
const columns: Array<TableColumn<EnterpriseRecord>> = [
{ key: 'id', title: '企业ID', width: '90px', render: (record) => record.id },
const columns: Array<TableColumn<CustomerRow>> = [
{ key: 'id', title: '企业ID', width: '220px', render: (record) => record.id },
{ key: 'name', title: '企业名称', render: (record) => <strong>{record.name}</strong> },
{
key: 'balance',
title: '当前余额',
align: 'right',
render: (record) => (
<span className={record.balance < 0 ? 'status-danger' : ''}>
¥{formatCurrency(record.balance)}
{record.balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
),
},
{ key: 'overdraftLimit', title: '透支限额', align: 'right', render: (record) => `¥${formatCurrency(record.overdraftLimit)}` },
{ key: 'todaySpend', title: '今日消费', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
{
key: 'status',
title: '企业状态',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'warning'}>
{record.status === 'active' ? '正常' : '已禁用'}
</Tag>
),
},
{ key: 'code', title: '企业编码', render: (record) => record.code },
{ key: 'balance', title: '现金余额', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')}` },
{ key: 'status', title: '企业状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<div className="table-actions">
<Button
onClick={() => navigate(`${basePath}/${record.id}/edit`)}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => setConfirmAction({ type: 'toggle', record })}
size="sm"
variant={record.status === 'active' ? 'danger' : 'secondary'}
>
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost"></Button>
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => setConfirmAction({ type: 'delete', record })}
size="sm"
variant="danger"
>
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', record })} size="sm" variant="danger"></Button>
</div>
),
},
];
function submitConfirmAction() {
if (!confirmAction) return;
const action = confirmAction.type === 'delete'
? adminApi.deleteTenant(confirmAction.record.id)
: adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
action.then(() => {
setConfirmAction(null);
loadData();
}).catch((failure: Error) => setError(failure.message || '企业状态更新失败'));
}
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['企业管理']} />
</div>
<Button icon={<Plus size={16} />} onClick={() => navigate(`${basePath}/new`)}>
</Button>
<div><Breadcrumb items={['企业管理']} /></div>
<Button icon={<Plus size={16} />} onClick={() => navigate(`${basePath}/new`)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<Building2 size={22} />
<div>
<span></span>
<strong>{records.length}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<TrendingUp size={22} />
<div>
<span></span>
<strong>{activeCount}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<TrendingDown size={22} />
<div>
<span></span>
<strong>{disabledCount}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<DollarSign size={22} />
<div>
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{(totalBalance / 100).toLocaleString('zh-CN')}</strong><small></small></div></div>
</div>
<div className="surface ui-query-panel">
<h2></h2>
<div className="ui-query-panel__grid enterprise-query-grid">
<Input
label="企业ID"
onChange={(event) => setQueryId(event.target.value)}
placeholder="请输入企业ID"
value={queryId}
/>
<Input
label="企业名称"
onChange={(event) => setQueryName(event.target.value)}
placeholder="请输入企业名称"
value={queryName}
/>
<Select
label="企业状态"
onChange={(event) => setQueryStatus(event.target.value)}
options={statusOptions}
value={queryStatus}
/>
<Input label="企业ID/编码" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID或编码" value={queryId} />
<Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} />
<Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} />
<div className="enterprise-query-actions">
<Button
onClick={() => setFilters({ id: queryId, name: queryName, status: queryStatus })}
variant="secondary"
>
</Button>
<Button
onClick={() => {
setQueryId('');
setQueryName('');
setQueryStatus('all');
setFilters({ id: '', name: '', status: 'all' });
}}
variant="ghost"
>
</Button>
<Button onClick={() => setFilters({ id: queryId, name: queryName, status: queryStatus })} variant="secondary"></Button>
<Button onClick={() => { setQueryId(''); setQueryName(''); setQueryStatus('all'); setFilters({ id: '', name: '', status: 'all' }); }} variant="ghost"></Button>
</div>
</div>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"> mock </p>
</div>
<Tag tone="info">{filteredRecords.length} </Tag>
</div>
<Table columns={columns} data={filteredRecords} rowKey="id" />
<div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{filteredRecords.length} </Tag></div>
<Table columns={columns} data={filteredRecords} emptyText="暂无企业" rowKey="id" />
</div>
{confirmAction ? (
<ConfirmModal
message={confirmAction.type === 'delete'
? `确认删除企业“${confirmAction.record.name}”吗?`
: `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
message={confirmAction.type === 'delete' ? `确认删除企业“${confirmAction.record.name}”吗?` : `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
if (confirmAction.type === 'delete') {
deleteEnterprise(confirmAction.record.id);
} else {
setRecords(toggleEnterpriseStatus(confirmAction.record.id));
}
setConfirmAction(null);
}}
onConfirm={submitConfirmAction}
/>
) : null}
</section>
+84 -155
View File
@@ -1,165 +1,78 @@
import { useMemo, useState } from 'react';
import { Filter, Pencil, Plus, Search, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
type DrainageFieldType = '字符串' | '整数' | '文件' | '网址' | '电话' | '日期';
type DrainageField = {
id: string;
name: string;
type: DrainageFieldType;
description: string;
channels: number;
channel: 'sms' | 'mms' | 'all';
type DrainageField = DictionaryItem & {
code?: string;
name?: string;
fieldType?: string;
required?: boolean;
description?: string | null;
};
const typeOptions = [
{ label: '全部类型', value: 'all' },
{ label: '字符串', value: '字符串' },
{ label: '整数', value: '整数' },
{ label: '文件', value: '文件' },
{ label: '网址', value: '网址' },
{ label: '电话', value: '电话' },
{ label: '日期', value: '日期' },
{ label: '字符串', value: 'string' },
{ label: '整数', value: 'number' },
{ label: '文件', value: 'file' },
{ label: '网址', value: 'url' },
{ label: '电话', value: 'phone' },
{ label: '日期', value: 'date' },
];
const channelOptions = [
{ label: '全部通道', value: 'all' },
{ label: '短信通道', value: 'sms' },
{ label: '彩信通道', value: 'mms' },
];
const initialFields: DrainageField[] = [
{ id: 'DRF20260630001', name: '应用ID', type: '字符串', description: '在应用集成中创建的短信应用 ID', channels: 1, channel: 'sms' },
{ id: 'DRF20260630002', name: '应用密匙', type: '字符串', description: '应用密匙或数字签名', channels: 1, channel: 'sms' },
{ id: 'DRF20260630003', name: '短信签名', type: '字符串', description: '短信签名,【】符号可省略', channels: 1, channel: 'sms' },
{ id: 'DRF20260630004', name: '短信用途', type: '整数', description: '0-行业通知短信、1-营销推广短信', channels: 1, channel: 'sms' },
{ id: 'DRF20260630005', name: '证明材料', type: '文件', description: '上传营业执照、授权书等证明材料', channels: 1, channel: 'all' },
{ id: 'DRF20260630006', name: '引流链接', type: '网址', description: '短信内链接地址', channels: 2, channel: 'all' },
{ id: 'DRF20260630007', name: '引流号码', type: '电话', description: '引流号码1', channels: 3, channel: 'sms' },
{ id: 'DRF20260630008', name: '机主姓名', type: '字符串', description: '引流号码1机主姓名', channels: 3, channel: 'sms' },
{ id: 'DRF20260630009', name: 'ICP备案号', type: '字符串', description: '域名ICP备案号', channels: 1, channel: 'all' },
{ id: 'DRF20260630010', name: '拨测日期', type: '日期', description: '引流号码拨测日期', channels: 2, channel: 'sms' },
];
function createFieldId() {
return `DRF${Date.now()}`;
}
type FieldFormModalProps = {
item?: DrainageField;
onClose: () => void;
onSubmit: (item: DrainageField) => void;
};
function FieldFormModal({ item, onClose, onSubmit }: FieldFormModalProps) {
const [form, setForm] = useState<DrainageField>(() => item ?? {
id: createFieldId(),
name: '',
type: '字符串',
description: '',
channels: 1,
channel: 'sms',
});
function updateField<Key extends keyof DrainageField>(key: Key, value: DrainageField[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
open
title={item ? '编辑报备字段' : '添加报备字段'}
>
<div className="admin-system-modal-form">
<Input label="字段名称" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
<Select
label="字段类型"
onChange={(event) => updateField('type', event.target.value as DrainageFieldType)}
options={typeOptions.filter((option) => option.value !== 'all')}
value={form.type}
/>
<Select
label="适用通道"
onChange={(event) => updateField('channel', event.target.value as DrainageField['channel'])}
options={channelOptions}
value={form.channel}
/>
<Input label="使用通道数" min={1} onChange={(event) => updateField('channels', Number(event.target.value) || 1)} type="number" value={form.channels} />
<Textarea
className="admin-system-modal-form__wide"
label="描述"
onChange={(event) => updateField('description', event.target.value)}
rows={4}
value={form.description}
/>
</div>
</Modal>
);
}
export function AdminDrainageFieldsPage() {
const [fields, setFields] = useState(initialFields);
const [fields, setFields] = useState<DrainageField[]>([]);
const [keyword, setKeyword] = useState('');
const [channel, setChannel] = useState('all');
const [type, setType] = useState('all');
const [editingField, setEditingField] = useState<DrainageField | null>(null);
const [creating, setCreating] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [fieldType, setFieldType] = useState('string');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listDrainageFields()
.then((items) => {
setFields(items as DrainageField[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备字段加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredFields = useMemo(
() => fields.filter((field) => {
const matchesKeyword = [field.name, field.type, field.description].some((value) => value.includes(keyword));
const matchesChannel = channel === 'all' || field.channel === channel || field.channel === 'all';
const matchesType = type === 'all' || field.type === type;
return matchesKeyword && matchesChannel && matchesType;
const matchesKeyword = !keyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(keyword));
const matchesType = type === 'all' || field.fieldType === type;
return matchesKeyword && matchesType;
}),
[channel, fields, keyword, type],
[fields, keyword, type],
);
function upsertField(nextField: DrainageField) {
setFields((current) => {
const exists = current.some((item) => item.id === nextField.id);
if (exists) {
return current.map((item) => (item.id === nextField.id ? nextField : item));
}
return [nextField, ...current];
});
setEditingField(null);
setCreating(false);
function createField() {
adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' })
.then(() => {
setCode('');
setName('');
setFieldType('string');
setDescription('');
setCreating(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
}
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
{ key: 'name', title: '字段名称', width: '190px', render: (record) => <strong>{record.name}</strong> },
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{record.type}</span> },
{ key: 'description', title: '描述', render: (record) => record.description },
{ key: 'channels', title: '使用通道数', width: '170px', render: (record) => <Tag>{record.channels} </Tag> },
{
key: 'actions',
title: '操作',
width: '140px',
align: 'right',
render: (record) => (
<div className="admin-drainage-actions">
<Button icon={<Pencil size={17} />} onClick={() => setEditingField(record)} size="sm" variant="ghost"></Button>
<Button
icon={<Trash2 size={17} />}
onClick={() => setFields((current) => current.filter((item) => item.id !== record.id))}
size="sm"
variant="danger"
>
</Button>
</div>
),
},
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{record.fieldType}</span> },
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
], []);
return (
@@ -170,31 +83,47 @@ export function AdminDrainageFieldsPage() {
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-drainage-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
<Select
onChange={(event) => setChannel(event.target.value)}
options={channelOptions}
value={channel}
/>
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}></Button>
</div>
<div className="surface admin-system-table-card admin-drainage-table-card">
<Table columns={columns} data={filteredFields} emptyText="暂无字段" rowKey="id" />
<div className="admin-drainage-pagination">
<span>10/</span>
<Button icon={<Filter size={16} />} iconOnly variant="ghost"></Button>
<Button size="sm">1</Button>
<span>...</span>
<Button size="sm" variant="ghost">1</Button>
</div>
</div>
{creating ? <FieldFormModal onClose={() => setCreating(false)} onSubmit={upsertField} /> : null}
{editingField ? <FieldFormModal item={editingField} onClose={() => setEditingField(null)} onSubmit={upsertField} /> : null}
<Modal
footer={(
<>
<Button onClick={() => setCreating(false)} variant="ghost"></Button>
<Button disabled={!code || !name} onClick={createField}></Button>
</>
)}
onClose={() => setCreating(false)}
open={creating}
title="添加报备字段"
>
<div className="admin-system-modal-form">
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} />
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select
label="字段类型"
onChange={(event) => setFieldType(event.target.value)}
options={typeOptions.filter((option) => option.value !== 'all')}
value={fieldType}
/>
<Textarea
className="admin-system-modal-form__wide"
label="描述"
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
</Modal>
</section>
);
}
+12 -11
View File
@@ -43,13 +43,6 @@ const statusToneMap: Record<EnterpriseAuditStatus, 'warning' | 'success' | 'dang
rejected: 'danger',
};
const initialEnterpriseAudits: EnterpriseAuditRecord[] = [
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', legalPerson: '赵明', registeredAddress: '北京市朝阳区望京东路 88 号', businessLicense: 'business-license-20260319-001.pdf', bankAccountName: '北京星云科技有限公司', bankName: '招商银行北京望京支行', bankAccountNo: '6214 **** **** 1028', verificationAmount: '0.23 元', contactName: '张伟', contactPhone: '13800138000', contactEmail: 'zhangwei@nebula.example.com', submittedAt: '2026-03-19 10:23:45', reviewRemark: '待核验营业执照与对公打款流水。', status: 'pending' },
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', legalPerson: '周海', registeredAddress: '上海市浦东新区张江路 66 号', businessLicense: 'business-license-20260319-002.pdf', bankAccountName: '上海蓝海科技有限公司', bankName: '建设银行上海张江支行', bankAccountNo: '6227 **** **** 3319', verificationAmount: '0.18 元', contactName: '李娜', contactPhone: '13900139000', contactEmail: 'lina@blueocean.example.com', submittedAt: '2026-03-18 15:45:12', reviewRemark: '联系人授权书已上传,等待人工复核。', status: 'pending' },
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', legalPerson: '黄杰', registeredAddress: '广州市天河区体育西路 118 号', businessLicense: 'business-license-20260318-001.pdf', bankAccountName: '广州飞跃文化传媒有限公司', bankName: '工商银行广州天河支行', bankAccountNo: '6202 **** **** 7750', verificationAmount: '0.31 元', contactName: '王强', contactPhone: '13700137000', contactEmail: 'wangqiang@feiyue.example.com', submittedAt: '2026-03-17 09:12:30', reviewRemark: '资料一致,对公验证通过。', status: 'approved' },
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', legalPerson: '林越', registeredAddress: '深圳市前海深港合作区梦海大道 1 号', businessLicense: 'business-license-20260317-001.pdf', bankAccountName: '深圳前海贸易有限公司', bankName: '中国银行深圳前海支行', bankAccountNo: '6216 **** **** 8901', verificationAmount: '0.12 元', contactName: '陈杰', contactPhone: '13600136000', contactEmail: 'chenjie@qianhai.example.com', submittedAt: '2026-03-16 11:30:22', reviewRemark: '营业执照主体与对公账户户名不一致,请重新提交。', status: 'rejected' },
];
function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecord {
const materials = record.materials ?? {};
return {
@@ -75,13 +68,20 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
export function AdminEnterpriseAuditPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [records, setRecords] = useState(initialEnterpriseAudits);
const [records, setRecords] = useState<EnterpriseAuditRecord[]>([]);
const [error, setError] = useState('');
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
useEffect(() => {
adminApi.listEnterpriseCertifications({ keyword, status })
.then((items) => setRecords(items.map(mapCertification)))
.catch(() => undefined);
.then((items) => {
setRecords(items.map(mapCertification));
setError('');
})
.catch((failure: Error) => {
setRecords([]);
setError(failure.message || '企业认证审核数据加载失败');
});
}, [keyword, status]);
const filteredRecords = useMemo(
@@ -135,6 +135,7 @@ export function AdminEnterpriseAuditPage() {
return (
<section className="page-stack admin-audit-page">
<Breadcrumb items={['审核中心', '企业认证审核']} />
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--enterprise">
<Input label="企业名称/信用代码" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入企业名称或统一社会信用代码" value={keyword} />
@@ -146,7 +147,7 @@ export function AdminEnterpriseAuditPage() {
</div>
</div>
<div className="surface audit-table-card">
<Table columns={columns} data={filteredRecords} rowKey="id" />
<Table columns={columns} data={filteredRecords} emptyText="暂无企业认证审核记录" rowKey="id" />
<div className="audit-pagination">
<span> {filteredRecords.length} </span>
<Button disabled icon={<FileSearch size={16} />} size="sm" variant="ghost"></Button>
+52 -59
View File
@@ -1,84 +1,73 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem, type TenantOption } from '@/api/adminApi';
type EnterpriseBlacklistItem = {
id: string;
enterprise: string;
application: string;
phone: string;
createdAt: string;
reason: string;
expiredAt: string;
type EnterpriseBlacklistItem = DictionaryItem & {
tenantId?: string;
tenant?: TenantOption;
phoneNumber?: string;
reason?: string | null;
};
const initialItems: EnterpriseBlacklistItem[] = [
{ id: 'EBL20260630001', enterprise: '四川骠骑企业管理', application: '营销应用1', phone: '13675569095', createdAt: '2026-06-28 10:12:05', reason: '用户回复退订', expiredAt: '2026-12-28 23:59:59' },
{ id: 'EBL20260630002', enterprise: '重庆进载数智', application: '通知应用', phone: '18607638087', createdAt: '2026-06-27 15:34:22', reason: '投诉拦截', expiredAt: '2026-09-27 23:59:59' },
{ id: 'EBL20260630003', enterprise: '超感世纪三三网', application: '推广应用2', phone: '15250668026', createdAt: '2026-06-25 09:18:41', reason: '运营手动加入', expiredAt: '2026-08-25 23:59:59' },
{ id: 'EBL20260630004', enterprise: '重庆香惠慧', application: '客服应用', phone: '18800000555', createdAt: '2026-06-24 18:01:10', reason: '敏感投诉号码', expiredAt: '2026-07-24 23:59:59' },
];
function nowText() {
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
}
export function AdminEnterpriseBlacklistPage() {
const [items, setItems] = useState(initialItems);
const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('');
const [application, setApplication] = useState('');
const [tenantId, setTenantId] = useState('');
const [phone, setPhone] = useState('');
const [reason, setReason] = useState('');
const [expiredAt, setExpiredAt] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState('');
function loadData() {
Promise.all([adminApi.listEnterpriseBlacklist({ keyword }), adminApi.listTenants()])
.then(([blacklist, tenantItems]) => {
setItems(blacklist as EnterpriseBlacklistItem[]);
setTenants(tenantItems);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业黑名单加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.enterprise, item.application, item.phone, item.reason].join(' ');
const text = [item.tenant?.name, item.phoneNumber, item.reason, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.enterprise}</strong> },
{ key: 'application', title: '应用名称', width: '150px', render: (record) => record.application },
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phone}</strong> },
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt },
{ key: 'reason', title: '入库原因', render: (record) => record.reason },
{ key: 'expiredAt', title: '过期时间', width: '170px', render: (record) => record.expiredAt },
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
{ key: 'status', title: '状态', width: '110px', render: (record) => record.status ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
</Button>
),
},
], []);
function resetForm() {
setEnterprise('');
setApplication('');
setPhone('');
setReason('');
setExpiredAt('');
}
function addItem() {
const nextItem: EnterpriseBlacklistItem = {
id: `EBL${Date.now()}`,
enterprise: enterprise || '未命名企业',
application: application || '默认应用',
phone: phone || '待补充号码',
createdAt: nowText(),
reason: reason || '运营手动加入',
expiredAt: expiredAt || '永久有效',
};
setItems((current) => [nextItem, ...current]);
resetForm();
setModalOpen(false);
adminApi.createEnterpriseBlacklist({ tenantId, phoneNumber: phone, reason, status: 'active' })
.then(() => {
setTenantId('');
setPhone('');
setReason('');
setModalOpen(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '添加企业黑名单失败'));
}
return (
@@ -90,17 +79,18 @@ export function AdminEnterpriseBlacklistPage() {
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input
label="搜索"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、应用、手机号或原因"
placeholder="搜索企业、手机号或原因"
prefix={<Search size={16} />}
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
</div>
</div>
@@ -113,7 +103,7 @@ export function AdminEnterpriseBlacklistPage() {
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button onClick={addItem}></Button>
<Button disabled={!tenantId || !phone} onClick={addItem}></Button>
</>
)}
onClose={() => setModalOpen(false)}
@@ -121,10 +111,13 @@ export function AdminEnterpriseBlacklistPage() {
title="添加企业黑名单"
>
<div className="admin-security-form">
<Input label="企业名称" onChange={(event) => setEnterprise(event.target.value)} placeholder="请输入企业名称" value={enterprise} />
<Input label="应用名称" onChange={(event) => setApplication(event.target.value)} placeholder="请输入应用名称" value={application} />
<Select
label="企业"
onChange={(event) => setTenantId(event.target.value)}
options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={tenantId}
/>
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
</div>
</Modal>
+35 -589
View File
@@ -1,607 +1,53 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
type SignatureKind = 'sms' | 'mms';
type DrainageInfo = {
id: string;
siteName: string;
url: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
submittedAt: string;
remark: string;
};
type SignatureItem = {
id: string;
name: string;
enterprise: string;
application: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
drainage?: DrainageInfo[];
updatedAt: string;
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
filing: '待报备',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
filing: 'neutral',
};
const statusOptions = [
{ label: '已通过', value: 'approved' },
{ label: '审核中', value: 'pending' },
{ label: '已驳回', value: 'rejected' },
{ label: '待报备', value: 'filing' },
];
const initialSmsSignatures: SignatureItem[] = [
{
id: 'sig-1',
name: '【科技公司】',
enterprise: '上海XXXXX科技有限公司',
application: '营销推广平台',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
updatedAt: '2026-01-08 11:00:00',
drainage: [
{ id: 'drain-1', siteName: '官网入口', url: 'https://www.example.com', mobile: 'approved', unicom: 'approved', telecom: 'approved', submittedAt: '2026-01-08 11:00:00', remark: '官网首页引流链接,三网报备通过。' },
{ id: 'drain-2', siteName: '促销活动页', url: 'https://sale.example.com', mobile: 'approved', unicom: 'pending', telecom: 'pending', submittedAt: '2026-01-09 10:30:00', remark: '活动页待联通、电信回执。' },
],
},
{
id: 'sig-2',
name: '【客户服务】',
enterprise: '重庆进载数智',
application: '客户服务系统',
mobile: 'approved',
unicom: 'pending',
telecom: 'approved',
updatedAt: '2026-01-09 14:20:00',
drainage: [
{ id: 'drain-3', siteName: '客户服务中心', url: 'https://service.example.com', mobile: 'approved', unicom: 'filing', telecom: 'pending', submittedAt: '2026-01-09 14:20:00', remark: '服务入口链接报备中。' },
],
},
{
id: 'sig-3',
name: '【促销活动】',
enterprise: '超感世纪互三网',
application: '营销推广平台',
mobile: 'rejected',
unicom: 'approved',
telecom: 'pending',
updatedAt: '2026-01-07 10:00:00',
drainage: [
{ id: 'drain-4', siteName: '促销专区', url: 'https://sale.example.com', mobile: 'rejected', unicom: 'approved', telecom: 'approved', submittedAt: '2026-01-07 10:00:00', remark: '移动侧驳回,需补充页面备案信息。' },
],
},
];
const initialMmsSignatures: SignatureItem[] = [
{ id: 'mms-sig-1', name: '【活动推广】', enterprise: '上海XXXXX科技有限公司', application: '营销活动彩信', mobile: 'approved', unicom: 'approved', telecom: 'approved', updatedAt: '2026-01-12 09:10:00' },
{ id: 'mms-sig-2', name: '【节日祝福】', enterprise: '重庆进载数智', application: '节日祝福彩信', mobile: 'approved', unicom: 'pending', telecom: 'approved', updatedAt: '2026-01-13 16:35:00' },
{ id: 'mms-sig-3', name: '【优品发布】', enterprise: '四川骠骑企业管理', application: '营销活动彩信', mobile: 'pending', unicom: 'pending', telecom: 'pending', updatedAt: '2026-01-15 10:28:00' },
];
function StatusTag({ status }: { status: CarrierStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
}
function paginate<T>(items: T[], page: number, pageSize: number) {
return items.slice((page - 1) * pageSize, page * pageSize);
}
function Pagination({ page, pageSize, total, onPageChange }: { page: number; pageSize: number; total: number; onPageChange: (page: number) => void }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="admin-split-pagination">
<span>{pageSize}/</span>
<Button disabled={page <= 1} icon={<ChevronLeft size={16} />} iconOnly onClick={() => onPageChange(page - 1)} variant="ghost"></Button>
<Button size="sm">{page}</Button>
<span>/ {totalPages}</span>
<Button disabled={page >= totalPages} icon={<ChevronRight size={16} />} iconOnly onClick={() => onPageChange(page + 1)} variant="ghost"></Button>
</div>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
function SignatureUploadBox({ label, compact = false }: { label: string; compact?: boolean }) {
return (
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
<span>{label}</span>
<Upload size={compact ? 30 : 42} />
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
{!compact ? <small> PNGJPGJPEG 3M</small> : null}
</div>
);
}
function SignatureFormModal({
item,
kind,
onClose,
onSubmit,
}: {
item?: SignatureItem;
kind: SignatureKind;
onClose: () => void;
onSubmit: (item: SignatureItem) => void;
}) {
const [name, setName] = useState(item?.name ?? '');
const [enterprise, setEnterprise] = useState(item?.enterprise ?? '');
const [application, setApplication] = useState(item?.application ?? '');
const [mobile, setMobile] = useState<CarrierStatus>(item?.mobile ?? 'filing');
const [unicom, setUnicom] = useState<CarrierStatus>(item?.unicom ?? 'filing');
const [telecom, setTelecom] = useState<CarrierStatus>(item?.telecom ?? 'filing');
function submit() {
onSubmit({
id: item?.id ?? `${kind}-sig-${Date.now()}`,
name,
enterprise,
application,
mobile,
unicom,
telecom,
drainage: kind === 'sms' ? (item?.drainage ?? []) : undefined,
updatedAt: '2026-06-30 10:00:00',
});
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={submit}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={(
<div className="signature-modal-title">
<h2>{item ? '编辑签名' : '添加签名'}</h2>
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
</div>
)}
>
<div className="signature-form">
<section>
<h3></h3>
<div className="signature-alert">
<Info size={18} />
<span>使 PNGJPG JPEG 3M</span>
</div>
<div className="signature-form-grid">
<Select
defaultValue={item ? 'company' : ''}
label="* 签名依据"
options={[
{ label: '请选择签名依据', value: '' },
{ label: '企事业单位证明', value: 'company' },
{ label: '商标注册证', value: 'trademark' },
{ label: '授权委托书', value: 'authorization' },
]}
/>
<Input label="* 短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" value={name} />
</div>
<SignatureUploadBox label="* 资质凭证" />
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 公司名称" onChange={(event) => setEnterprise(event.target.value)} placeholder="请输入公司名称" value={enterprise} />
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
<SignatureUploadBox compact label="法人身份证照片-人像面" />
<SignatureUploadBox compact label="法人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 应用名称" onChange={(event) => setApplication(event.target.value)} placeholder="请输入应用名称" value={application} />
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
<Input className="signature-form-grid__wide" label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
<SignatureUploadBox compact label="责任人身份证照片-人像面" />
<SignatureUploadBox compact label="责任人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Select label="移动状态" onChange={(event) => setMobile(event.target.value as CarrierStatus)} options={statusOptions} value={mobile} />
<Select label="联通状态" onChange={(event) => setUnicom(event.target.value as CarrierStatus)} options={statusOptions} value={unicom} />
<Select label="电信状态" onChange={(event) => setTelecom(event.target.value as CarrierStatus)} options={statusOptions} value={telecom} />
</div>
</section>
</div>
</Modal>
);
}
function SignatureReportModal({ item, onClose }: { item: SignatureItem; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情">
<div className="admin-report-detail">
<div className="detail-grid">
<div><span></span><strong>{item.enterprise}</strong></div>
<div><span></span><strong>{item.application}</strong></div>
<div><span></span><strong>{item.name}</strong></div>
<div><span></span><strong>{item.updatedAt}</strong></div>
</div>
<div className="admin-report-tabs">
<button className="admin-report-carrier--mobile active" type="button"><strong></strong><span><StatusTag status={item.mobile} /></span></button>
<button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><StatusTag status={item.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><StatusTag status={item.telecom} /></span></button>
</div>
</div>
</Modal>
);
}
function DrainageFormModal({
item,
onClose,
onSubmit,
}: {
item?: DrainageInfo;
onClose: () => void;
onSubmit: (item: DrainageInfo) => void;
}) {
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
id: `drain-${Date.now()}`,
siteName: '',
url: '',
mobile: 'filing',
unicom: 'filing',
telecom: 'filing',
submittedAt: '2026-06-30 10:00:00',
remark: '',
});
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={item ? '编辑引流链接' : '添加引流链接'}
>
<div className="signature-form drainage-edit-form">
<section>
<h3></h3>
<Input
label="* 引流信息"
onChange={(event) => update('url', event.target.value)}
placeholder="请输入引流网址"
value={form.url}
/>
<div className="signature-alert drainage-form-note">
<Info size={18} />
<ol>
<li></li>
<li> PNGJPG JPEG 3M</li>
<li> pdf 10M</li>
</ol>
</div>
<div className="signature-form-grid">
<SignatureUploadBox compact label="* 字段名称1" />
<Input label="* 字段名称2" placeholder="请输入字段2内容" />
<Input label="* 字段名称3" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入公司名称" value={form.siteName} />
<Input label="字段名称4" placeholder="请输入统一社会信用代码" />
<Input label="* 字段名称5" placeholder="请输入法人姓名" />
<Input label="字段名称6" placeholder="请输入法人身份证号" />
<div className="drainage-file-picker">
<span>7:</span>
<div>
<Button size="sm"></Button>
<em></em>
</div>
</div>
<Input label="* 字段名称8" placeholder="请输入责任人身份证号" />
<Input label="* 字段名称9" placeholder="请输入责任人姓名" />
<Input label="* 字段名称10" placeholder="请输入责任人手机号" />
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
</section>
</div>
</Modal>
);
}
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><StatusTag status={item.mobile} /></div>
<div><span></span><StatusTag status={item.unicom} /></div>
<div><span></span><StatusTag status={item.telecom} /></div>
<div><span></span><strong>{item.submittedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
</Modal>
);
}
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
export function AdminEnterpriseSignaturesPage() {
const [activeTab, setActiveTab] = useState<SignatureKind>('sms');
const [smsSignatures, setSmsSignatures] = useState(initialSmsSignatures);
const [mmsSignatures, setMmsSignatures] = useState(initialMmsSignatures);
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [page, setPage] = useState(1);
const [signatureModal, setSignatureModal] = useState<{ kind: SignatureKind; item?: SignatureItem } | null>(null);
const [signatureReport, setSignatureReport] = useState<SignatureItem | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; signatureKind: SignatureKind; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const pageSize = 2;
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const filteredSmsSignatures = useMemo(
() => smsSignatures.filter((item) => (
(!enterpriseKeyword || item.enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || item.application.includes(signatureKeyword))
)),
[enterpriseKeyword, signatureKeyword, smsSignatures],
);
const filteredMmsSignatures = useMemo(
() => mmsSignatures.filter((item) => (
(!enterpriseKeyword || item.enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || item.application.includes(signatureKeyword))
)),
[enterpriseKeyword, signatureKeyword, mmsSignatures],
);
const pagedSmsSignatures = paginate(filteredSmsSignatures, page, pageSize);
const pagedMmsSignatures = paginate(filteredMmsSignatures, page, pageSize);
function upsertSignature(kind: SignatureKind, nextItem: SignatureItem) {
const setter = kind === 'sms' ? setSmsSignatures : setMmsSignatures;
setter((current) => current.some((item) => item.id === nextItem.id)
? current.map((item) => item.id === nextItem.id ? nextItem : item)
: [nextItem, ...current]);
setSignatureModal(null);
function loadData() {
adminApi.listEnterpriseSignatures({ keyword })
.then((items) => {
setSignatures(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业签名加载失败'));
}
function upsertDrainage(signatureId: string, nextItem: DrainageInfo) {
setSmsSignatures((current) => current.map((signature) => {
if (signature.id !== signatureId) {
return signature;
}
const drainage = signature.drainage ?? [];
const nextDrainage = drainage.some((item) => item.id === nextItem.id)
? drainage.map((item) => item.id === nextItem.id ? nextItem : item)
: [nextItem, ...drainage];
return { ...signature, drainage: nextDrainage };
}));
setDrainageModal(null);
setExpandedSignatureId(signatureId);
}
useEffect(() => {
loadData();
}, []);
function confirmDelete() {
if (!deleteTarget) {
return;
}
if (deleteTarget.kind === 'signature') {
const setter = deleteTarget.signatureKind === 'sms' ? setSmsSignatures : setMmsSignatures;
setter((current) => current.filter((item) => item.id !== deleteTarget.id));
} else {
setSmsSignatures((current) => current.map((signature) => signature.id === deleteTarget.signatureId
? { ...signature, drainage: (signature.drainage ?? []).filter((item) => item.id !== deleteTarget.id) }
: signature));
}
setDeleteTarget(null);
}
const filtered = useMemo(() => signatures.filter((item) => !keyword || [item.name, item.purpose, item.auditStatus].join(' ').includes(keyword)), [keyword, signatures]);
const mmsColumns = useMemo<Array<TableColumn<SignatureItem>>>(() => [
{ key: 'name', title: '签名名称', width: '170px', render: (record) => <strong>{record.name}</strong> },
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application },
{ key: 'mobile', title: '移动', width: '110px', render: (record) => <StatusTag status={record.mobile} /> },
{ key: 'unicom', title: '联通', width: '110px', render: (record) => <StatusTag status={record.unicom} /> },
{ key: 'telecom', title: '电信', width: '110px', render: (record) => <StatusTag status={record.telecom} /> },
{ key: 'updatedAt', title: '更新时间', width: '180px', render: (record) => record.updatedAt },
{
key: 'actions',
title: '操作',
align: 'right',
width: '230px',
render: (record) => (
<div className="table-actions">
<Button icon={<FileText size={15} />} onClick={() => setSignatureReport(record)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setSignatureModal({ kind: 'mms', item: record })} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'mms', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
</div>
),
},
], []);
const smsSignatureContent = (
<>
<div className="signature-list admin-enterprise-signature-list">
{pagedSmsSignatures.map((signature) => {
const expanded = expandedSignatureId === signature.id;
return (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.enterprise}</strong></div>
<div><span></span><strong>{signature.application}</strong></div>
<div><span></span><StatusTag status={signature.mobile} /></div>
<div><span></span><StatusTag status={signature.unicom} /></div>
<div><span></span><StatusTag status={signature.telecom} /></div>
<div><span></span><strong>{signature.drainage?.length ?? 0} </strong></div>
<div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ kind: 'sms', item: signature })} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button>
</div>
</div>
{expanded ? (
<div className="drainage-panel">
<h2></h2>
{signature.drainage?.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{signature.drainage.map((item) => (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<a href={item.url}>{item.url}</a>
<StatusTag status={item.mobile} />
<StatusTag status={item.unicom} />
<StatusTag status={item.telecom} />
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button>
</span>
</div>
))}
</div>
) : (
<p className="muted"></p>
)}
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost"></Button>
</div>
</div>
) : null}
</article>
);
})}
</div>
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredSmsSignatures.length} />
</>
);
const columns: Array<TableColumn<ClientSmsSignature>> = [
{ key: 'name', title: '签名名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
{ key: 'purpose', title: '用途', render: (record) => record.purpose ?? '-' },
{ key: 'materials', title: '材料', render: (record) => `${record.materials?.length ?? 0}` },
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
];
return (
<section className="page-stack admin-customer-split-page">
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['客户管理', '企业签名管理']} />
<h1></h1>
<Breadcrumb items={['企业配置', '企业签名']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal({ kind: activeTab })}></Button>
</div>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => { setEnterpriseKeyword(event.target.value); setPage(1); }} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="签名/应用" onChange={(event) => { setSignatureKeyword(event.target.value); setPage(1); }} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); setPage(1); }} variant="ghost"></Button>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名、用途或状态" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={loadData}></Button>
</div>
<div className="surface section-stack">
<Tabs
onChange={(value) => { setActiveTab(value as SignatureKind); setPage(1); }}
value={activeTab}
items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
{
label: '彩信签名',
pending: true,
value: 'mms',
content: (
<>
<Table columns={mmsColumns} data={pagedMmsSignatures} rowKey="id" />
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredMmsSignatures.length} />
</>
),
},
]}
/>
<div className="surface">
<Table columns={columns} data={filtered} emptyText="暂无企业签名" rowKey="id" />
</div>
{signatureModal ? (
<SignatureFormModal
item={signatureModal.item}
kind={signatureModal.kind}
onClose={() => setSignatureModal(null)}
onSubmit={(item) => upsertSignature(signatureModal.kind, item)}
/>
) : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{drainageModal ? (
<DrainageFormModal
item={drainageModal.item}
onClose={() => setDrainageModal(null)}
onSubmit={(item) => upsertDrainage(drainageModal.signatureId, item)}
/>
) : null}
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后仅影响当前本地 mock 数据。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
/>
) : null}
</section>
);
}
+35 -612
View File
@@ -1,630 +1,53 @@
import { useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Edit3, Eye, FileText, ImageIcon, Info, Music, Plus, Search, Trash2, Video } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
type TemplateKind = 'sms' | 'mms';
type SmsTemplate = {
id: string;
name: string;
enterprise: string;
application: string;
hash: string;
content: string;
variables: string[];
updatedAt: string;
accent: 'green' | 'blue' | 'red';
};
type MmsTemplate = {
id: string;
name: string;
enterprise: string;
application: string;
code: string;
title: string;
image: string;
content: string;
status: 'approved' | 'pending' | 'rejected';
updatedAt: string;
accent: 'green' | 'blue' | 'gray';
frames?: MmsFrame[];
};
type FrameType = 'text' | 'image' | 'video' | 'audio';
type MmsFrame = {
id: string;
type: FrameType;
text?: string;
};
const initialSmsTemplates: SmsTemplate[] = [
{ id: 'tpl-1', name: '喜约领券', enterprise: '上海XXXXX科技有限公司', application: '营销推广平台', hash: '1c37fb4a7c4a4a63', content: '【喜约领券】尊宾您好!您于${time}备存物品(${item})过期时间${expireTime}。', variables: ['time', 'item', 'expireTime'], updatedAt: '2026-01-04 17:45:36', accent: 'green' },
{ id: 'tpl-2', name: '南通仲裁委', enterprise: '重庆进载数智', application: '客户服务系统', hash: '8e0a32c9d7b14c6a', content: '【南通仲裁委】尊敬的仲裁员,${caseNumber}号件请前往小程序或PC端查看本案信息。', variables: ['caseNumber'], updatedAt: '2026-01-04 17:45:36', accent: 'blue' },
{ id: 'tpl-3', name: '派件通知', enterprise: '超感世纪互三网', application: '验证码服务', hash: '6e9f23a4b5c7d8e1', content: '【派件通知】${name}您的快递已到达${station},请保持电话畅通。', variables: ['name', 'station'], updatedAt: '2026-01-04 11:20:18', accent: 'red' },
];
const initialMmsTemplates: MmsTemplate[] = [
{ id: 'mms-tpl-1', name: '春节祝福', enterprise: '上海XXXXX科技有限公司', application: '营销活动彩信', code: 'MMS_1a2b3c4d5e6f', title: '新春佳节,福气满满', image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80', content: '【活动推广】尊敬的客户,新春佳节来临之际,祝您新春快乐,万事如意!', status: 'approved', updatedAt: '2026-01-15 10:30:00', accent: 'green', frames: [{ id: 'mms-frame-1', type: 'text' }, { id: 'mms-frame-2', type: 'image' }] },
{ id: 'mms-tpl-2', name: '新品发布', enterprise: '重庆进载数智', application: '营销活动彩信', code: 'MMS_2b3c4d5e6f7a', title: '重磅新品震撼来袭', image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80', content: '【新品发布】优品商城重磅新品无线蓝牙耳机震撼来袭!', status: 'pending', updatedAt: '2026-01-16 14:20:00', accent: 'blue', frames: [{ id: 'mms-frame-3', type: 'text' }, { id: 'mms-frame-4', type: 'image' }] },
{ id: 'mms-tpl-3', name: '促销活动', enterprise: '超感世纪互三网', application: '节日祝福彩信', code: 'MMS_6f7a8b9c0d1e', title: '限时抢购,低至3折', image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80', content: '【活动推广】年中大促火热进行中!精选商品限时抢购。', status: 'rejected', updatedAt: '2026-01-11 15:30:00', accent: 'gray', frames: [{ id: 'mms-frame-5', type: 'video' }, { id: 'mms-frame-6', type: 'text' }] },
];
const frameTypeOptions = [
{ label: '文字', value: 'text' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
];
const frameIconMap: Record<FrameType, typeof FileText> = {
text: FileText,
image: ImageIcon,
video: Video,
audio: Music,
};
const frameFormatMap: Record<FrameType, string> = {
text: '',
image: '支持格式:jpg, jpeg, png, gif',
video: '支持格式:mp4, mpg, 3gp, 3gpp',
audio: '支持格式:mp3, mpeg3',
};
const applicationOptions = [
{ label: '请选择', value: '' },
{ label: '营销推广平台', value: '营销推广平台' },
{ label: '客户服务系统', value: '客户服务系统' },
{ label: '验证码服务', value: '验证码服务' },
];
const signatureOptions = [
{ label: '请选择', value: '' },
{ label: '【科技公司】', value: '【科技公司】' },
{ label: '【客户服务】', value: '【客户服务】' },
{ label: '【促销活动】', value: '【促销活动】' },
];
const recommendedVariables = [
['验证码', 'code'],
['手机号', 'phone'],
['姓名', 'name'],
['日期', 'date'],
['金额', 'amount'],
['时间', 'time'],
['余额', 'balance'],
['地址', 'address'],
['天数', 'days'],
['快递单号', 'trackingNumber'],
['案件号', 'caseNumber'],
['课程名称', 'courseName'],
['链接', 'link'],
['站点', 'station'],
];
const statusToneMap = {
approved: 'success',
pending: 'info',
rejected: 'danger',
} as const;
const statusLabelMap = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
};
function extractVariables(content: string) {
return Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]);
}
function paginate<T>(items: T[], page: number, pageSize: number) {
return items.slice((page - 1) * pageSize, page * pageSize);
}
function Pagination({ page, pageSize, total, onPageChange }: { page: number; pageSize: number; total: number; onPageChange: (page: number) => void }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="admin-split-pagination">
<span>{pageSize}/</span>
<Button disabled={page <= 1} icon={<ChevronLeft size={16} />} iconOnly onClick={() => onPageChange(page - 1)} variant="ghost"></Button>
<Button size="sm">{page}</Button>
<span>/ {totalPages}</span>
<Button disabled={page >= totalPages} icon={<ChevronRight size={16} />} iconOnly onClick={() => onPageChange(page + 1)} variant="ghost"></Button>
</div>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
function SmsTemplateModal({ item, onClose, onSubmit }: { item?: SmsTemplate; onClose: () => void; onSubmit: (item: SmsTemplate) => void }) {
const [application, setApplication] = useState(item?.application ?? '');
const [signature, setSignature] = useState('');
const [content, setContent] = useState(item?.content ?? '');
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const [error, setError] = useState('');
const variables = extractVariables(content);
const wordCount = content.length;
const billingCount = Math.max(1, Math.ceil(wordCount / 70));
function insertVariable(name: string) {
if (!name.trim()) {
return;
}
setContent((current) => `${current}\${${name.trim()}}`);
}
function submit() {
if (!application || !signature || !content.trim()) {
setError('请填写应用、签名和模板内容');
return;
}
onSubmit({
id: item?.id ?? `tpl-${Date.now()}`,
name: content.replace(/^【([^】]+)】.*$/, '$1').slice(0, 10) || '新建模板',
enterprise: item?.enterprise ?? '上海XXXXX科技有限公司',
application,
hash: item?.hash ?? Math.random().toString(16).slice(2, 18),
content,
variables,
updatedAt: '2026-06-30 10:00:00',
accent: item?.accent ?? 'green',
});
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={submit}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{item ? '编辑模板' : '添加模板'}</h2><p></p></div>}
>
<div className="template-form">
<Select
error={error && !application ? '请选择应用' : undefined}
label="* 应用:"
onChange={(event) => setApplication(event.target.value)}
options={applicationOptions}
value={application}
/>
<Select
error={error && !signature ? '请选择签名' : undefined}
label="* 签名:"
onChange={(event) => setSignature(event.target.value)}
options={signatureOptions}
value={signature}
/>
<Textarea
error={error && !content.trim() ? '请输入模板内容' : undefined}
label="* 模板内容:"
onChange={(event) => setContent(event.target.value)}
placeholder="请输入模板内容"
rows={9}
value={content}
/>
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{wordCount} {billingCount} </span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
<h3></h3>
<div className="template-variable-buttons">
{recommendedVariables.map(([label, value]) => (
<button key={value} onClick={() => insertVariable(value)} type="button">
{label} ({value})
</button>
))}
</div>
<h3></h3>
<div className="template-custom-variable">
<Input
onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable || 'custom');
setCustomVariable('');
}}
>
</Button>
</div>
</div>
) : null}
<div className="template-info-tip">
<Info size={18} />
<span>=++ 70 1 67 1 </span>
</div>
</div>
</Modal>
);
}
function FrameEditor({
frame,
index,
onRemove,
onTypeChange,
}: {
frame: MmsFrame;
index: number;
onRemove: () => void;
onTypeChange: (type: FrameType) => void;
}) {
const Icon = frameIconMap[frame.type];
return (
<div className="mms-frame">
<div className="mms-frame__top">
<strong> {index + 1} </strong>
<Select
className="mms-frame-type"
onChange={(event) => onTypeChange(event.target.value as FrameType)}
options={frameTypeOptions}
value={frame.type}
/>
<button aria-label="删除帧" onClick={onRemove} type="button">
<Trash2 size={18} />
</button>
</div>
{frame.type === 'text' ? (
<Textarea defaultValue={frame.text} placeholder="请输入文字内容" />
) : (
<div className="mms-file-drop">
<Icon size={22} />
<div>
<strong></strong>
<span></span>
</div>
<small>{frameFormatMap[frame.type]}</small>
</div>
)}
</div>
);
}
function MmsTemplateModal({ item, onClose, onSubmit }: { item?: MmsTemplate; onClose: () => void; onSubmit: (item: MmsTemplate) => void }) {
const [previewOpen, setPreviewOpen] = useState(false);
const [name, setName] = useState(item?.name ?? '');
const [enterprise] = useState(item?.enterprise ?? '上海XXXXX科技有限公司');
const [application, setApplication] = useState(item?.application ?? '营销活动彩信');
const [title, setTitle] = useState(item?.title ?? '');
const [content] = useState(item?.content ?? '这里展示当前彩信模板的文字、图片、视频或音频帧内容。');
const [frames, setFrames] = useState<MmsFrame[]>(item?.frames ?? [
{ id: 'new-mms-frame-1', type: 'text' },
{ id: 'new-mms-frame-2', type: 'image' },
]);
const totalSize = useMemo(() => {
const textSize = frames.filter((frame) => frame.type === 'text').length * 0.2;
const mediaSize = frames.filter((frame) => frame.type !== 'text').length * 180;
return Math.min(2000, textSize + mediaSize).toFixed(1);
}, [frames]);
function addFrame() {
if (frames.length >= 9) {
return;
}
setFrames((items) => [...items, { id: `new-mms-frame-${Date.now()}`, type: 'text' }]);
}
function removeFrame(id: string) {
setFrames((items) => items.filter((frame) => frame.id !== id));
}
function changeFrameType(id: string, type: FrameType) {
setFrames((items) => items.map((frame) => (frame.id === id ? { ...frame, type } : frame)));
}
function submit() {
onSubmit({
id: item?.id ?? `mms-tpl-${Date.now()}`,
name: name || '新建彩信模板',
enterprise,
application,
code: item?.code ?? `MMS_${Math.random().toString(16).slice(2, 14)}`,
title,
image: item?.image ?? 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
content,
status: item?.status ?? 'pending',
updatedAt: '2026-06-30 10:00:00',
accent: item?.accent ?? 'blue',
frames,
});
}
return (
<Modal
footer={(
<>
<Button onClick={() => setPreviewOpen(true)} variant="secondary"></Button>
<Button onClick={onClose} variant="ghost"></Button>
<Button className="mms-save-button" onClick={submit}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={(
<div className="mms-template-modal-title">
<h2>{item ? '编辑彩信模板' : '创建彩信模板'}</h2>
<p>92000KB</p>
</div>
)}
>
<div className="mms-template-form">
<div className="mms-template-form-grid">
<Input label="彩信模板名称 *" onChange={(event) => setName(event.target.value)} placeholder="春节祝福" value={name} />
<Select
label="彩信应用 *"
onChange={(event) => setApplication(event.target.value)}
options={[
{ label: '营销活动彩信', value: '营销活动彩信' },
{ label: '节日祝福彩信', value: '节日祝福彩信' },
]}
value={application}
/>
</div>
<Select
defaultValue="【活动推广】"
label="签名 *"
options={[
{ label: '【活动推广】', value: '【活动推广】' },
{ label: '【优品发布】', value: '【优品发布】' },
{ label: '【节日祝福】', value: '【节日祝福】' },
]}
/>
<Input label="彩信标题 *" onChange={(event) => setTitle(event.target.value)} placeholder="新春佳节,福气满满" value={title} />
<div className="mms-frame-header">
<div>
<strong> *</strong>
<span>{frames.length}/9 使 {totalSize}KB/2000KB</span>
</div>
<Button icon={<Plus size={16} />} onClick={addFrame} variant="ghost"></Button>
</div>
<div className="mms-frame-list">
{frames.map((frame, index) => (
<FrameEditor
frame={frame}
index={index}
key={frame.id}
onRemove={() => removeFrame(frame.id)}
onTypeChange={(type) => changeFrameType(frame.id, type)}
/>
))}
</div>
</div>
<Modal
footer={<Button onClick={() => setPreviewOpen(false)}></Button>}
onClose={() => setPreviewOpen(false)}
open={previewOpen}
size="md"
title={<div className="template-modal-title"><h2></h2><p>{name || '新建彩信模板'}</p></div>}
>
<div className="mms-preview">
{item?.image ? <img alt={item.name} src={item.image} /> : null}
<h3>{title || '新春佳节,福气满满'}</h3>
<p>{content}</p>
<div className="mms-preview-frames">
{frames.map((frame, index) => {
const Icon = frameIconMap[frame.type];
return (
<span key={frame.id}>
<Icon size={15} />
{index + 1} · {frameTypeOptions.find((option) => option.value === frame.type)?.label}
</span>
);
})}
</div>
</div>
</Modal>
</Modal>
);
}
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { adminApi, type ClientSmsTemplate } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
export function AdminEnterpriseTemplatesPage() {
const [activeTab, setActiveTab] = useState<TemplateKind>('sms');
const [smsTemplates, setSmsTemplates] = useState(initialSmsTemplates);
const [mmsTemplates, setMmsTemplates] = useState(initialMmsTemplates);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState('');
const [page, setPage] = useState(1);
const [smsModal, setSmsModal] = useState<SmsTemplate | null | undefined>(undefined);
const [mmsModal, setMmsModal] = useState<MmsTemplate | null | undefined>(undefined);
const [preview, setPreview] = useState<MmsTemplate | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ kind: TemplateKind; id: string; name: string } | null>(null);
const pageSize = 2;
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const filteredSmsTemplates = useMemo(
() => smsTemplates.filter((template) => (
(!enterpriseKeyword || template.enterprise.includes(enterpriseKeyword))
&& (!templateKeyword || template.name.includes(templateKeyword) || template.application.includes(templateKeyword) || template.content.includes(templateKeyword))
)),
[enterpriseKeyword, smsTemplates, templateKeyword],
);
const filteredMmsTemplates = useMemo(
() => mmsTemplates.filter((template) => (
(!enterpriseKeyword || template.enterprise.includes(enterpriseKeyword))
&& (!templateKeyword || template.name.includes(templateKeyword) || template.application.includes(templateKeyword) || template.title.includes(templateKeyword) || template.content.includes(templateKeyword))
)),
[enterpriseKeyword, mmsTemplates, templateKeyword],
);
const pagedSmsTemplates = paginate(filteredSmsTemplates, page, pageSize);
const pagedMmsTemplates = paginate(filteredMmsTemplates, page, pageSize);
function upsertSmsTemplate(nextTemplate: SmsTemplate) {
setSmsTemplates((current) => current.some((item) => item.id === nextTemplate.id)
? current.map((item) => item.id === nextTemplate.id ? nextTemplate : item)
: [nextTemplate, ...current]);
setSmsModal(undefined);
function loadData() {
adminApi.listEnterpriseTemplates({ keyword })
.then((items) => {
setTemplates(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业模板加载失败'));
}
function upsertMmsTemplate(nextTemplate: MmsTemplate) {
setMmsTemplates((current) => current.some((item) => item.id === nextTemplate.id)
? current.map((item) => item.id === nextTemplate.id ? nextTemplate : item)
: [nextTemplate, ...current]);
setMmsModal(undefined);
}
useEffect(() => {
loadData();
}, []);
function confirmDelete() {
if (!deleteTarget) {
return;
}
if (deleteTarget.kind === 'sms') {
setSmsTemplates((current) => current.filter((item) => item.id !== deleteTarget.id));
} else {
setMmsTemplates((current) => current.filter((item) => item.id !== deleteTarget.id));
}
setDeleteTarget(null);
}
const filtered = useMemo(() => templates.filter((item) => !keyword || [item.name, item.content, item.auditStatus, item.application?.name].join(' ').includes(keyword)), [keyword, templates]);
const smsContent = (
<>
<div className="template-card-grid">
{pagedSmsTemplates.map((template) => (
<article className={`template-card template-card--${template.accent}`} key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.enterprise} / {template.application}</p>
<p className="template-hash">{template.hash}</p>
<p className="template-content">{template.content}</p>
<div className="template-vars">
<span></span>
{template.variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)}
</div>
<div className="template-card-footer">
<span>{template.updatedAt}</span>
<div>
<button onClick={() => setSmsModal(template)} type="button"><Edit3 size={15} /></button>
<button onClick={() => setDeleteTarget({ kind: 'sms', id: template.id, name: template.name })} type="button"><Trash2 size={15} /></button>
</div>
</div>
</article>
))}
</div>
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredSmsTemplates.length} />
</>
);
const mmsContent = (
<>
<div className="mms-template-grid">
{pagedMmsTemplates.map((template) => (
<article className={`mms-template-card mms-template-card--${template.accent}`} key={template.id}>
<span className="mms-template-app-tag">{template.enterprise}</span>
<div className="mms-template-card__body">
<div className="mms-template-meta">
<h2>{template.name}</h2>
<p className="mms-template-code">{template.code}</p>
<h3>{template.title}</h3>
</div>
<img alt={template.name} src={template.image} />
<p className="mms-template-content">{template.content}</p>
<div className="mms-template-status">
<span>{template.application}</span>
<Tag tone={statusToneMap[template.status]}>{statusLabelMap[template.status]}</Tag>
</div>
</div>
<footer className="mms-template-card__footer">
<span>{template.updatedAt}</span>
<div>
<button onClick={() => setPreview(template)} type="button"><Eye size={17} /></button>
<button onClick={() => setMmsModal(template)} type="button"><Edit3 size={15} /></button>
<button onClick={() => setDeleteTarget({ kind: 'mms', id: template.id, name: template.name })} type="button"><Trash2 size={15} /></button>
</div>
</footer>
</article>
))}
</div>
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredMmsTemplates.length} />
</>
);
const columns: Array<TableColumn<ClientSmsTemplate>> = [
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'content', title: '内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
];
return (
<section className="page-stack admin-customer-split-page">
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['客户管理', '企业模板管理']} />
<h1></h1>
<Breadcrumb items={['企业配置', '企业模板']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => (activeTab === 'sms' ? setSmsModal(null) : setMmsModal(null))}></Button>
</div>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => { setEnterpriseKeyword(event.target.value); setPage(1); }} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="模板/应用" onChange={(event) => { setTemplateKeyword(event.target.value); setPage(1); }} placeholder="请输入模板、应用或内容关键词" prefix={<Search size={16} />} value={templateKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); setPage(1); }} variant="ghost"></Button>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索模板、应用、内容或状态" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={loadData}></Button>
</div>
<div className="surface section-stack">
<Tabs
onChange={(value) => { setActiveTab(value as TemplateKind); setPage(1); }}
value={activeTab}
items={[
{ label: '短信模板', value: 'sms', content: smsContent },
{ label: '彩信模板', value: 'mms', pending: true, content: mmsContent },
]}
/>
<div className="surface">
<Table columns={columns} data={filtered} emptyText="暂无企业模板" rowKey="id" />
</div>
{smsModal !== undefined ? <SmsTemplateModal item={smsModal ?? undefined} onClose={() => setSmsModal(undefined)} onSubmit={upsertSmsTemplate} /> : null}
{mmsModal !== undefined ? <MmsTemplateModal item={mmsModal ?? undefined} onClose={() => setMmsModal(undefined)} onSubmit={upsertMmsTemplate} /> : null}
<Modal
footer={<Button onClick={() => setPreview(null)}></Button>}
onClose={() => setPreview(null)}
open={Boolean(preview)}
title="彩信预览"
>
{preview ? (
<div className="mms-preview">
<img alt={preview.name} src={preview.image} />
<h3>{preview.title}</h3>
<p>{preview.content}</p>
</div>
) : null}
</Modal>
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后仅影响当前本地 mock 数据。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
/>
) : null}
</section>
);
}
+38 -46
View File
@@ -1,74 +1,66 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
type GlobalBlacklistItem = {
id: string;
phone: string;
createdAt: string;
reason: string;
expiredAt: string;
type GlobalBlacklistItem = DictionaryItem & {
phoneNumber?: string;
reason?: string | null;
};
const initialItems: GlobalBlacklistItem[] = [
{ id: 'GBL20260630001', phone: '13500000888', createdAt: '2026-06-28 11:20:05', reason: '多企业投诉号码', expiredAt: '2026-12-28 23:59:59' },
{ id: 'GBL20260630002', phone: '13755558888', createdAt: '2026-06-27 16:05:12', reason: '黑名单同步导入', expiredAt: '2026-09-27 23:59:59' },
{ id: 'GBL20260630003', phone: '18800000555', createdAt: '2026-06-26 09:44:30', reason: '监管要求拦截', expiredAt: '2027-06-26 23:59:59' },
{ id: 'GBL20260630004', phone: '15250668026', createdAt: '2026-06-25 14:12:18', reason: '高频退订', expiredAt: '2026-08-25 23:59:59' },
];
function nowText() {
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
}
export function AdminGlobalBlacklistPage() {
const [items, setItems] = useState(initialItems);
const [items, setItems] = useState<GlobalBlacklistItem[]>([]);
const [keyword, setKeyword] = useState('');
const [phone, setPhone] = useState('');
const [reason, setReason] = useState('');
const [expiredAt, setExpiredAt] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState('');
function loadData() {
adminApi.listGlobalBlacklist({ keyword })
.then((data) => {
setItems(data as GlobalBlacklistItem[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '全局黑名单加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.phone, item.reason, item.expiredAt].join(' ');
const text = [item.phoneNumber, item.reason, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => record.createdAt },
{ key: 'reason', title: '入库原因', render: (record) => record.reason },
{ key: 'expiredAt', title: '过期时间', width: '190px', render: (record) => record.expiredAt },
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteGlobalBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
</Button>
),
},
], []);
function resetForm() {
setPhone('');
setReason('');
setExpiredAt('');
}
function addItem() {
const nextItem: GlobalBlacklistItem = {
id: `GBL${Date.now()}`,
phone: phone || '待补充号码',
createdAt: nowText(),
reason: reason || '运营手动加入',
expiredAt: expiredAt || '永久有效',
};
setItems((current) => [nextItem, ...current]);
resetForm();
setModalOpen(false);
adminApi.createGlobalBlacklist({ phoneNumber: phone, reason, status: 'active' })
.then(() => {
setPhone('');
setReason('');
setModalOpen(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '添加全局黑名单失败'));
}
return (
@@ -80,17 +72,18 @@ export function AdminGlobalBlacklistPage() {
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input
label="搜索"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索手机号、原因或过期时间"
placeholder="搜索手机号、原因或状态"
prefix={<Search size={16} />}
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
</div>
</div>
@@ -103,7 +96,7 @@ export function AdminGlobalBlacklistPage() {
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button onClick={addItem}></Button>
<Button disabled={!phone} onClick={addItem}></Button>
</>
)}
onClose={() => setModalOpen(false)}
@@ -112,7 +105,6 @@ export function AdminGlobalBlacklistPage() {
>
<div className="admin-security-form">
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
</div>
</Modal>
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, RefreshCw } from 'lucide-react';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
import { getEnterpriseRecords } from './adminEnterpriseMock';
const channelOptions = {
mobile: [
@@ -26,10 +25,7 @@ function generateCode(prefix: string) {
export function AdminMmsApplicationFormPage() {
const navigate = useNavigate();
const { enterpriseId, appId } = useParams();
const enterprise = useMemo(
() => getEnterpriseRecords().find((item) => item.id === enterpriseId),
[enterpriseId],
);
const enterpriseName = enterpriseId ? `企业 ${enterpriseId}` : '当前企业';
const isEdit = Boolean(appId);
const [appName, setAppName] = useState(isEdit ? '示例彩信应用' : '');
const [unitPrice, setUnitPrice] = useState(isEdit ? '0.0300' : '');
@@ -64,7 +60,7 @@ export function AdminMmsApplicationFormPage() {
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑彩信应用' : '添加彩信应用']} />
<p>{enterprise?.name ?? '当前企业'} </p>
<p>{enterpriseName} </p>
</div>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
+42 -15
View File
@@ -1,23 +1,48 @@
import { useEffect, useMemo, useState } from 'react';
import { Activity } from 'lucide-react';
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
import { adminService, type Channel } from '@/mock';
import { adminApi, type AdminChannel } from '@/api/adminApi';
const columns: Array<TableColumn<Channel>> = [
const columns: Array<TableColumn<AdminChannel>> = [
{ key: 'id', title: '通道编号', render: (record) => record.id },
{ key: 'name', title: '通道名称', render: (record) => record.name },
{ key: 'region', title: '区域', render: (record) => record.region },
{ key: 'successRate', title: '成功率', render: (record) => `${record.successRate}%` },
{ key: 'latencyMs', title: '平均延迟', render: (record) => `${record.latencyMs} ms` },
{ key: 'carrier', title: '运营商', render: (record) => record.carrier ?? '-' },
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
{
key: 'enabled',
key: 'status',
title: '状态',
render: (record) => <Tag tone={record.enabled ? 'success' : 'danger'}>{record.enabled ? '运行中' : '已停用'}</Tag>,
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'danger'}>{record.status === 'active' ? '运行中' : '已停用'}</Tag>,
},
];
export function AdminMonitorPage() {
const channels = adminService.getChannels();
const enabledChannels = channels.filter((item) => item.enabled).length;
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadData() {
setLoading(true);
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
.then(([channelItems, monitorData]) => {
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setMonitor(monitorData);
setError('');
})
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
}, []);
const enabledChannels = channels.filter((item) => item.status === 'active').length;
const statusGroups = Array.isArray(monitor.byStatus) ? monitor.byStatus as Array<{ status: string; _count: { _all: number } }> : [];
const totalMessages = useMemo(() => statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
const deliveredMessages = useMemo(() => statusGroups.filter((item) => item.status === 'delivered').reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
const successRate = totalMessages > 0 ? ((deliveredMessages / totalMessages) * 100).toFixed(1) : '0.0';
return (
<section className="page-stack">
@@ -25,8 +50,10 @@ export function AdminMonitorPage() {
<div>
<Breadcrumb items={['发送监控']} />
</div>
<Button icon={<Activity size={16} />} variant="ghost"></Button>
<Button icon={<Activity size={16} />} onClick={loadData} variant="ghost"></Button>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span></span>
@@ -35,13 +62,13 @@ export function AdminMonitorPage() {
</div>
<div className="surface metric-card">
<span></span>
<strong>98.5%</strong>
<small> 1 </small>
<strong>{successRate}%</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>167 ms</strong>
<small></small>
<span></span>
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
<small></small>
</div>
</div>
<div className="surface">
+71 -114
View File
@@ -1,130 +1,61 @@
import { useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
type PhoneSegment = {
id: string;
segment: string;
carrier: string;
province: string;
city: string;
createdAt: string;
updatedAt: string;
type PhoneSegment = DictionaryItem & {
prefix?: string;
carrier?: string;
province?: string | null;
city?: string | null;
};
const initialSegments: PhoneSegment[] = [
{ id: 'SEG20260630001', segment: '1367556', carrier: '中国移动', province: '四川省', city: '成都市', createdAt: '2026-06-11 09:30:12', updatedAt: '2026-06-28 15:40:00' },
{ id: 'SEG20260630002', segment: '1860763', carrier: '中国联通', province: '重庆市', city: '重庆市', createdAt: '2026-06-12 10:18:44', updatedAt: '2026-06-27 11:22:13' },
{ id: 'SEG20260630003', segment: '1525066', carrier: '中国电信', province: '江苏省', city: '南京市', createdAt: '2026-06-15 14:22:31', updatedAt: '2026-06-26 17:05:39' },
{ id: 'SEG20260630004', segment: '1501234', carrier: '中国移动', province: '广东省', city: '深圳市', createdAt: '2026-06-18 16:10:25', updatedAt: '2026-06-24 09:15:26' },
];
function createSegmentId() {
return `SEG${Date.now()}`;
}
type SegmentFormModalProps = {
item?: PhoneSegment;
onClose: () => void;
onSubmit: (item: PhoneSegment) => void;
};
function SegmentFormModal({ item, onClose, onSubmit }: SegmentFormModalProps) {
const [form, setForm] = useState<PhoneSegment>(() => item ?? {
id: createSegmentId(),
segment: '',
carrier: '中国移动',
province: '',
city: '',
createdAt: '2026-06-30 10:00:00',
updatedAt: '2026-06-30 10:00:00',
});
function updateField<Key extends keyof PhoneSegment>(key: Key, value: PhoneSegment[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => onSubmit({ ...form, updatedAt: '2026-06-30 10:00:00' })}></Button>
</>
)}
onClose={onClose}
open
title={item ? '编辑手机号段' : '新增手机号段'}
>
<div className="admin-system-modal-form">
<Input label="手机号段" maxLength={7} onChange={(event) => updateField('segment', event.target.value)} placeholder="手机号码前7位" value={form.segment} />
<Select
label="运营商"
onChange={(event) => updateField('carrier', event.target.value)}
options={[
{ label: '中国移动', value: '中国移动' },
{ label: '中国联通', value: '中国联通' },
{ label: '中国电信', value: '中国电信' },
]}
value={form.carrier}
/>
<Input label="省份" onChange={(event) => updateField('province', event.target.value)} value={form.province} />
<Input label="城市" onChange={(event) => updateField('city', event.target.value)} value={form.city} />
</div>
</Modal>
);
}
export function AdminPhoneSegmentsPage() {
const [segments, setSegments] = useState(initialSegments);
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [keyword, setKeyword] = useState('');
const [editingSegment, setEditingSegment] = useState<PhoneSegment | null>(null);
const [creating, setCreating] = useState(false);
const [prefix, setPrefix] = useState('');
const [carrier, setCarrier] = useState('中国移动');
const [province, setProvince] = useState('');
const [city, setCity] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listPhoneSegments()
.then((items) => {
setSegments(items as PhoneSegment[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredSegments = useMemo(
() => segments.filter((segment) => [segment.segment, segment.carrier, segment.province, segment.city].some((value) => value.includes(keyword))),
() => segments.filter((segment) => [segment.prefix, segment.carrier, segment.province, segment.city].some((value) => String(value ?? '').includes(keyword))),
[keyword, segments],
);
function upsertSegment(nextSegment: PhoneSegment) {
setSegments((current) => {
const exists = current.some((item) => item.id === nextSegment.id);
if (exists) {
return current.map((item) => (item.id === nextSegment.id ? nextSegment : item));
}
return [nextSegment, ...current];
});
setEditingSegment(null);
setCreating(false);
function createSegment() {
adminApi.createPhoneSegment({ prefix, carrier, province, city })
.then(() => {
setPrefix('');
setProvince('');
setCity('');
setCreating(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.segment}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier },
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province },
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
{ key: 'updatedAt', title: '更新时间', width: '190px', render: (record) => record.updatedAt },
{
key: 'actions',
title: '操作',
width: '150px',
align: 'right',
render: (record) => (
<div className="admin-system-actions">
<Button onClick={() => setEditingSegment(record)} size="sm" variant="ghost"></Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => setSegments((current) => current.filter((item) => item.id !== record.id))}
size="sm"
variant="danger"
>
</Button>
</div>
),
},
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
], []);
return (
@@ -135,6 +66,7 @@ export function AdminPhoneSegmentsPage() {
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
@@ -145,8 +77,33 @@ export function AdminPhoneSegmentsPage() {
<Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
</div>
{creating ? <SegmentFormModal onClose={() => setCreating(false)} onSubmit={upsertSegment} /> : null}
{editingSegment ? <SegmentFormModal item={editingSegment} onClose={() => setEditingSegment(null)} onSubmit={upsertSegment} /> : null}
<Modal
footer={(
<>
<Button onClick={() => setCreating(false)} variant="ghost"></Button>
<Button disabled={!prefix} onClick={createSegment}></Button>
</>
)}
onClose={() => setCreating(false)}
open={creating}
title="新增手机号段"
>
<div className="admin-system-modal-form">
<Input label="手机号段" maxLength={7} onChange={(event) => setPrefix(event.target.value)} placeholder="手机号码前7位" value={prefix} />
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '中国移动', value: '中国移动' },
{ label: '中国联通', value: '中国联通' },
{ label: '中国电信', value: '中国电信' },
]}
value={carrier}
/>
<Input label="省份" onChange={(event) => setProvince(event.target.value)} value={province} />
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
</div>
</Modal>
</section>
);
}
+42 -86
View File
@@ -1,84 +1,32 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
type RecordStatus = 'unreported' | 'reporting' | 'success' | 'failed' | 'withdrawn' | 'abandoned';
type ReportRecord = {
id: string;
taskId: string;
channel: string;
enterprise: string;
type: '签名' | '引流信息';
content: string;
carrier: '移动' | '联通' | '电信';
status: RecordStatus;
submittedAt: string;
reportedAt?: string;
updatedAt: string;
reason?: string;
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral',
reporting: 'warning',
success: 'success',
completed: 'success',
failed: 'danger',
rejected: 'danger',
};
const statusMeta: Record<RecordStatus, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
unreported: { label: '未报备', tone: 'neutral' },
reporting: { label: '报备中', tone: 'warning' },
success: { label: '报备成功', tone: 'success' },
failed: { label: '报备失败', tone: 'danger' },
withdrawn: { label: '被清退', tone: 'danger' },
abandoned: { label: '放弃报备', tone: 'warning' },
};
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '未报备', value: 'unreported' },
{ label: '报备中', value: 'reporting' },
{ label: '报备成功', value: 'success' },
{ label: '报备失败', value: 'failed' },
{ label: '被清退', value: 'withdrawn' },
{ label: '放弃报备', value: 'abandoned' },
];
const carrierOptions = [
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: '移动' },
{ label: '联通', value: '联通' },
{ label: '电信', value: '电信' },
];
const initialRecords: ReportRecord[] = [
{ id: 'RPT-REC-001', taskId: 'RPT-TASK-20260628-003', channel: '移动-行北-上海甲医院-38', enterprise: '超感世纪互三网', type: '签名', content: '【促销活动】', carrier: '移动', status: 'success', submittedAt: '2026-06-28 12:20:16', reportedAt: '2026-06-29 10:05:44', updatedAt: '2026-06-29 10:05:44' },
{ id: 'RPT-REC-002', taskId: 'RPT-TASK-20260629-006', channel: '联通-行政-杭州甲医院-37', enterprise: '重庆进载数智', type: '引流信息', content: 'https://service.example.com', carrier: '联通', status: 'reporting', submittedAt: '2026-06-29 16:02:11', updatedAt: '2026-06-29 16:02:11' },
{ id: 'RPT-REC-003', taskId: 'RPT-TASK-20260629-006', channel: '联通-行政-杭州甲医院-37', enterprise: '重庆进载数智', type: '签名', content: '【客户服务】', carrier: '联通', status: 'failed', submittedAt: '2026-06-29 16:02:11', reportedAt: '2026-06-30 09:12:35', updatedAt: '2026-06-30 09:12:35', reason: '责任人身份证照片不清晰。' },
{ id: 'RPT-REC-004', taskId: 'RPT-TASK-20260630-001', channel: '行北-集市三甲医院-39', enterprise: '上海XXXXX科技有限公司', type: '签名', content: '【科技公司】', carrier: '移动', status: 'unreported', submittedAt: '2026-06-30 09:12:00', updatedAt: '2026-06-30 09:12:00' },
];
function RemarkCell({ value }: { value?: string }) {
return (
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
{value || '暂无备注'}
</div>
);
}
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.channel}</strong></div>
<div><span></span><strong>{record.enterprise}</strong></div>
<div><span></span><strong>{record.type} {record.content}</strong></div>
<div><span></span><strong>{record.carrier}</strong></div>
<div><span></span><Tag tone={statusMeta[record.status].tone}>{statusMeta[record.status].label}</Tag></div>
<div><span></span><strong>{record.submittedAt}</strong></div>
<div><span></span><strong>{record.reportedAt ?? '-'}</strong></div>
<div><span></span><strong>{record.channelId}</strong></div>
<div><span></span><strong>{record.action}</strong></div>
<div><span></span><strong>{record.statusBefore ?? '-'}</strong></div>
<div><span></span><strong>{record.statusAfter ?? '-'}</strong></div>
<div className="detail-grid__wide"><span>/</span><strong>{record.reason ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3><Clock3 size={17} /></h3>
<div><span>{record.submittedAt}</span><strong></strong><em></em></div>
<div><span>{record.reportedAt ?? record.updatedAt}</span><strong>{statusMeta[record.status].label}</strong><em>{record.reason ?? '运营商回执同步。'}</em></div>
<div><span>{record.createdAt ?? '-'}</span><strong>{record.action}</strong><em>{record.reason ?? '系统记录真实报备状态变化。'}</em></div>
</section>
</div>
</Modal>
@@ -86,31 +34,40 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
}
export function AdminReportRecordsPage() {
const [records] = useState(initialRecords);
const [records, setRecords] = useState<ReportRecord[]>([]);
const [keyword, setKeyword] = useState('');
const [carrier, setCarrier] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState('');
function loadData() {
adminApi.listReportRecords()
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备记录加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredRecords = useMemo(() => records.filter((record) => {
const text = `${record.taskId}${record.channel}${record.enterprise}${record.content}`;
const date = record.submittedAt.slice(0, 10);
const text = `${record.taskId}${record.channelId}${record.action}${record.reason ?? ''}`;
const date = record.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (carrier === 'all' || record.carrier === carrier)
&& (status === 'all' || record.status === status)
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [carrier, dateRange.end, dateRange.start, keyword, records, status]);
}), [dateRange.end, dateRange.start, keyword, records]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'object', title: '报备对象', width: '260px', render: (record) => <div className="admin-task-enterprise"><strong>{record.content}</strong><span>{record.type} / {record.enterprise}</span></div> },
{ key: 'task', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{ key: 'channel', title: '通道', width: '230px', render: (record) => record.channel },
{ key: 'carrier', title: '运营商', width: '90px', render: (record) => <Tag tone="info">{record.carrier}</Tag> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusMeta[record.status].tone}>{statusMeta[record.status].label}</Tag> },
{ key: 'time', title: '时间', width: '190px', render: (record) => <div className="report-task-time"><span> {record.submittedAt}</span><span> {record.reportedAt ?? '-'}</span></div> },
{ key: 'reason', title: '备注', width: '280px', render: (record) => <RemarkCell value={record.reason} /> },
{ key: 'channel', title: '通道', width: '230px', render: (record) => record.channelId },
{ key: 'action', title: '动作', width: '150px', render: (record) => record.action },
{ key: 'status', title: '状态变化', width: '180px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${record.statusBefore ?? '-'} -> ${record.statusAfter ?? '-'}`}</Tag> },
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
];
@@ -122,15 +79,14 @@ export function AdminReportRecordsPage() {
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="任务/通道/企业/签名" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<Input label="任务/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); setDateRange({}); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); }} variant="ghost"></Button>
</div>
</div>
+68 -233
View File
@@ -1,27 +1,10 @@
import { useMemo, useState } from 'react';
import { Download, Eye, FileUp, Plus, Search, Settings2 } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { useEffect, useMemo, useState } from 'react';
import { Download, Eye, FileUp, Search } from 'lucide-react';
import { adminApi, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
type ReportTaskStatus = 'draft' | 'ready' | 'exported' | 'reporting' | 'partial' | 'completed' | 'failed';
type ReportTask = {
id: string;
channel: string;
carrier: '移动' | '联通' | '电信';
enterprise: string;
signatureCount: number;
drainageCount: number;
missingCount: number;
status: ReportTaskStatus;
createdAt: string;
exportedAt?: string;
receiptAt?: string;
owner: string;
remark: string;
};
const statusMeta: Record<ReportTaskStatus, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
draft: { label: '待生成', tone: 'neutral' },
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
pending: { label: '待处理', tone: 'neutral' },
ready: { label: '待导出', tone: 'info' },
exported: { label: '已导出', tone: 'warning' },
reporting: { label: '报备中', tone: 'warning' },
@@ -30,246 +13,104 @@ const statusMeta: Record<ReportTaskStatus, { label: string; tone: 'neutral' | 'i
failed: { label: '有失败', tone: 'danger' },
};
const carrierOptions = [
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: '移动' },
{ label: '联通', value: '联通' },
{ label: '电信', value: '电信' },
];
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '待生成', value: 'draft' },
{ label: '待导出', value: 'ready' },
{ label: '已导出', value: 'exported' },
{ label: '报备中', value: 'reporting' },
{ label: '部分完成', value: 'partial' },
{ label: '已完成', value: 'completed' },
{ label: '有失败', value: 'failed' },
];
const initialTasks: ReportTask[] = [
{
id: 'RPT-TASK-20260630-001',
channel: '行北-集市三甲医院-39',
carrier: '移动',
enterprise: '上海XXXXX科技有限公司',
signatureCount: 18,
drainageCount: 9,
missingCount: 0,
status: 'ready',
createdAt: '2026-06-30 09:12:00',
owner: '运营',
remark: '字段版本 V3,等待导出提交运营商。',
},
{
id: 'RPT-TASK-20260629-006',
channel: '联通-行政-杭州甲医院-37',
carrier: '联通',
enterprise: '重庆进载数智',
signatureCount: 12,
drainageCount: 4,
missingCount: 2,
status: 'exported',
createdAt: '2026-06-29 15:24:00',
exportedAt: '2026-06-29 16:02:11',
owner: '李青',
remark: '2 条引流链接缺 ICP 备案截图。',
},
{
id: 'RPT-TASK-20260628-003',
channel: '移动-行北-上海甲医院-38',
carrier: '移动',
enterprise: '超感世纪互三网',
signatureCount: 7,
drainageCount: 3,
missingCount: 0,
status: 'completed',
createdAt: '2026-06-28 11:36:00',
exportedAt: '2026-06-28 12:20:16',
receiptAt: '2026-06-29 10:05:44',
owner: '运营',
remark: '已导入移动回执,全部通过。',
},
];
function RemarkCell({ value }: { value?: string }) {
return (
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
{value || '暂无备注'}
</div>
);
}
function StatusTag({ status }: { status: ReportTaskStatus }) {
return <Tag tone={statusMeta[status].tone}>{statusMeta[status].label}</Tag>;
}
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: () => void }) {
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (fileName: string, remark: string) => void }) {
const [fileName, setFileName] = useState('');
const [remark, setRemark] = useState('');
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button onClick={onSubmit}></Button></>}
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!fileName} onClick={() => onSubmit(fileName, remark)}></Button></>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="report-receipt-modal">
<div className="report-upload-drop">
<label className="report-upload-drop">
<FileUp size={38} />
<strong></strong>
<span> ExcelCSV</span>
</div>
<div className="report-receipt-preview">
<h3></h3>
<div><span></span><strong>26 </strong></div>
<div><span></span><strong>3 </strong></div>
<div><span></span><strong>1 </strong></div>
</div>
<Textarea label="导入备注" placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} />
</div>
</Modal>
);
}
function GenerateTaskModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (task: ReportTask) => void }) {
const [channel, setChannel] = useState('行北-集市三甲医院-39');
const [carrier, setCarrier] = useState<'移动' | '联通' | '电信'>('移动');
const [enterprise, setEnterprise] = useState('全部资料完整企业');
function submit() {
onSubmit({
id: `RPT-TASK-${Date.now()}`,
channel,
carrier,
enterprise,
signatureCount: 15,
drainageCount: 6,
missingCount: enterprise === '全部资料完整企业' ? 0 : 2,
status: enterprise === '全部资料完整企业' ? 'ready' : 'draft',
createdAt: '2026-06-30 10:00:00',
owner: '运营',
remark: '由候选签名池生成,按当前通道字段版本校验。',
});
}
return (
<Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button onClick={submit}></Button></>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="report-task-generate">
<section>
<h3></h3>
<div className="admin-system-modal-form">
<Select label="报备通道" onChange={(event) => setChannel(event.target.value)} options={[
{ label: '行北-集市三甲医院-39', value: '行北-集市三甲医院-39' },
{ label: '联通-行政-杭州甲医院-37', value: '联通-行政-杭州甲医院-37' },
{ label: '移动-行北-上海甲医院-38', value: '移动-行北-上海甲医院-38' },
]} value={channel} />
<Select label="运营商" onChange={(event) => setCarrier(event.target.value as '移动' | '联通' | '电信')} options={carrierOptions.filter((item) => item.value !== 'all')} value={carrier} />
<Select label="候选企业" onChange={(event) => setEnterprise(event.target.value)} options={[
{ label: '全部资料完整企业', value: '全部资料完整企业' },
{ label: '上海XXXXX科技有限公司', value: '上海XXXXX科技有限公司' },
{ label: '重庆进载数智', value: '重庆进载数智' },
]} value={enterprise} />
</div>
</section>
<section>
<h3></h3>
<div className="report-task-preview">
<div><span></span><strong>15</strong></div>
<div><span></span><strong>6</strong></div>
<div><span></span><strong>{enterprise === '全部资料完整企业' ? 0 : 2}</strong></div>
<div><span></span><strong> V3</strong></div>
</div>
</section>
<strong>{fileName || '选择回执文件'}</strong>
<span> ExcelCSVPDF </span>
<input onChange={(event) => setFileName(event.target.files?.[0]?.name ?? '')} style={{ display: 'none' }} type="file" />
</label>
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
</div>
</Modal>
);
}
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{task.id}</p></div>}>
<div className="admin-task-detail report-task-detail">
<div className="admin-task-metrics">
<div className="admin-task-metric"><span></span><strong>{task.signatureCount}</strong></div>
<div className="admin-task-metric admin-task-metric--primary"><span></span><strong>{task.drainageCount}</strong></div>
<div className="admin-task-metric"><span></span><strong>{task.missingCount}</strong></div>
<div className="admin-task-metric admin-task-metric--success"><span></span><strong>{statusMeta[task.status].label}</strong></div>
<div className="admin-task-metric"><span></span><strong>{task.signature?.name ?? task.signatureId}</strong></div>
<div className="admin-task-metric admin-task-metric--primary"><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
<div className="admin-task-metric admin-task-metric--success"><span></span><strong>{status.label}</strong></div>
</div>
<section className="admin-task-card">
<h3><Settings2 size={18} /></h3>
<dl className="admin-task-info-list">
<div><dt></dt><dd>{task.channel}</dd></div>
<div><dt></dt><dd>{task.carrier}</dd></div>
<div><dt></dt><dd>{task.enterprise}</dd></div>
<div><dt></dt><dd>{task.createdAt}</dd></div>
<div><dt></dt><dd>{task.exportedAt ?? '-'}</dd></div>
<div><dt></dt><dd>{task.receiptAt ?? '-'}</dd></div>
</dl>
</section>
</div>
</Modal>
);
}
export function AdminReportTasksPage() {
const [tasks, setTasks] = useState(initialTasks);
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [carrier, setCarrier] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [generateOpen, setGenerateOpen] = useState(false);
const [receiptTask, setReceiptTask] = useState<ReportTask | 'batch' | null>(null);
const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [error, setError] = useState('');
const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel}${task.enterprise}`;
const date = task.createdAt.slice(0, 10);
return (!keyword || text.includes(keyword))
&& (carrier === 'all' || task.carrier === carrier)
&& (status === 'all' || task.status === status)
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [carrier, dateRange.end, dateRange.start, keyword, status, tasks]);
function exportTask(taskId: string) {
setTasks((current) => current.map((task) => (
task.id === taskId ? { ...task, status: 'exported', exportedAt: '2026-06-30 10:18:00' } : task
)));
function loadData() {
adminApi.listReportTasks()
.then((items) => {
setTasks(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备任务加载失败'));
}
function importReceipt() {
if (receiptTask && receiptTask !== 'batch') {
setTasks((current) => current.map((task) => (
task.id === receiptTask.id ? { ...task, status: 'partial', receiptAt: '2026-06-30 10:36:00' } : task
)));
}
setReceiptTask(null);
useEffect(() => {
loadData();
}, []);
const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}`;
const date = task.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [dateRange.end, dateRange.start, keyword, tasks]);
function exportTask(task: ReportTask) {
adminApi.createReportExport(task.id, { fileName: `${task.id}.xlsx`, rowCount: 0 })
.then(loadData)
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
}
function importReceipt(fileName: string, remark: string) {
if (!receiptTask) return;
adminApi.importReportReceipt(receiptTask.id, { fileName, reason: remark, statusAfter: 'partial' })
.then(() => {
setReceiptTask(null);
loadData();
})
.catch((failure: Error) => setError(failure.message));
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/企业', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel}</strong><span>{record.enterprise}</span></div> },
{ key: 'carrier', title: '运营商', width: '90px', render: (record) => <Tag tone="info">{record.carrier}</Tag> },
{ key: 'counts', title: '资料数量', width: '190px', render: (record) => <div className="admin-task-counts"><span> {record.signatureCount}</span><span> {record.drainageCount}</span><strong> {record.missingCount}</strong></div> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <StatusTag status={record.status} /> },
{ key: 'time', title: '流转时间', width: '210px', render: (record) => <div className="report-task-time"><span> {record.createdAt}</span><span> {record.exportedAt ?? '-'}</span><span> {record.receiptAt ?? '-'}</span></div> },
{ key: 'remark', title: '备注', width: '300px', render: (record) => <RemarkCell value={record.remark} /> },
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{
key: 'actions',
title: '操作',
align: 'right',
width: '280px',
width: '240px',
render: (record) => (
<div className="admin-task-actions">
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button>
<Button disabled={record.missingCount > 0} icon={<Download size={14} />} onClick={() => exportTask(record.id)} size="sm" variant="ghost"></Button>
<Button icon={<Download size={14} />} onClick={() => exportTask(record)} size="sm" variant="ghost"></Button>
<Button icon={<FileUp size={14} />} onClick={() => setReceiptTask(record)} size="sm" variant="ghost"></Button>
</div>
),
@@ -280,23 +121,18 @@ export function AdminReportTasksPage() {
<section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备任务', '报备任务']} />
<h1></h1>
</div>
<div className="report-page-actions">
<Button icon={<FileUp size={16} />} onClick={() => setReceiptTask('batch')} variant="secondary"></Button>
<Button icon={<Plus size={16} />} onClick={() => setGenerateOpen(true)}></Button>
<Breadcrumb items={['报备任务', '签名通道报备']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="任务/通道/企业" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、通道或企业" value={keyword} />
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
<Select label="任务状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<Input label="任务/通道/签名" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); setDateRange({}); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); }} variant="ghost"></Button>
</div>
</div>
@@ -304,7 +140,6 @@ export function AdminReportTasksPage() {
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
</div>
{generateOpen ? <GenerateTaskModal onClose={() => setGenerateOpen(false)} onSubmit={(task) => { setTasks((current) => [task, ...current]); setGenerateOpen(false); }} /> : null}
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} /> : null}
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
</section>
+44 -55
View File
@@ -1,98 +1,87 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
type SensitiveLevel = 'low' | 'medium' | 'high';
type SensitiveWordItem = {
id: string;
word: string;
category: string;
level: SensitiveLevel;
createdAt: string;
updatedAt: string;
type SensitiveWordItem = DictionaryItem & {
word?: string;
level?: string;
};
const levelLabelMap: Record<SensitiveLevel, string> = {
const levelLabelMap: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
block: '拦截',
};
const levelToneMap: Record<SensitiveLevel, 'success' | 'warning' | 'danger'> = {
const levelToneMap: Record<string, 'success' | 'warning' | 'danger'> = {
low: 'success',
medium: 'warning',
high: 'danger',
block: 'danger',
};
const initialItems: SensitiveWordItem[] = [
{ id: 'SW20260630001', word: '高息贷款', category: '金融营销', level: 'high', createdAt: '2026-06-20 09:12:18', updatedAt: '2026-06-28 16:24:10' },
{ id: 'SW20260630002', word: '中奖链接', category: '欺诈风险', level: 'high', createdAt: '2026-06-19 13:40:22', updatedAt: '2026-06-26 11:09:45' },
{ id: 'SW20260630003', word: '限时返利', category: '营销规范', level: 'medium', createdAt: '2026-06-18 10:30:00', updatedAt: '2026-06-24 15:18:32' },
{ id: 'SW20260630004', word: '免费领取', category: '普通营销', level: 'low', createdAt: '2026-06-17 17:06:51', updatedAt: '2026-06-21 09:05:14' },
];
const levelOptions = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
{ label: '拦截', value: 'block' },
];
function nowText() {
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
}
export function AdminSensitiveWordsPage() {
const [items, setItems] = useState(initialItems);
const [items, setItems] = useState<SensitiveWordItem[]>([]);
const [keyword, setKeyword] = useState('');
const [word, setWord] = useState('');
const [category, setCategory] = useState('');
const [level, setLevel] = useState<SensitiveLevel>('medium');
const [level, setLevel] = useState('medium');
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState('');
function loadData() {
adminApi.listSensitiveWords({ keyword })
.then((data) => {
setItems(data as SensitiveWordItem[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '敏感词加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.word, item.category, levelLabelMap[item.level]].join(' ');
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'category', title: '分类', width: '160px', render: (record) => record.category },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
{ key: 'updatedAt', title: '更新时间', width: '190px', render: (record) => record.updatedAt },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => setItems((current) => current.filter((item) => item.id !== record.id))} size="sm" variant="danger">
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
</Button>
),
},
], []);
function resetForm() {
setWord('');
setCategory('');
setLevel('medium');
}
function addItem() {
const time = nowText();
const nextItem: SensitiveWordItem = {
id: `SW${Date.now()}`,
word: word || '待补充敏感词',
category: category || '未分类',
level,
createdAt: time,
updatedAt: time,
};
setItems((current) => [nextItem, ...current]);
resetForm();
setModalOpen(false);
adminApi.createSensitiveWord({ word, level, status: 'active' })
.then(() => {
setWord('');
setLevel('medium');
setModalOpen(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '添加敏感词失败'));
}
return (
@@ -104,17 +93,18 @@ export function AdminSensitiveWordsPage() {
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-security-filter">
<Input
label="搜索"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索敏感词、分类或级别"
placeholder="搜索敏感词、级别或状态"
prefix={<Search size={16} />}
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
</div>
</div>
@@ -127,7 +117,7 @@ export function AdminSensitiveWordsPage() {
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button onClick={addItem}></Button>
<Button disabled={!word} onClick={addItem}></Button>
</>
)}
onClose={() => setModalOpen(false)}
@@ -136,10 +126,9 @@ export function AdminSensitiveWordsPage() {
>
<div className="admin-security-form">
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
<Input label="分类" onChange={(event) => setCategory(event.target.value)} placeholder="请输入分类" value={category} />
<Select
label="风险级别"
onChange={(event) => setLevel(event.target.value as SensitiveLevel)}
onChange={(event) => setLevel(event.target.value)}
options={levelOptions}
value={level}
/>
+42 -175
View File
@@ -1,76 +1,45 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Info, RefreshCw } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { adminApi } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
import { getEnterpriseRecords } from './adminEnterpriseMock';
type QueueType = 'priority' | 'normal';
type ReportRule = 'any' | 'one';
type InterfaceType = 'cmpp' | 'http';
const channelGroupOptions = {
mobile: [
{ label: '移动通道组A', value: 'mobile-a' },
{ label: '移动通道组B', value: 'mobile-b' },
],
unicom: [
{ label: '联通通道组B', value: 'unicom-b' },
{ label: '联通通道组C', value: 'unicom-c' },
],
telecom: [
{ label: '电信通道组C', value: 'telecom-c' },
{ label: '电信通道组D', value: 'telecom-d' },
],
};
const mismatchPolicyOptions = [
{ label: '拒绝发送', value: 'reject' },
{ label: '跳人工审核', value: 'manual-review' },
{ label: '直接发送', value: 'direct-send' },
];
function generateCode(prefix: string) {
return `${prefix}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
}
export function AdminSmsApplicationFormPage() {
const navigate = useNavigate();
const { enterpriseId, appId } = useParams();
const enterprise = useMemo(
() => getEnterpriseRecords().find((item) => item.id === enterpriseId),
[enterpriseId],
);
const isEdit = Boolean(appId);
const [appName, setAppName] = useState(isEdit ? '示例应用' : '');
const [unitPrice, setUnitPrice] = useState(isEdit ? '0.0300' : '');
const [queueType, setQueueType] = useState<QueueType>('priority');
const [mobileGroup, setMobileGroup] = useState('mobile-a');
const [unicomGroup, setUnicomGroup] = useState('unicom-b');
const [telecomGroup, setTelecomGroup] = useState('telecom-c');
const [reportRule, setReportRule] = useState<ReportRule>('any');
const [dailyLimit, setDailyLimit] = useState(isEdit ? '100000' : '');
const [phoneDailyLimit, setPhoneDailyLimit] = useState(isEdit ? '10' : '');
const [mismatchPolicy, setMismatchPolicy] = useState('manual-review');
const [smsEnabled, setSmsEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp');
const [ipAddress, setIpAddress] = useState(isEdit ? '192.168.1.100' : '');
const [connectionCount, setConnectionCount] = useState(isEdit ? '2' : '');
const [enterpriseCode, setEnterpriseCode] = useState(isEdit ? 'ABC123' : generateCode('EC'));
const [interfaceAccount, setInterfaceAccount] = useState(isEdit ? 'ABC123' : generateCode('AC'));
const [interfacePassword, setInterfacePassword] = useState(isEdit ? '************' : generateCode('PW'));
const [accessNumber, setAccessNumber] = useState(isEdit ? '1069' : '');
const [nameError, setNameError] = useState('');
const [appName, setAppName] = useState('');
const [scene, setScene] = useState('行业通知');
const [dailyLimit, setDailyLimit] = useState('100000');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
const [error, setError] = useState('');
function goBack() {
navigate('/admin/enterprise-applications');
}
function submit() {
if (!appName.trim()) {
setNameError('请填写应用名称');
if (!enterpriseId) {
setError('缺少企业 ID');
return;
}
goBack();
if (isEdit) {
setError('短信应用编辑接口待补,当前不做本地模拟保存');
return;
}
adminApi.createEnterpriseApplication({
tenantId: enterpriseId,
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: ipAddress ? [ipAddress] : [],
})
.then(goBack)
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
}
return (
@@ -78,140 +47,38 @@ export function AdminSmsApplicationFormPage() {
<div className="page-heading">
<div>
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
<p>{enterprise?.name ?? '当前企业'} </p>
<p></p>
</div>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
</Button>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-app-form-card">
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3></h3>
</div>
<div className="admin-app-form-grid">
<Input
error={nameError}
label="应用名称"
onChange={(event) => {
setAppName(event.target.value);
setNameError('');
}}
placeholder="请输入应用名称"
required
value={appName}
/>
<Input
label="编ID(元)"
onChange={(event) => setUnitPrice(event.target.value)}
placeholder="0.0300"
required
suffix={<span className="admin-app-form-price-note">(3.0000)</span>}
value={unitPrice}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<label>
<input checked={queueType === 'priority'} onChange={() => setQueueType('priority')} type="radio" />
</label>
<label>
<input checked={queueType === 'normal'} onChange={() => setQueueType('normal')} type="radio" />
</label>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span></span>
</div>
</div>
<Select label="发送通道组-移动" onChange={(event) => setMobileGroup(event.target.value)} options={channelGroupOptions.mobile} required value={mobileGroup} />
<Select label="发送通道组-联通" onChange={(event) => setUnicomGroup(event.target.value)} options={channelGroupOptions.unicom} required value={unicomGroup} />
<Select label="发送通道组-电信" onChange={(event) => setTelecomGroup(event.target.value)} options={channelGroupOptions.telecom} required value={telecomGroup} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<label>
<input checked={reportRule === 'any'} onChange={() => setReportRule('any')} type="radio" />
</label>
<label>
<input checked={reportRule === 'one'} onChange={() => setReportRule('one')} type="radio" />
</label>
</div>
</div>
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3></h3>
</div>
<div className="ui-detail-section__header"><h3></h3></div>
<div className="admin-app-form-grid">
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="每号码日发送频次限制" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
onChange={(event) => setMismatchPolicy(event.target.value)}
options={mismatchPolicyOptions}
options={[
{ label: '拒绝发送', value: 'reject' },
{ label: '跳人工审核', value: 'manual_review' },
{ label: '直接发送', value: 'direct_send' },
]}
required
value={mismatchPolicy}
/>
</div>
</section>
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3></h3>
</div>
<div className="admin-app-form-grid">
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<button className={smsEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setSmsEnabled((current) => !current)} type="button">
<span />
{smsEnabled ? '开通' : '关闭'}
</button>
</div>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<label>
<input checked={interfaceType === 'cmpp'} onChange={() => setInterfaceType('cmpp')} type="radio" />
CMPP接口
</label>
<label>
<input checked={interfaceType === 'http'} onChange={() => setInterfaceType('http')} type="radio" />
HTTP接口
</label>
</div>
</div>
<Input label="IP地址" onChange={(event) => setIpAddress(event.target.value)} placeholder="请输入 IP 地址" required value={ipAddress} />
<Input label="连接数" onChange={(event) => setConnectionCount(event.target.value)} placeholder="请输入连接数" required value={connectionCount} />
<Input
label="企业代码"
onChange={(event) => setEnterpriseCode(event.target.value)}
required
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setEnterpriseCode(generateCode('EC'))} size="sm" variant="ghost"></Button>}
value={enterpriseCode}
/>
<Input label="接口账号" onChange={(event) => setInterfaceAccount(event.target.value)} required value={interfaceAccount} />
<Input
label="接口密码"
onChange={(event) => setInterfacePassword(event.target.value)}
required
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setInterfacePassword(generateCode('PW'))} size="sm" variant="ghost"></Button>}
value={interfacePassword}
/>
<Input label="接入号" onChange={(event) => setAccessNumber(event.target.value)} placeholder="请输入接入号" required value={accessNumber} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
</div>
</section>
<div className="enterprise-form-footer">
<Button onClick={submit}></Button>
<Button onClick={goBack} variant="ghost"></Button>
<Button disabled={!appName || isEdit} onClick={submit}>{isEdit ? '编辑待补接口' : '创建应用'}</Button>
<Button onClick={goBack} variant="ghost"></Button>
</div>
</div>
</section>
+114 -354
View File
@@ -1,400 +1,160 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
type SmsAuditStatus = 'pending' | 'approved' | 'rejected';
type SmsAuditRecord = {
id: string;
customer: string;
industry: string;
application: string;
submittedAt: string;
content: string;
chars: number;
billCount: number;
phoneCount: number;
reasons: string[];
status: SmsAuditStatus;
reviewedAt?: string;
const statusLabel: Record<string, string> = {
pending_review: '待审核',
approved: '已通过',
rejected: '已驳回',
};
type PhoneRecord = {
id: number;
phone: string;
location: string;
carrier: string;
const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
pending_review: 'warning',
approved: 'success',
rejected: 'danger',
};
const initialSmsAudits: SmsAuditRecord[] = [
{
id: 'SMS-20250106-001',
customer: '广州XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '应用名称示例',
submittedAt: '2025-01-06 09:02:28',
content: '【深圳市北健科技有限公司】KH58户主,您录名的您好您好。1)童套充不要禁运控。2)禁止去速1200m左右的来水,收费收在2;3)恐怕接待,我接待您恐来子。单,现现确下里况要可图的注明技术想上,统统统他只有禁运信息;最可以请点仙应出他有记。',
chars: 300,
billCount: 1,
phoneCount: 256,
reasons: ['模板', '签名', '引流信息'],
status: 'pending',
},
{
id: 'SMS-20250105-001',
customer: '上海XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '客户通知服务',
submittedAt: '2025-01-05 08:02:28',
content: '【某某公司】尊敬的客户您好,您好。感谢您选择光纤宽带!如遇问题请联系客服...',
chars: 100,
billCount: 1,
phoneCount: 156,
reasons: ['模板', '签名', '引流信息'],
status: 'approved',
reviewedAt: '2026-01-12 17:27:28',
},
{
id: 'SMS-20250105-002',
customer: '上海XXXXX有限公司',
industry: 'XXXXXXXX行业',
application: '会员通知',
submittedAt: '2025-01-05 08:02:28',
content: '【某某公司】尊敬的客户您好,您好。',
chars: 50,
billCount: 1,
phoneCount: 2762,
reasons: ['模板', '签名'],
status: 'approved',
reviewedAt: '2026-01-12 17:27:28',
},
{
id: 'SMS-20250105-003',
customer: '北京XXXXX有限公司',
industry: 'XXXXXXXX营销',
application: '营销推广平台',
submittedAt: '2025-01-05 08:02:28',
content: '【大童芝】签住链,应控计 移奇亭某间遇道进使用请您动幼时此从此出收在2026-01-06 17:00-17:30至取到交投买元画山点。',
chars: 200,
billCount: 1,
phoneCount: 256,
reasons: ['模板'],
status: 'pending',
},
];
const rejectReasons = ['内容不发', '模板未提交', '签名未报备', '引流未报备完成', '内容包含敏感词', '号码格式错误', '缺少必要信息', '违反运营商规定', '签名与内容不符', '模板变量不匹配'];
const initialPhoneRows: PhoneRecord[] = [
{ id: 1, phone: '188xxxx9999', location: '河南 信阳', carrier: '移动' },
{ id: 2, phone: '133xxxx1111', location: '河南 信阳', carrier: '移动' },
{ id: 3, phone: '134xxxx2222', location: '河南 信阳', carrier: '移动' },
{ id: 4, phone: '156xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 5, phone: '178625xxxxx', location: '山东 青岛', carrier: '联通' },
{ id: 6, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 7, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '联通' },
{ id: 8, phone: '190xxxxyyyy', location: '山东 青岛', carrier: '电信' },
{ id: 9, phone: '177xxxx5555', location: '北京', carrier: '移动' },
{ id: 10, phone: '189xxxx6666', location: '上海', carrier: '电信' },
];
function SmsEditModal({
record,
onClose,
onSubmit,
}: {
record: SmsAuditRecord;
onClose: () => void;
onSubmit: (content: string) => void;
}) {
const [content, setContent] = useState(record.content);
const billingCount = Math.max(1, Math.ceil(content.length / 67));
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={() => onSubmit(content)}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="sms-audit-edit">
<div className="sms-audit-edit__meta">
<span><strong>{record.customer}</strong></span>
<span><strong>{record.application}</strong></span>
<span><strong>{record.submittedAt}</strong></span>
</div>
<Textarea label="短信内容" onChange={(event) => setContent(event.target.value)} rows={9} value={content} />
<div className="sms-audit-edit__count">
<span> {content.length} </span>
<strong> {billingCount} </strong>
</div>
</div>
</Modal>
);
}
function SmsRejectModal({
record,
onClose,
onSubmit,
}: {
record: SmsAuditRecord;
onClose: () => void;
onSubmit: (reason: string) => void;
}) {
const [reason, setReason] = useState('');
function appendReason(nextReason: string) {
setReason((current) => (current ? `${current}${nextReason}` : nextReason));
}
return (
<Modal
footer={(
<>
<Button onClick={() => onSubmit(reason)} disabled={!reason.trim()}></Button>
<Button onClick={onClose} variant="ghost"></Button>
</>
)}
onClose={onClose}
open
title={<div className="template-modal-title"><h2></h2><p></p></div>}
>
<div className="sms-reject-modal">
<Textarea label="* 驳回原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入驳回原因" rows={4} value={reason} />
<div>
<strong></strong>
<div className="reject-reason-tags">
{rejectReasons.map((item) => (
<button key={item} onClick={() => appendReason(item)} type="button">{item}</button>
))}
</div>
</div>
<button className="audit-collapse-link" type="button"></button>
<p className="muted">{record.customer}</p>
</div>
</Modal>
);
}
function PhoneListModal({
record,
onClose,
}: {
record: SmsAuditRecord;
onClose: () => void;
}) {
const [keyword, setKeyword] = useState('');
const phoneRows = useMemo(
() => initialPhoneRows.filter((item) => !keyword || item.phone.includes(keyword)),
[keyword],
);
const columns: Array<TableColumn<PhoneRecord>> = [
{ key: 'id', title: '序号', width: '120px', render: (item) => item.id },
{ key: 'phone', title: '手机号码', render: (item) => <strong>{item.phone}</strong> },
{ key: 'location', title: '号码归属地', render: (item) => item.location },
{ key: 'carrier', title: '运营商', render: (item) => item.carrier },
];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.customer} {record.phoneCount} </p></div>}
>
<div className="phone-list-modal">
<Input
autoFocus
onChange={(event) => setKeyword(event.target.value)}
placeholder="手机号"
prefix={<Search size={18} />}
value={keyword}
/>
<div className="phone-list-toolbar">
<Select
options={[
{ label: '10条/页', value: '10' },
{ label: '20条/页', value: '20' },
{ label: '50条/页', value: '50' },
]}
value="10"
/>
<div>
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="ghost">24</Button>
<Button size="sm" variant="secondary">25</Button>
<Button size="sm" variant="ghost">26</Button>
<span>...</span>
<Button size="sm" variant="ghost">63</Button>
<Button size="sm" variant="ghost"></Button>
</div>
</div>
<div className="phone-list-table">
<Table columns={columns} data={phoneRows} rowKey="id" />
</div>
</div>
</Modal>
);
}
export function AdminSmsAuditPage() {
const [records, setRecords] = useState(initialSmsAudits);
const [records, setRecords] = useState<RiskReviewTask[]>([]);
const [keyword, setKeyword] = useState('');
const [company, setCompany] = useState('');
const [application, setApplication] = useState('');
const [status, setStatus] = useState('pending_review');
const [date, setDate] = useState('');
const [editRecord, setEditRecord] = useState<SmsAuditRecord | null>(null);
const [rejectRecord, setRejectRecord] = useState<SmsAuditRecord | null>(null);
const [phoneListRecord, setPhoneListRecord] = useState<SmsAuditRecord | null>(null);
const [error, setError] = useState('');
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | null>(null);
const [rejectReason, setRejectReason] = useState('');
function loadData() {
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信审核任务加载失败'));
}
useEffect(() => {
loadData();
}, [status]);
const filteredRecords = useMemo(
() => records.filter((record) => {
const matchesCompany = !company || record.customer === company;
const matchesApplication = !application || record.application === application;
const matchesKeyword = !keyword || record.content.includes(keyword);
const matchesDate = !date || record.submittedAt.startsWith(date);
return matchesCompany && matchesApplication && matchesKeyword && matchesDate;
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
const matchesDate = !date || record.createdAt.startsWith(date);
return matchesKeyword && matchesDate;
}),
[application, company, date, keyword, records],
[date, keyword, records],
);
function updateStatus(id: string, status: SmsAuditStatus) {
setRecords((items) => items.map((item) => (
item.id === id ? { ...item, status, reviewedAt: status === 'pending' ? undefined : '2026-01-12 17:27:28' } : item
)));
async function approveRecord(record: RiskReviewTask) {
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
setApproveTarget(null);
loadData();
}
function updateContent(content: string) {
if (!editRecord) return;
setRecords((items) => items.map((item) => (
item.id === editRecord.id ? { ...item, content, chars: content.length, billCount: Math.max(1, Math.ceil(content.length / 67)) } : item
)));
setEditRecord(null);
async function approveBatch() {
await Promise.all(filteredRecords.filter((item) => item.status === 'pending_review').map((item) => adminApi.approveRiskReviewTask(item.id, '运营批量审核通过')));
setApproveTarget(null);
loadData();
}
function rejectRecordWithReason() {
if (!rejectRecord) return;
updateStatus(rejectRecord.id, 'rejected');
setRejectRecord(null);
async function rejectRecord() {
if (!rejectTarget) return;
await adminApi.rejectRiskReviewTask(rejectTarget.id, rejectReason || '运营审核驳回');
setRejectTarget(null);
setRejectReason('');
loadData();
}
const columns: Array<TableColumn<RiskReviewTask>> = [
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '号码数', width: '110px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join('') ?? '-' },
{
key: 'status',
title: '状态',
width: '110px',
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
},
{
key: 'actions',
title: '操作',
width: '160px',
align: 'right',
render: (record) => record.status === 'pending_review' ? (
<div className="audit-actions">
<Button icon={<Check size={15} />} onClick={() => setApproveTarget(record)} size="sm" variant="success"></Button>
<Button icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button>
</div>
) : null,
},
];
return (
<section className="page-stack admin-audit-page">
<Breadcrumb items={['审核中心', '短信审核']} />
{error ? <p className="form-error">{error}</p> : null}
<div className="surface sms-audit-filter">
<h2></h2>
<div className="audit-filter-grid audit-filter-grid--sms">
<Select
label="企业"
onChange={(event) => setCompany(event.target.value)}
options={[{ label: '请选择企业', value: '' }, ...Array.from(new Set(records.map((item) => item.customer))).map((item) => ({ label: item, value: item }))]}
value={company}
label="审核状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' },
]}
value={status}
/>
<Select
disabled={!company}
label="应用"
onChange={(event) => setApplication(event.target.value)}
options={[{ label: company ? '请选择应用' : '请先选择企业', value: '' }, ...Array.from(new Set(records.filter((item) => !company || item.customer === company).map((item) => item.application))).map((item) => ({ label: item, value: item }))]}
value={application}
/>
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容关键词" value={keyword} />
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、内容或审核原因" value={keyword} />
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />}></Button>
<Button onClick={() => { setCompany(''); setApplication(''); setKeyword(''); setDate(''); }} variant="ghost"></Button>
<Button icon={<Search size={17} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost"></Button>
</div>
</div>
<div className="sms-bulk-actions">
<span></span>
<Button icon={<Check size={16} />} onClick={() => setRecords((items) => items.map((item) => ({ ...item, status: 'approved', reviewedAt: '2026-01-12 17:27:28' })))} variant="secondary"></Button>
<Button icon={<X size={16} />} onClick={() => setRejectRecord(filteredRecords[0] ?? null)} variant="danger"></Button>
</div>
</div>
<div className="sms-audit-toolbar">
<span>10/</span>
<div>
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">1</Button>
<Button size="sm" variant="ghost">2</Button>
<Button size="sm" variant="ghost">63</Button>
<Button size="sm" variant="ghost"></Button>
<Button disabled={filteredRecords.every((item) => item.status !== 'pending_review')} icon={<Check size={16} />} onClick={() => setApproveTarget('batch')} variant="success"></Button>
</div>
</div>
<div className="surface sms-audit-list">
<div className="sms-audit-head">
<span><input aria-label="全选" type="checkbox" /></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{filteredRecords.map((record) => (
<article className="sms-audit-row" key={record.id}>
<span><input aria-label={`选择 ${record.id}`} type="checkbox" /></span>
<div className="sms-audit-customer">
<strong>{record.customer}</strong>
<span>{record.industry}</span>
<small>{record.submittedAt} </small>
</div>
<p className="sms-audit-content">{record.content}</p>
<div className="sms-audit-count">
<span><strong>{record.chars}</strong></span>
<span><strong>{record.billCount}</strong></span>
<span><a>{record.phoneCount}</a></span>
<button onClick={() => setPhoneListRecord(record)} type="button"></button>
</div>
<div className="sms-audit-reasons">
{record.reasons.map((reason) => <Tag key={reason} tone={reason === '模板' ? 'success' : 'neutral'}>{reason}</Tag>)}
</div>
<div className="sms-audit-result">
{record.status === 'pending' ? <span className="audit-dot audit-dot--warning"></span> : null}
{record.status === 'approved' ? <span className="audit-dot audit-dot--success"></span> : null}
{record.status === 'rejected' ? <span className="audit-dot audit-dot--danger"></span> : null}
{record.reviewedAt ? <small>{record.reviewedAt}</small> : null}
</div>
<div className="sms-audit-actions">
<Button onClick={() => updateStatus(record.id, 'approved')} size="sm" variant="secondary"></Button>
<Button onClick={() => setRejectRecord(record)} size="sm" variant="danger"></Button>
<Button onClick={() => setEditRecord(record)} size="sm" variant="ghost"></Button>
</div>
</article>
))}
<Table columns={columns} data={filteredRecords} emptyText="暂无短信审核任务" rowKey="id" />
</div>
{editRecord ? (
<SmsEditModal
onClose={() => setEditRecord(null)}
onSubmit={updateContent}
record={editRecord}
/>
) : null}
<Modal
footer={(
<>
<Button onClick={() => setApproveTarget(null)} variant="ghost"></Button>
<Button onClick={() => approveTarget === 'batch' ? void approveBatch() : approveTarget ? void approveRecord(approveTarget) : undefined} variant="success"></Button>
</>
)}
onClose={() => setApproveTarget(null)}
open={Boolean(approveTarget)}
title="确认通过"
>
<p>{approveTarget === 'batch' ? `确认通过 ${filteredRecords.filter((item) => item.status === 'pending_review').length} 条待审核任务?` : '确认通过该短信审核任务?'}</p>
</Modal>
{rejectRecord ? (
<SmsRejectModal
onClose={() => setRejectRecord(null)}
onSubmit={rejectRecordWithReason}
record={rejectRecord}
/>
) : null}
{phoneListRecord ? (
<PhoneListModal
onClose={() => setPhoneListRecord(null)}
record={phoneListRecord}
/>
) : null}
<Modal
footer={(
<>
<Button onClick={() => setRejectTarget(null)} variant="ghost"></Button>
<Button disabled={!rejectReason.trim()} onClick={() => void rejectRecord()} variant="danger"></Button>
</>
)}
onClose={() => setRejectTarget(null)}
open={Boolean(rejectTarget)}
title="确认驳回"
>
<Textarea label="驳回原因" onChange={(event) => setRejectReason(event.target.value)} rows={4} value={rejectReason} />
</Modal>
</section>
);
}
+69 -277
View File
@@ -1,242 +1,73 @@
import { useMemo, useState } from 'react';
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Tag,
type DateRangeValue,
} from '@/components/ui';
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
type SendStatus = 'success' | 'unknown' | 'failed';
type SendRoute = {
id: string;
channel: string;
sentAt: string;
receiptAt: string;
receiptCode: string;
};
type SmsRecord = {
id: string;
enterprise: string;
application: string;
submittedAt: string;
content: string;
phone: string;
carrier: string;
region: string;
wordCount: number;
billingCount: number;
channel: string;
status: SendStatus;
receiptAt: string;
routes: SendRoute[];
};
const statusLabelMap: Record<SendStatus, string> = {
success: '发送成功',
const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
queued: '排队中',
submitted: '已提交',
unknown: '未知',
failed: '失败',
rejected: '已拒绝',
};
const statusToneMap: Record<SendStatus, 'success' | 'neutral' | 'danger'> = {
success: 'success',
const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
delivered: 'success',
queued: 'info',
submitted: 'info',
unknown: 'neutral',
failed: 'danger',
rejected: 'danger',
};
const statusDotClassMap: Record<SendStatus, string> = {
success: 'is-success',
unknown: 'is-unknown',
failed: 'is-failed',
};
const recordsSeed: SmsRecord[] = [
{
id: 'SMSR202601020001',
enterprise: '四川骠骑企业管理',
application: '应用1',
submittedAt: '2025-12-31 18:00:02',
content: '【大富翁】尊重的,您好!非常荣幸能邀请您到我们最新活动现场,请仔细阅读此短信,并将链接分享给朋友:https://x.wrtdalent.cn/aedrex',
phone: '13675569095',
carrier: '中国移动',
region: '成都',
wordCount: 100,
billingCount: 1,
channel: '三网西南堡垒卡 上海高流量 C77021',
status: 'success',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '三网行业-黄峰-三网-编号3.3', sentAt: '2026-01-02 18:38:02', receiptAt: '2026-01-02 18:38:05', receiptCode: '2' },
{ id: '2', channel: '三网行业-黄峰(循环号用)-三网-编号3.4', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:07', receiptCode: 'UDJDF' },
{ id: '3', channel: '移动映华北-上海富煌C60289-移动2.7', sentAt: '2026-01-02 18:38:07', receiptAt: '2026-01-02 18:38:15', receiptCode: 'VKIJ' },
{ id: '4', channel: '三网行业-北京富慧互联-三网-编号3.5', sentAt: '2026-01-02 18:38:15', receiptAt: '2026-01-02 18:38:23', receiptCode: 'DELIVRD' },
],
},
{
id: 'SMSR202601020002',
enterprise: '重庆进载数智',
application: '应用2',
submittedAt: '2025-12-31 11:49:10',
content: '【宜信易贷】限额提升:宜昌市用土工业 宜昌市营商会金 宜昌市营销线上信用 共同提出新行业更新工商登记变更名主动、您由是联机工具...',
phone: '18607638087',
carrier: '中国联通',
region: '重庆',
wordCount: 150,
billingCount: 2,
channel: '112383 三网合群 第部 三三 郭划3.5 CMPP2.0(32-27-0)',
status: 'unknown',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '112383 三网合群 第部 三三 郭划3.5', sentAt: '2026-01-02 18:37:58', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
{ id: '2', channel: '三网行业-西南备用-编号2.1', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:10', receiptCode: 'UNKNOWN' },
],
},
{
id: 'SMSR202601020003',
enterprise: '行业',
application: '应用3',
submittedAt: '2025-12-31 11:49:08',
content: '【瓷慧坤营销】联系联络:重点开拓士工业 宜昌市营商会金 宜昌市营销线上信用共同提出新行业更新工商登记变更名',
phone: 'XXXX市 持动',
carrier: '未知',
region: '未知',
wordCount: 80,
billingCount: 1,
channel: '未知',
status: 'unknown',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '未知通道', sentAt: '2026-01-02 18:38:01', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
],
},
{
id: 'SMSR202601020004',
enterprise: '超感世纪互三网',
application: '应用4',
submittedAt: '2025-12-31 11:47:23',
content: '【南京邮银】南京市权益和金融互实消市证就增幅有限公司 为您开通了一款全新、友联、诚信的创新模块:https://baWF.cn/s6/1WNnOaAy5i',
phone: '15250668026',
carrier: '中国电信',
region: '南京',
wordCount: 120,
billingCount: 1,
channel: '1069017 三网行业-北京商在互动-三网-第0.4-CMPP2.0(25-5-0)',
status: 'failed',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '1069017 三网行业-北京商在互动-三网-第0.4', sentAt: '2026-01-02 18:38:00', receiptAt: '2026-01-02 18:38:04', receiptCode: 'UNDELIV' },
{ id: '2', channel: '北京备用-CMPP2.0', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:09', receiptCode: 'FAILED' },
],
},
];
function getDate(value: string) {
return value.slice(0, 10);
}
function StatusLine({ status }: { status: SendStatus }) {
return (
<span className="admin-sms-record-status">
<i className={statusDotClassMap[status]} />
{statusLabelMap[status]}
</span>
);
}
function SendDetailModal({ record, onClose }: { record: SmsRecord; onClose: () => void }) {
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title="发送详情"
>
<div className="admin-sms-send-detail">
<section>
<h3></h3>
<p className="admin-sms-detail-content">{record.content}</p>
</section>
<div className="admin-sms-route-list">
{record.routes.map((route) => (
<article key={route.id}>
<span>{route.id}</span>
<div>
<strong>{route.channel}</strong>
<dl>
<div>
<dt></dt>
<dd>{route.sentAt}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptAt}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptCode}</dd>
</div>
</dl>
</div>
</article>
))}
</div>
</div>
</Modal>
);
}
export function AdminSmsRecordsPage() {
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [channelKeyword, setChannelKeyword] = useState('');
const [status, setStatus] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsRecord | null>(null);
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [error, setError] = useState('');
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(recordsSeed.map((item) => item.enterprise)));
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
function loadData() {
adminApi.listAdminMessages({ phoneNumber: phoneKeyword || undefined, status: status === 'all' ? undefined : status })
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
}
useEffect(() => {
loadData();
}, []);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(recordsSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [enterprise]);
const filteredRows = useMemo(
() => recordsSeed.filter((item) => {
const submittedDate = getDate(item.submittedAt);
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
const matchesApplication = application === 'all' || item.application === application;
() => records.filter((item) => {
const submittedDate = item.queuedAt.slice(0, 10);
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesChannel = !channelKeyword || item.channel.includes(channelKeyword);
const matchesStatus = status === 'all' || item.status === status;
return matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate && matchesPhone && matchesContent && matchesChannel && matchesStatus;
return matchesStartDate && matchesEndDate && matchesContent;
}),
[application, channelKeyword, contentKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status],
[contentKeyword, dateRange.end, dateRange.start, records],
);
const columns: Array<TableColumn<SmsMessageRecord>> = [
{ key: 'messageId', title: '消息编号', width: '220px', render: (record) => <strong>{record.messageId}</strong> },
{ key: 'phone', title: '手机号', width: '140px', render: (record) => record.phoneNumber },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'billing', title: '计费', width: '120px', render: (record) => `${record.billingUnits} 条 / ¥${(record.amountCents / 100).toFixed(2)}` },
{ key: 'queuedAt', title: '提交时间', width: '190px', render: (record) => record.queuedAt },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
function resetFilters() {
setEnterprise('all');
setApplication('all');
setDateRange({});
setPhoneKeyword('');
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
}
@@ -248,95 +79,56 @@ export function AdminSmsRecordsPage() {
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-sms-record-filter">
<Select
label="企业"
onChange={(event) => {
setEnterprise(event.target.value);
setApplication('all');
}}
options={enterpriseOptions}
value={enterprise}
/>
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
<Select
label="发送状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部', value: 'all' },
{ label: '发送成功', value: 'success' },
{ label: '发送成功', value: 'delivered' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
]}
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} variant="ghost">CSV</Button>
</div>
<div className="ui-table-wrap">
<table className="ui-table admin-sms-record-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th></th>
<th style={{ width: '170px' }}></th>
<th style={{ width: '300px' }}></th>
<th style={{ textAlign: 'right', width: '120px' }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={5}></td>
</tr>
) : filteredRows.map((record) => (
<tr key={record.id}>
<td>
<div className="admin-sms-record-sender">
<strong>{record.enterprise}</strong>
<span>{record.application}</span>
<small>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11)}</small>
</div>
</td>
<td><p className="admin-sms-record-content">{record.content}</p></td>
<td>
<div className="admin-sms-record-phone">
<strong>{record.phone}</strong>
<span>{record.region} {record.carrier}</span>
<small>{record.wordCount}/{record.billingCount}</small>
</div>
</td>
<td>
<div className="admin-sms-record-channel">
<strong>{record.channel}</strong>
<StatusLine status={record.status} />
<span>{record.receiptAt}</span>
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="surface">
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
<Pagination total={filteredRows.length} />
</div>
{selectedRecord ? <SendDetailModal onClose={() => setSelectedRecord(null)} record={selectedRecord} /> : null}
<Modal
footer={<Button onClick={() => setSelectedRecord(null)} variant="ghost"></Button>}
onClose={() => setSelectedRecord(null)}
open={Boolean(selectedRecord)}
size="xl"
title="发送详情"
>
{selectedRecord ? (
<div className="admin-sms-send-detail">
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{selectedRecord.content}</p>
</section>
<section>
<h3></h3>
<p>{selectedRecord.messageId}</p>
<p>{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}</p>
<p>{selectedRecord.errorMessage ?? '-'}</p>
</section>
</div>
) : null}
</Modal>
</section>
);
}
+2 -2
View File
@@ -62,7 +62,7 @@ export function AdminTemplateAuditPage() {
icon={<Check size={15} />}
onClick={() => void reviewTemplate(record.id, 'approved')}
size="sm"
variant="secondary"
variant="success"
>
</Button>
@@ -71,7 +71,7 @@ export function AdminTemplateAuditPage() {
icon={<X size={15} />}
onClick={() => void reviewTemplate(record.id, 'rejected')}
size="sm"
variant="ghost"
variant="danger"
>
</Button>
-193
View File
@@ -1,193 +0,0 @@
import { adminService } from '@/mock';
import { readLocalData, writeLocalData } from '@/mock/storage';
export type EnterpriseStatus = 'active' | 'disabled';
export type EnterpriseRecord = {
id: string;
name: string;
creditCode: string;
province: string;
city: string;
address: string;
contactName: string;
contactIdCard: string;
contactPhone: string;
contactEmail: string;
balance: number;
overdraftLimit: number;
todaySpend: number;
status: EnterpriseStatus;
};
export type EnterpriseForm = {
id?: string;
name: string;
creditCode: string;
province: string;
city: string;
address: string;
contactName: string;
contactIdCard: string;
contactPhone: string;
contactEmail: string;
};
const STORAGE_KEY = 'admin-enterprises';
export const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '正常', value: 'active' },
{ label: '已禁用', value: 'disabled' },
];
export const provinceOptions = [
{ label: '请选择省/直辖市', value: '' },
{ label: '上海', value: '上海' },
{ label: '广东', value: '广东' },
{ label: '北京', value: '北京' },
{ label: '浙江', value: '浙江' },
{ label: '四川', value: '四川' },
];
export const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
: [{ label: '上海市', value: '上海市' }],
广: [{ label: '深圳市', value: '深圳市' }, { label: '广州市', value: '广州市' }],
: [{ label: '北京市', value: '北京市' }],
: [{ label: '杭州市', value: '杭州市' }],
: [{ label: '成都市', value: '成都市' }],
};
export const initialEnterpriseForm: EnterpriseForm = {
name: '',
creditCode: '',
province: '',
city: '',
address: '',
contactName: '',
contactIdCard: '',
contactPhone: '',
contactEmail: '',
};
function buildEnterpriseRecords(): EnterpriseRecord[] {
const customers = adminService.getCustomers();
const seed = [
{ id: '2763', city: '上海市', province: '上海', spend: 1123.4, overdraft: 1000, code: '91310000MA1K2763X1' },
{ id: '9213', city: '上海市', province: '上海', spend: 256.3, overdraft: 0, code: '91310000MA1K9213X2' },
{ id: '2345', city: '深圳市', province: '广东', spend: 97.25, overdraft: 0, code: '91440300MA1K2345X3' },
{ id: '3431', city: '北京市', province: '北京', spend: 66.2, overdraft: 0, code: '91110108MA1K3431X4' },
{ id: '2313', city: '杭州市', province: '浙江', spend: 12, overdraft: 0, code: '91330100MA1K2313X5' },
{ id: '5621', city: '广州市', province: '广东', spend: 2.51, overdraft: 0, code: '91440100MA1K5621X6' },
{ id: '7834', city: '成都市', province: '四川', spend: 0, overdraft: 0, code: '91510100MA1K7834X7' },
];
return seed.map((item, index) => {
const customer = customers[index % customers.length];
return {
id: item.id,
name: item.id === '2763' ? '上海XXXXX科技有限公司' : customer.name.replace('云舟', 'XXXXX'),
creditCode: item.code,
province: item.province,
city: item.city,
address: `${item.city}示例路 ${index + 1}`,
contactName: customer.contact,
contactIdCard: `31010119900${index + 1}01001X`,
contactPhone: `1380000${String(index + 1).padStart(4, '0')}`,
contactEmail: `contact${index + 1}@example.com`,
balance: index === 1 ? -256.3 : customer.balance / 100,
overdraftLimit: item.overdraft,
todaySpend: item.spend,
status: index === 0 || index === 2 ? 'disabled' : 'active',
};
});
}
export function getEnterpriseRecords() {
return readLocalData<EnterpriseRecord[]>(STORAGE_KEY, buildEnterpriseRecords());
}
export function saveEnterpriseRecords(records: EnterpriseRecord[]) {
writeLocalData(STORAGE_KEY, records);
}
export function createEnterprise(form: EnterpriseForm) {
const records = getEnterpriseRecords();
const nextId = String(Math.max(...records.map((record) => Number(record.id)), 1000) + 1);
const nextRecord: EnterpriseRecord = {
id: nextId,
name: form.name,
creditCode: form.creditCode,
province: form.province,
city: form.city,
address: form.address,
contactName: form.contactName,
contactIdCard: form.contactIdCard,
contactPhone: form.contactPhone,
contactEmail: form.contactEmail,
balance: 0,
overdraftLimit: 0,
todaySpend: 0,
status: 'active',
};
saveEnterpriseRecords([nextRecord, ...records]);
return nextRecord;
}
export function updateEnterprise(form: EnterpriseForm) {
if (!form.id) {
return;
}
saveEnterpriseRecords(getEnterpriseRecords().map((record) => (
record.id === form.id
? {
...record,
name: form.name,
creditCode: form.creditCode,
province: form.province,
city: form.city,
address: form.address,
contactName: form.contactName,
contactIdCard: form.contactIdCard,
contactPhone: form.contactPhone,
contactEmail: form.contactEmail,
}
: record
)));
}
export function toggleEnterpriseStatus(id: string) {
const records = getEnterpriseRecords().map((record) => (
record.id === id
? {
...record,
status: (record.status === 'active' ? 'disabled' : 'active') as EnterpriseStatus,
}
: record
));
saveEnterpriseRecords(records);
return records;
}
export function toEnterpriseForm(record: EnterpriseRecord): EnterpriseForm {
return {
id: record.id,
name: record.name,
creditCode: record.creditCode,
province: record.province,
city: record.city,
address: record.address,
contactName: record.contactName,
contactIdCard: record.contactIdCard,
contactPhone: record.contactPhone,
contactEmail: record.contactEmail,
};
}
export function formatCurrency(value: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
+74 -87
View File
@@ -1,5 +1,5 @@
import { Fragment, useMemo, useState } from 'react';
import { Clock3, Eye, FileText, Search, StopCircle, TrendingUp } from 'lucide-react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
import {
Button,
DateRangeInput,
@@ -7,21 +7,36 @@ import {
DetailProgressStats,
DetailSection,
DetailTitle,
getRateTone,
InlineTextPreview,
Input,
Modal,
Pagination,
ProgressBar,
QueryPanel,
RateCard,
RateOverview,
Select,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { clientService, type BatchTask, type BatchTaskStatus } from '@/mock';
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
type BatchTask = {
id: string;
backendId: string;
applicationName: string;
submittedAt: string;
phoneCount: number;
wordCount: number;
sendType: 'immediate' | 'scheduled';
scheduledAt?: string | null;
sentCount: number;
deliveredCount: number;
failedCount: number;
totalCount: number;
templateContent: string;
status: BatchTaskStatus;
};
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
@@ -35,22 +50,6 @@ const statusLabelMap: Record<BatchTaskStatus, string> = {
terminated: '已终止',
};
const carrierStats = [
{ name: '中国移动', success: 738, total: 750, rate: 98.4 },
{ name: '中国联通', success: 443, total: 450, rate: 98.44 },
{ name: '中国电信', success: 294, total: 300, rate: 98 },
];
const cityStats = [
{ city: '北京', total: 300, success: 295 },
{ city: '上海', total: 280, success: 276 },
{ city: '深圳', total: 250, success: 246 },
{ city: '广州', total: 220, success: 215 },
{ city: '杭州', total: 200, success: 197 },
{ city: '成都', total: 150, success: 148 },
{ city: '武汉', total: 100, success: 98 },
];
function splitSignature(content: string) {
const match = content.match(/^【(.+?)】(.+)$/);
return {
@@ -60,7 +59,7 @@ function splitSignature(content: string) {
}
function getProgress(task: BatchTask) {
return Math.round((task.sentCount / task.totalCount) * 100);
return task.totalCount > 0 ? Math.round((task.sentCount / task.totalCount) * 100) : 0;
}
function getBillingCount(task: BatchTask) {
@@ -68,21 +67,59 @@ function getBillingCount(task: BatchTask) {
}
function getDeliveredCount(task: BatchTask) {
if (task.status === 'completed') {
return Math.round(task.totalCount * 0.9833);
}
return task.deliveredCount;
}
return task.sentCount;
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 {
return {
id: task.taskNo || task.id,
backendId: task.id,
applicationName: task.application?.name ?? task.applicationId ?? '未绑定应用',
submittedAt: task.createdAt,
phoneCount: task.phoneTotal,
wordCount: [...task.content].length,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
sentCount: task.progressSent,
deliveredCount: task.progressDelivered,
failedCount: task.progressFailed,
totalCount: task.progressTotal || task.phoneTotal,
templateContent: task.content,
status: normalizeTaskStatus(task.status),
};
}
export function ClientBatchTasksPage() {
const [tasks, setTasks] = useState(() => clientService.getBatchTasks());
const [tasks, setTasks] = useState<BatchTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [keyword, setKeyword] = useState('');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
function loadTasks() {
setLoading(true);
clientApi.listBatchTasks()
.then((items) => {
setTasks(items.map(mapTask));
setError('');
})
.catch((reason: Error) => setError(reason.message || '批量任务加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadTasks();
}, []);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.applicationName)));
return [
@@ -101,7 +138,11 @@ export function ClientBatchTasksPage() {
});
function terminateTask(id: string) {
setTasks(clientService.terminateBatchTask(id));
const source = tasks.find((item) => item.id === id);
if (!source) return;
clientApi.cancelBatchTask(source.backendId)
.then(loadTasks)
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
}
const columns: Array<TableColumn<BatchTask>> = [
@@ -200,6 +241,8 @@ export function ClientBatchTasksPage() {
</QueryPanel>
<div className="surface batch-table-card">
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="ui-table-wrap">
<table className="ui-table batch-table">
<thead>
@@ -287,66 +330,10 @@ export function ClientBatchTasksPage() {
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
{ label: '发送失败数量', value: selectedTask.failedCount.toLocaleString('zh-CN') },
]}
/>
</DetailSection>
<DetailSection title={<><TrendingUp size={20} /> </>}>
{(() => {
const overallRate = (getDeliveredCount(selectedTask) / selectedTask.totalCount) * 100;
return (
<RateOverview
label="总体成功率"
metrics={[
{ label: '成功总数', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
{ label: '总计', value: selectedTask.totalCount.toLocaleString('zh-CN') },
]}
rate={overallRate}
tone={getRateTone(overallRate)}
/>
);
})()}
<h4></h4>
<div className="carrier-rate-grid">
{carrierStats.map((item) => (
<RateCard
key={item.name}
meta={<><span>: {item.success}</span><span>: {item.total}</span></>}
rate={item.rate}
title={item.name}
tone={getRateTone(item.rate)}
/>
))}
</div>
<h4></h4>
<div className="ui-table-wrap">
<table className="ui-table city-rate-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{cityStats.map((item) => {
const rate = (item.success / item.total) * 100;
return (
<tr key={item.city}>
<td><strong>{item.city}</strong></td>
<td>{item.total}</td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{item.success}</span></td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{rate.toFixed(2)}%</span></td>
<td><ProgressBar percent={rate} tone={getRateTone(rate)} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</DetailSection>
</div>
) : null}
</Modal>
+30 -8
View File
@@ -1,9 +1,28 @@
import { useEffect, useState } from 'react';
import { CreditCard } from 'lucide-react';
import { Button, Tag } from '@/components/ui';
import { clientService } from '@/mock';
import { clientApi, type BillingPlan } from '@/api/adminApi';
export function ClientBillingPage() {
const plans = clientService.getBillingPlans();
const [plans, setPlans] = useState<BillingPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
clientApi.listPlans()
.then((items) => {
setPlans(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '充值套餐加载失败'))
.finally(() => setLoading(false));
}, []);
function createOrder(plan: BillingPlan) {
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
.catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
}
return (
<section className="page-stack">
@@ -14,21 +33,24 @@ export function ClientBillingPage() {
</div>
</div>
<div className="surface">
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="plan-grid">
{plans.map((plan) => (
<article className={['plan-card', plan.highlight ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
<div className="section-heading">
<h2>{plan.name}</h2>
{plan.highlight ? <Tag tone="accent"></Tag> : null}
{plan.smsUnits >= 100000 ? <Tag tone="accent"></Tag> : null}
</div>
<strong>{plan.messages.toLocaleString('zh-CN')} </strong>
<p className="muted"></p>
<Button icon={<CreditCard size={16} />} variant={plan.highlight ? 'primary' : 'ghost'}>
¥{plan.price.toLocaleString('zh-CN')}
<strong>{plan.smsUnits.toLocaleString('zh-CN')} </strong>
<p className="muted">{plan.description ?? '适合阶段性短信发送和活动通知。'}</p>
<Button icon={<CreditCard size={16} />} onClick={() => createOrder(plan)} variant={plan.smsUnits >= 100000 ? 'primary' : 'ghost'}>
¥{(plan.amountCents / 100).toLocaleString('zh-CN')}
</Button>
</article>
))}
</div>
{!loading && !error && plans.length === 0 ? <p className="muted"></p> : null}
</div>
</section>
);
+50 -2
View File
@@ -1,5 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { Table, Tag, type TableColumn } from '@/components/ui';
import { clientService, type Invoice } from '@/mock';
import { clientApi, type AccountTransaction, type RechargeOrder } from '@/api/adminApi';
type Invoice = {
id: string;
title: string;
messages: number;
amount: number;
createdAt: string;
status: 'paid' | 'pending' | 'failed';
};
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
paid: 'success',
@@ -23,6 +33,42 @@ const columns: Array<TableColumn<Invoice>> = [
];
export function ClientInvoicesPage() {
const [orders, setOrders] = useState<RechargeOrder[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([clientApi.listOrders(), clientApi.listTransactions()])
.then(([orderItems, transactionItems]) => {
setOrders(orderItems);
setTransactions(transactionItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '账单流水加载失败'))
.finally(() => setLoading(false));
}, []);
const rows = useMemo<Invoice[]>(() => [
...orders.map((item) => ({
id: item.orderNo,
title: item.payMethod === 'manual_topup' ? '人工充值' : '充值订单',
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: item.status === 'paid' ? 'paid' as const : item.status === 'failed' ? 'failed' as const : 'pending' as const,
})),
...transactions.map((item) => ({
id: item.id,
title: item.remark ?? item.transactionType,
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: 'paid' as const,
})),
].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), [orders, transactions]);
return (
<section className="page-stack">
<div className="page-heading">
@@ -32,7 +78,9 @@ export function ClientInvoicesPage() {
</div>
</div>
<div className="surface">
<Table columns={columns} data={clientService.getInvoices()} rowKey="id" />
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<Table columns={columns} data={rows} emptyText="暂无账单流水" rowKey="id" />
</div>
</section>
);
+57 -39
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { Button, DateTimeInput, Input, Modal, Select, Tag } from '@/components/ui';
import { clientService, type RecentMessage } from '@/mock';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type SmsBatchTask } from '@/api/adminApi';
type Recipient = {
id: string;
@@ -11,47 +11,52 @@ type Recipient = {
type SendMode = 'now' | 'scheduled';
type ReceiverMode = 'manual' | 'import';
const smsApplications = [
{ label: '会员营销平台', value: 'member' },
{ label: '订单通知系统', value: 'order' },
{ label: '登录认证服务', value: 'auth' },
];
export function ClientSendPage() {
const templates = clientService.getTemplates();
const signatures = clientService.getSignatures();
const approvedTemplates = templates.filter((item) => item.status === 'approved');
const approvedSignatures = signatures.filter((item) => item.status === 'approved');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [error, setError] = useState('');
const [taskName, setTaskName] = useState('');
const [applicationId, setApplicationId] = useState('');
const [signatureId, setSignatureId] = useState('');
const [templateId, setTemplateId] = useState('');
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [templateKeyword, setTemplateKeyword] = useState('');
const [messageContent, setMessageContent] = useState('');
const [sendMode, setSendMode] = useState<SendMode>('now');
const [scheduledAt, setScheduledAt] = useState('');
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
const [submittedRecord, setSubmittedRecord] = useState<RecentMessage | null>(null);
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
useEffect(() => {
Promise.all([clientApi.listApplications(), clientApi.listTemplates({ status: 'approved' }), clientApi.listSignatures()])
.then(([applicationItems, templateItems, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateItems);
setSignatures(signatureItems.filter((item) => item.auditStatus === 'approved'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信发送基础数据加载失败'));
}, []);
const selectedSignature = useMemo(
() => approvedSignatures.find((item) => item.id === signatureId),
[approvedSignatures, signatureId],
() => signatures.find((item) => item.id === signatureId),
[signatures, signatureId],
);
const selectedTemplate = useMemo(
() => approvedTemplates.find((item) => item.id === templateId),
[approvedTemplates, templateId],
() => templates.find((item) => item.id === templateId),
[templates, templateId],
);
const filteredTemplates = approvedTemplates.filter((item) => (
const filteredTemplates = templates.filter((item) => (
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
));
const validRecipients = recipients.filter((item) => item.phone.trim());
const previewText = selectedSignature && selectedTemplate
? `${selectedSignature.name}${selectedTemplate.content}`
: '请选择签名和模板';
const previewText = selectedSignature && messageContent
? `${selectedSignature.name}${messageContent}`
: messageContent;
const wordCount = previewText.length;
const smsParts = Math.max(1, Math.ceil(wordCount / 70));
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
const estimatedCount = validRecipients.length * smsParts;
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
@@ -68,7 +73,9 @@ export function ClientSendPage() {
}
function chooseTemplate(id: string) {
const template = templates.find((item) => item.id === id);
setTemplateId(id);
setMessageContent(template?.content ?? '');
setTemplatePickerOpen(false);
}
@@ -77,17 +84,20 @@ export function ClientSendPage() {
return;
}
const nextRecord: RecentMessage = {
id: `MSG-${Date.now().toString().slice(-6)}`,
scene: selectedTemplate?.name ?? taskName,
count: estimatedCount,
channel: '华东主通道',
status: sendMode === 'now' ? 'info' : 'warning',
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
};
clientService.addRecentMessage(nextRecord);
setSubmittedRecord(nextRecord);
clientApi.createBatchTask({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
phones: validRecipients.map((item) => item.phone.trim()),
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
})
.then((task) => {
setSubmittedRecord(task);
setError('');
})
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
}
return (
@@ -97,8 +107,9 @@ export function ClientSendPage() {
<Send size={22} />
</span>
<h1></h1>
{submittedRecord ? <Tag tone="success"> {submittedRecord.id}</Tag> : null}
{submittedRecord ? <Tag tone="success"> {submittedRecord.taskNo}</Tag> : null}
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="sms-send-layout">
<div className="sms-send-main">
@@ -117,7 +128,7 @@ export function ClientSendPage() {
<Select
label="短信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '选择应用', value: '' }, ...smsApplications]}
options={[{ label: '选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Select
@@ -125,7 +136,7 @@ export function ClientSendPage() {
onChange={(event) => setSignatureId(event.target.value)}
options={[
{ label: '选择签名', value: '' },
...approvedSignatures.map((item) => ({ label: item.name, value: item.id })),
...signatures.map((item) => ({ label: item.name, value: item.id })),
]}
value={signatureId}
/>
@@ -245,6 +256,13 @@ export function ClientSendPage() {
<div className="phone-preview">
<div>{previewText}</div>
</div>
<Textarea
label="模板内容"
onChange={(event) => setMessageContent(event.target.value)}
placeholder="选择模板后可在这里编辑模板内容和变量"
rows={7}
value={messageContent}
/>
<div className="preview-stats">
<div>
<span></span>
@@ -252,7 +270,7 @@ export function ClientSendPage() {
</div>
<div>
<span></span>
<strong>{estimatedCount || 1} /</strong>
<strong>{estimatedCount} </strong>
</div>
<div>
<span></span>
+129 -287
View File
@@ -1,175 +1,88 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature } from '@/api/adminApi';
type CarrierStatus = 'approved' | 'pending' | 'rejected';
type DrainageInfo = {
id: string;
siteName: string;
content: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
submittedAt: string;
};
type SignatureItem = {
id: string;
name: string;
application: string;
accent: 'green' | 'blue' | 'red' | 'gray';
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
drainage: DrainageInfo[];
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
draft: 'warning',
};
const initialSignatures: SignatureItem[] = [
{
id: 'sig-1',
name: '【科技公司】',
application: '营销推广',
accent: 'green',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
drainage: [
{ id: 'drain-1', siteName: '官方网站', content: 'https://www.example.com', mobile: 'approved', unicom: 'approved', telecom: 'approved', submittedAt: '2024-01-08 11:00:00' },
{ id: 'drain-2', siteName: '促销活动页', content: 'https://promo.example.com', mobile: 'approved', unicom: 'pending', telecom: 'pending', submittedAt: '2024-01-09 10:30:00' },
],
},
{
id: 'sig-2',
name: '【客户服务】',
application: '通知服务',
accent: 'blue',
mobile: 'approved',
unicom: 'pending',
telecom: 'approved',
drainage: [
{ id: 'drain-3', siteName: '客服入口', content: 'https://service.example.com', mobile: 'approved', unicom: 'pending', telecom: 'approved', submittedAt: '2024-01-10 09:12:00' },
],
},
{
id: 'sig-3',
name: '【验证码】',
application: '验证码',
accent: 'green',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
drainage: [],
},
{
id: 'sig-4',
name: '【促销活动】',
application: '百<>会员推广',
accent: 'red',
mobile: 'rejected',
unicom: 'approved',
telecom: 'pending',
drainage: [
{ id: 'drain-4', siteName: '会员活动页', content: 'https://vip.example.com', mobile: 'rejected', unicom: 'approved', telecom: 'pending', submittedAt: '2024-01-11 13:42:00' },
],
},
{
id: 'sig-5',
name: '【会员中心】',
application: '会员服务',
accent: 'gray',
mobile: 'pending',
unicom: 'pending',
telecom: 'pending',
drainage: [],
},
];
function UploadBox({ label, compact = false }: { label?: string; compact?: boolean }) {
return (
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
{label ? <span>{label}</span> : null}
<Upload size={compact ? 30 : 42} />
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
{!compact ? <small> PNGJPGJPEG 3M</small> : null}
</div>
);
}
function SignatureForm({ signature }: { signature?: SignatureItem }) {
return (
<div className="signature-form">
<section>
<h3></h3>
<div className="signature-alert">
<Info size={18} />
<span>使PNGJPG或JPEG格式的正版文件3M</span>
</div>
<div className="signature-form-grid">
<Select label="* 签名依据" options={[{ label: '请选择签名依据', value: '' }, { label: '企事业单位证明', value: 'company' }]} defaultValue={signature ? 'company' : ''} />
<Input label="* 短信签名" defaultValue={signature?.name ?? ''} placeholder="请输入短信签名,如【XXXX公司】" />
</div>
<UploadBox label="* 资质凭证" />
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 公司名称" defaultValue={signature?.application ?? ''} placeholder="请输入公司名称" />
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
<UploadBox compact label="法人身份证照片-人像面" />
<UploadBox compact label="法人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 责任人姓名" placeholder="请输入责任人姓名" />
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
<Input label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
<Input label="责任人邮箱" placeholder="请输入责任人邮箱" />
<UploadBox compact label="责任人身份证照片-人像面" />
<UploadBox compact label="责任人身份证照片-国徽面" />
</div>
</section>
</div>
);
}
const statusLabel: Record<string, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
draft: '草稿',
disabled: '已禁用',
};
export function ClientSignaturesPage() {
const [signatures, setSignatures] = useState(initialSignatures);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [expandedId, setExpandedId] = useState('');
const [signatureModal, setSignatureModal] = useState<{ mode: 'add' | 'edit'; signature?: SignatureItem } | null>(null);
const [editingDrainage, setEditingDrainage] = useState<{ signature: SignatureItem; drainage?: DrainageInfo } | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [applicationId, setApplicationId] = useState('');
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [file, setFile] = useState<File | null>(null);
const filteredSignatures = signatures.filter((item) => (
item.name.includes(keyword) || item.application.includes(keyword)
));
function deleteSignature(id: string) {
setSignatures((items) => items.filter((item) => item.id !== id));
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listSignatures()])
.then(([applicationItems, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setSignatures(signatureItems.filter((item) => item.auditStatus !== 'disabled' && item.auditStatus !== 'deleted'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '签名数据加载失败'))
.finally(() => setLoading(false));
}
function deleteDrainage(signatureId: string, drainageId: string) {
setSignatures((items) => items.map((item) => (
item.id === signatureId ? { ...item, drainage: item.drainage.filter((drainage) => drainage.id !== drainageId) } : item
)));
useEffect(() => {
loadData();
}, []);
const filteredSignatures = useMemo(() => signatures.filter((item) => (
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
)), [keyword, signatures]);
async function createSignature() {
try {
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose });
if (file) {
const fileObject = await clientApi.createFileObject({
objectKey: `signature-materials/${signature.id}/${Date.now()}-${file.name}`,
fileName: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
purpose: 'signature_material',
});
await clientApi.createSignatureMaterial(signature.id, {
fileObjectId: fileObject.id,
materialType: file.type.startsWith('image/') ? 'image' : 'file',
title: file.name,
});
}
await clientApi.submitSignature(signature.id);
setModalOpen(false);
setApplicationId('');
setName('');
setPurpose('');
setFile(null);
loadData();
} catch (reason) {
setError(reason instanceof Error ? reason.message : '签名提交失败');
}
}
function disableSignature(id: string) {
clientApi.changeSignatureStatus(id, 'disabled')
.then(loadData)
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
}
return (
@@ -179,154 +92,83 @@ export function ClientSignaturesPage() {
<span className="sms-send-title__icon">
<FilePenLine size={22} />
</span>
<h1></h1>
<h1></h1>
</div>
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal({ mode: 'add' })}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}></Button>
</div>
<div className="signature-search-row">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索签名名称或用途"
placeholder="搜索签名名称、用途或应用"
prefix={<Search size={17} />}
value={keyword}
/>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="signature-list">
{filteredSignatures.map((signature) => {
const expanded = expandedId === signature.id;
return (
<article className={`signature-card signature-card--${signature.accent}`} key={signature.id}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div>
<span></span>
<strong>{signature.name}</strong>
</div>
<div>
<span></span>
<strong>{signature.application}</strong>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.mobile]}>{statusLabelMap[signature.mobile]}</Tag>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.unicom]}>{statusLabelMap[signature.unicom]}</Tag>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.telecom]}>{statusLabelMap[signature.telecom]}</Tag>
</div>
<div>
<span></span>
<strong>{signature.drainage.length} </strong>
</div>
<div className="signature-actions">
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ mode: 'edit', signature })} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => deleteSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
{filteredSignatures.map((signature) => (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<div>
<span></span>
<strong>{signature.name}</strong>
</div>
{expanded ? (
<div className="drainage-panel">
<h2></h2>
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{signature.drainage.map((item) => (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<a href={item.content}>{item.content}</a>
<Tag tone={statusToneMap[item.mobile]}>{statusLabelMap[item.mobile]}</Tag>
<Tag tone={statusToneMap[item.unicom]}>{statusLabelMap[item.unicom]}</Tag>
<Tag tone={statusToneMap[item.telecom]}>{statusLabelMap[item.telecom]}</Tag>
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setEditingDrainage({ signature, drainage: item })} size="sm" variant="ghost"></Button>
<Button onClick={() => deleteDrainage(signature.id, item.id)} size="sm" variant="danger"></Button>
</span>
</div>
))}
</div>
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setEditingDrainage({ signature })} size="sm" variant="ghost">
</Button>
</div>
</div>
) : null}
</article>
);
})}
<div>
<span></span>
<strong>{signature.purpose ?? '-'}</strong>
</div>
<div>
<span></span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
</div>
<div>
<span></span>
<strong>{signature.materials?.length ?? 0} </strong>
</div>
<div className="signature-actions">
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
</div>
</article>
))}
</div>
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null}
<Modal
footer={
footer={(
<>
<Button variant="ghost" onClick={() => setSignatureModal(null)}></Button>
<Button onClick={() => setSignatureModal(null)}></Button>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!name} onClick={createSignature}></Button>
</>
}
onClose={() => setSignatureModal(null)}
open={Boolean(signatureModal)}
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title={<div className="signature-modal-title"><h2>{signatureModal?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p></p></div>}
title="添加签名"
>
<SignatureForm signature={signatureModal?.signature} />
</Modal>
<Modal
footer={
<>
<Button variant="ghost" onClick={() => setEditingDrainage(null)}></Button>
<Button onClick={() => setEditingDrainage(null)}></Button>
</>
}
onClose={() => setEditingDrainage(null)}
open={Boolean(editingDrainage)}
size="xl"
title={<div className="signature-modal-title"><h2></h2><p>{editingDrainage?.signature.name}</p></div>}
>
{editingDrainage ? (
<div className="signature-form">
<section>
<h3></h3>
<Input label="* 引流信息:" defaultValue={editingDrainage.drainage?.content ?? 'https://www.example.com'} />
<div className="signature-alert">
<Info size={18} />
<span>1 使2 PNGJPG或JPEG格式的正版文件3M3 pdf格式或者图片10M</span>
</div>
<div className="signature-form-grid">
<UploadBox compact label="* 字段名称1" />
<Input label="* 字段名称2" defaultValue={editingDrainage.drainage?.siteName ?? '官方网站'} />
<Input label="* 字段名称3" placeholder="请输入公司名称" />
<Input label="* 字段名称4" placeholder="请输入统一社会信用代码" />
<Input label="* 字段名称5" placeholder="请输入法人姓名" />
<Input label="* 字段名称6" placeholder="请输入法人身份证号" />
<div className="signature-file-line">
<span>7</span>
<Button size="sm"></Button>
<small></small>
</div>
<Input label="* 字段名称8" placeholder="请输入责任人身份证号" />
<Input label="* 字段名称9" placeholder="请输入责任人姓名" />
<Input label="* 字段名称10" placeholder="请输入责任人手机号" />
</div>
</section>
</div>
) : null}
<div className="signature-form">
<Select
label="短信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【某某科技】" value={name} />
<Input label="用途" onChange={(event) => setPurpose(event.target.value)} placeholder="请输入签名用途" value={purpose} />
<label className="signature-upload">
<Upload size={36} />
<strong>{file ? file.name : '上传资质图片或文件'}</strong>
<small>PDFWord </small>
<input
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
type="file"
/>
</label>
</div>
</Modal>
</section>
);
+113 -220
View File
@@ -1,207 +1,76 @@
import { useMemo, useState } from 'react';
import { MessageSquare, Plus, Search, Info } from 'lucide-react';
import { Button, Input, Modal, Select, Textarea } from '@/components/ui';
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
type TemplateAccent = 'green' | 'blue' | 'red';
type SmsTemplateCard = {
id: string;
name: string;
application: string;
hash: string;
content: string;
variables: string[];
updatedAt: string;
accent: TemplateAccent;
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
draft: 'warning',
};
const recommendedVariables = [
['验证码', 'code'],
['手机号', 'phone'],
['姓名', 'name'],
['日期', 'date'],
['金额', 'amount'],
['时间', 'time'],
['余额', 'balance'],
['地址', 'address'],
['天数', 'days'],
['快递单号', 'trackingNumber'],
['案件号', 'caseNumber'],
['课程名称', 'courseName'],
['链接', 'link'],
['站点', 'station'],
];
const initialTemplates: SmsTemplateCard[] = [
{
id: 'tpl-1',
name: '营销领券',
application: '营销推广平台',
hash: '1c37f4da7c4a4a63',
content: '尊敬的${time}客户!您于${time}在有效期${expiryTime},基础${party}有优惠元。',
variables: ['time', 'time', 'expiryTime', 'party'],
updatedAt: '2026-01-04 17:45:36',
accent: 'green',
},
{
id: 'tpl-2',
name: '南通申诉受理',
application: '客户服务系统',
hash: '8e8a32c9d7b14c6a',
content: '尊敬的${caseNumber}客户!您的${responder}已受理,当事人:${responder},当联总台/本人在任你定义您的档案表返。${url}。',
variables: ['caseNumber', 'responder', 'responder', 'url'],
updatedAt: '2026-01-04 17:46:30',
accent: 'blue',
},
{
id: 'tpl-3',
name: '商城通知',
application: '营销推广平台',
hash: '9f8b2d3e5a7c4f2',
content: '亲爱的${username},您的订单已发货,预计${days}个工作日送达。物流单号:${trackingNumber},可通过官网查询物流信息。',
variables: ['username', 'days', 'trackingNumber'],
updatedAt: '2026-01-03 14:20:16',
accent: 'green',
},
{
id: 'tpl-4',
name: '支付通知',
application: '客户服务系统',
hash: '7d6c43e21f9a5d8',
content: '尊敬的客户,您的账户已收到${date}的款项${amount}元,账户余额${balance}元。如有疑问请联系客服${phone}。',
variables: ['date', 'amount', 'balance', 'phone'],
updatedAt: '2026-01-04 08:30:22',
accent: 'green',
},
{
id: 'tpl-5',
name: '课程提醒',
application: '营销推广平台',
hash: '3a5b678d9ef42aa',
content: '${name}同学您好,您预约的${courseName}课程将于${time}开始,请提前进入直播间,课程链接:${link}',
variables: ['name', 'courseName', 'time', 'link'],
updatedAt: '2026-01-03 16:55:40',
accent: 'blue',
},
{
id: 'tpl-6',
name: '派件通知',
application: '客户服务系统',
hash: '6e4f23ad4c7d8e1',
content: '${name}您的快递已到达${station},快递员${courier}正在派件中:${address}。',
variables: ['name', 'station', 'courier', 'address'],
updatedAt: '2026-01-04 11:20:18',
accent: 'red',
},
];
const statusLabel: Record<string, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
draft: '草稿',
disabled: '已禁用',
};
function extractVariables(content: string) {
return Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]);
}
function TemplateModal({
mode,
template,
onClose,
}: {
mode: 'add' | 'edit';
template?: SmsTemplateCard;
onClose: () => void;
}) {
const [content, setContent] = useState(template?.content ?? '');
const [variablesOpen, setVariablesOpen] = useState(false);
const variables = extractVariables(content);
const wordCount = content.length;
const billingCount = Math.max(1, Math.ceil(wordCount / 70));
function insertVariable(name: string) {
setContent((current) => `${current}\${${name}}`);
}
return (
<Modal
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={onClose}></Button>
</>
}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{mode === 'edit' ? '编辑模板' : '添加模板'}</h2><p></p></div>}
>
<div className="template-form">
<Select
label="* 应用:"
defaultValue={template?.application ?? ''}
options={[
{ label: '请选择应用', value: '' },
{ label: '营销推广平台', value: '营销推广平台' },
{ label: '客户服务系统', value: '客户服务系统' },
]}
/>
<Select
label="* 签名:"
options={[
{ label: '请选择签名', value: '' },
{ label: '【科技公司】', value: '科技公司' },
{ label: '【客户服务】', value: '客户服务' },
]}
/>
<Textarea
label="* 模板内容:"
onChange={(event) => setContent(event.target.value)}
placeholder="请输入模板内容"
value={content}
/>
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
+ {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{wordCount} {billingCount} </span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
<h3></h3>
<div className="template-variable-buttons">
{recommendedVariables.map(([label, value]) => (
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
))}
</div>
<h3></h3>
<div className="template-custom-variable">
<Input placeholder="英文字符或数字" />
<Button onClick={() => insertVariable('custom')}></Button>
</div>
</div>
) : null}
<div className="template-info-tip">
<Info size={18} />
<span>=++ 70 1 67 1 </span>
</div>
{variables.length ? (
<div className="template-current-vars">
<span></span>
{variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
</div>
) : null}
</div>
</Modal>
);
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
}
export function ClientTemplatesPage() {
const [templates, setTemplates] = useState(initialTemplates);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [keyword, setKeyword] = useState('');
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; template?: SmsTemplateCard } | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [applicationId, setApplicationId] = useState('');
const [name, setName] = useState('');
const [content, setContent] = useState('');
const filteredTemplates = templates.filter((item) => (
item.name.includes(keyword) || item.application.includes(keyword)
));
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
.then(([applicationItems, templateItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
.finally(() => setLoading(false));
}
function deleteTemplate(id: string) {
setTemplates((items) => items.filter((item) => item.id !== id));
useEffect(() => {
loadData();
}, []);
const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
)), [keyword, templates]);
function createTemplate() {
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
clientApi.createTemplate({ applicationId, name, content, variables })
.then((created) => clientApi.submitTemplate(created.id))
.then(() => {
setModalOpen(false);
setApplicationId('');
setName('');
setContent('');
loadData();
})
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
}
function disableTemplate(id: string) {
clientApi.changeTemplateStatus(id, 'disabled')
.then(loadData)
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
}
return (
@@ -218,42 +87,66 @@ export function ClientTemplatesPage() {
<div className="template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称应用..."
placeholder="搜索模板名称应用或内容"
prefix={<Search size={17} />}
value={keyword}
/>
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}></Button>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{filteredTemplates.map((template) => (
<article className={`template-card template-card--${template.accent}`} key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application}</p>
<p className="template-hash">{template.hash}</p>
<p className="template-content">{template.content}</p>
<div className="template-vars">
<span></span>
{template.variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
</div>
<div className="template-card-footer">
<span>{template.updatedAt}</span>
<div>
<button onClick={() => setModalState({ mode: 'edit', template })} type="button"></button>
<button onClick={() => deleteTemplate(template.id)} type="button"></button>
{filteredTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
return (
<article className="template-card template-card--green" key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application?.name ?? template.applicationId}</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<p className="template-content">{template.content}</p>
<div className="template-vars">
<span></span>
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted"></span>}
</div>
</div>
</article>
))}
<div className="template-card-footer">
<span>{template.updatedAt}</span>
<div>
<button onClick={() => disableTemplate(template.id)} type="button">
<Trash2 size={14} />
</button>
</div>
</div>
</article>
);
})}
</div>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null}
{modalState ? (
<TemplateModal
mode={modalState.mode}
onClose={() => setModalState(null)}
template={modalState.template}
/>
) : null}
<Modal
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}></Button>
</>
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加短信模板"
>
<div className="template-form">
<Select
label="短信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '请选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Input label="模板名称" onChange={(event) => setName(event.target.value)} placeholder="请输入模板名称" value={name} />
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
</div>
</Modal>
</section>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'warning';
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
+2 -9
View File
@@ -10,7 +10,6 @@ import {
PanelLeftClose,
PanelLeftOpen,
Search,
Sparkles,
} from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { clearSession } from '@/api/session';
@@ -47,7 +46,6 @@ type AppShellProps = {
export function AppShell({
title,
subtitle,
workspaceName,
loginPath,
userName,
@@ -85,13 +83,8 @@ export function AppShell({
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : ''].filter(Boolean).join(' ')}>
<aside className="sidebar">
<div className="brand-block">
<div className="brand-icon">
<Sparkles size={18} />
</div>
<div className="brand-copy">
<div className="brand-mark">{title}</div>
<div className="brand-subtitle">{subtitle}</div>
</div>
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
</div>
<nav className="side-nav" aria-label="主导航">
+26
View File
@@ -58,6 +58,26 @@
background: var(--color-selected-hover);
}
.ui-button--success {
background: rgba(22, 163, 74, 0.12);
color: #15803d;
}
.ui-button--success:hover {
background: rgba(22, 163, 74, 0.2);
color: #166534;
}
.ui-button--warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.ui-button--warning:hover {
background: rgba(245, 158, 11, 0.22);
color: #92400e;
}
.ui-button--ghost {
background: var(--color-surface);
border-color: var(--color-border);
@@ -79,6 +99,12 @@
background: #b91c1c;
}
.ui-button--danger .ui-button__icon,
.ui-button--danger .ui-button__icon svg {
color: #fff;
stroke: #fff;
}
.ui-button--icon-only {
padding: 0;
width: var(--control-height-md);
+81 -34
View File
@@ -126,7 +126,7 @@ h3 {
}
.app-shell--collapsed {
grid-template-columns: 84px minmax(0, 1fr);
grid-template-columns: 76px minmax(0, 1fr);
}
.sidebar {
@@ -146,19 +146,25 @@ h3 {
align-items: center;
display: flex;
gap: var(--space-3);
padding: 0 var(--space-2) var(--space-2);
justify-content: center;
padding: 0 0 var(--space-2);
}
.brand-icon {
align-items: center;
background: var(--color-selected-soft);
border: 1px solid rgba(37, 99, 235, 0.14);
border-radius: var(--radius-lg);
color: var(--color-selected);
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
.brand-logo {
display: block;
flex: 0 0 auto;
object-fit: contain;
}
.brand-logo--full {
height: 50px;
width: 148px;
}
.brand-logo--compact {
display: none;
height: 34px;
width: 34px;
}
.brand-copy {
@@ -187,6 +193,7 @@ h3 {
flex: 1;
gap: var(--space-5);
overflow-y: auto;
overflow-x: hidden;
margin-right: calc(var(--space-4) * -1);
padding-right: calc(var(--space-4) - 2px);
scrollbar-color: transparent transparent;
@@ -377,20 +384,41 @@ h3 {
display: none;
}
.app-shell--collapsed .brand-block {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.app-shell--collapsed .brand-logo--full {
display: none;
}
.app-shell--collapsed .brand-logo--compact {
display: block;
}
.app-shell--collapsed .side-nav {
width: 100%;
overflow-x: hidden;
}
.app-shell--collapsed .side-nav-list {
justify-items: center;
padding-left: 0;
width: 100%;
}
.app-shell--collapsed .side-nav a {
height: 42px;
height: 44px;
justify-content: center;
padding: 0;
width: 42px;
width: 44px;
}
.app-shell--collapsed .side-nav a svg {
height: 20px;
width: 20px;
}
.app-shell--collapsed .side-nav a.active {
@@ -3860,51 +3888,57 @@ h3 {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
padding: 36px 20px;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(16, 185, 129, 0.08)),
radial-gradient(circle at 18% 16%, rgba(37, 99, 235, 0.12), transparent 32%),
radial-gradient(circle at 82% 14%, rgba(16, 185, 129, 0.1), transparent 30%),
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(15, 23, 42, 0.04)),
var(--color-bg);
}
.login-panel {
width: min(440px, 100%);
padding: 28px;
border: 1px solid var(--color-border);
width: min(428px, 100%);
padding: 34px 34px 30px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.14);
}
.login-brand {
display: flex;
align-items: center;
display: grid;
justify-items: center;
gap: 14px;
margin-bottom: 24px;
margin-bottom: 30px;
text-align: center;
}
.login-brand > span {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 8px;
color: var(--color-primary);
background: var(--color-primary-soft);
.login-brand img {
display: block;
width: 178px;
height: 64px;
object-fit: contain;
}
.login-brand h1 {
margin: 0;
color: var(--color-text-strong);
font-size: 24px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.15;
}
.login-brand p {
margin: 4px 0 0;
margin: 8px 0 0;
color: var(--color-text-muted);
font-size: 15px;
font-weight: 500;
}
.login-form {
display: grid;
gap: 16px;
gap: 17px;
}
.login-captcha-row {
@@ -3918,7 +3952,7 @@ h3 {
height: 40px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-muted);
background: linear-gradient(180deg, #fff, var(--color-surface-muted));
color: var(--color-text-strong);
font-weight: 700;
cursor: pointer;
@@ -8181,6 +8215,19 @@ h3 {
display: revert;
}
.app-shell--collapsed .brand-block {
justify-content: flex-start;
padding: 0 var(--space-2) var(--space-2);
}
.app-shell--collapsed .brand-logo--full {
display: block;
}
.app-shell--collapsed .brand-logo--compact {
display: none;
}
.app-shell--collapsed .side-nav a {
height: auto;
justify-content: flex-start;