fix: connect remaining sms pages to real backend
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 的队列消息格式。
|
||||
|
||||
@@ -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 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 |
|
||||
|
||||
@@ -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` 模拟创建、删除、审核、充值、发送、报备成功。
|
||||
- 彩信相关页面当前可保留待开发占位,但测试报告必须单独标注,不得混入短信第一版已完成范围。
|
||||
|
||||
@@ -354,3 +354,39 @@ npm run build
|
||||
- 前端 build:通过,仍有既有大 chunk warning。
|
||||
- `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890`。
|
||||
- 真实数据库迁移、浏览器端登录 smoke 需要在生产验证环境执行 `prisma migrate deploy` 后补充记录。
|
||||
|
||||
## 2026-07-02 非彩信纯 mock 菜单真实化
|
||||
|
||||
### 本轮修复范围
|
||||
|
||||
- 客户端:充值套餐、账单流水、批量任务、短信发送、短信签名、短信模板改为调用真实 API;签名材料使用真实文件元数据和材料关联接口;发送任务调用真实批量任务接口。
|
||||
- 运营端:数据统计、账务账户、发送监控、敏感词、全局黑名单、企业黑名单、手机号段库、报备字段库、通道组、通道报备字段、报备任务、报备记录、短信审核、短信记录改为真实 API。
|
||||
- 企业管理:客户列表、客户表单、客户详情由 `adminEnterpriseMock`/localStorage 改为真实租户、账户、应用、签名、模板接口;后端补充租户编辑、状态变更和删除接口。
|
||||
- 通道管理:删除静态通道兜底,API 失败展示错误态。
|
||||
- 企业认证审核:删除静态认证兜底,API 失败展示错误态。
|
||||
- 企业签名/企业模板运营端列表只展示真实短信配置数据;彩信相关菜单继续作为待开发边界,不计入第一版短信验收。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm --prefix api test
|
||||
npm --prefix api run build
|
||||
npm run build
|
||||
npm run verify:phase8
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- API Jest:10 个 test suite 通过,49 个测试通过。
|
||||
- API build 通过。
|
||||
- 前端 build 通过,仍存在既有大 chunk warning。
|
||||
- `npm run verify:phase8` 通过:
|
||||
- Gateway 队列契约 4 个示例通过。
|
||||
- Go Gateway 测试通过。
|
||||
- BullMQ 15000 条消息、并发 500、端到端 TPS 681.47,满足 500 TPS。
|
||||
- Prisma generate、API build、前端 build 均通过。
|
||||
- 源码搜索剩余静态业务数据集中在彩信待开发页面、彩信审核页面、企业应用彩信 tab,以及 `src/api/session.ts` 的登录 session 持久化;非彩信主菜单的 `clientService`/`adminService` 业务路径已清理。
|
||||
|
||||
### 待复测
|
||||
|
||||
- 浏览器 smoke 和真实文件上传 smoke 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 |
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 |
@@ -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' }) }),
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>支持 Excel、CSV。导入后会按签名、引流内容和通道匹配当前报备记录。</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<section className="page-stack admin-system-page admin-drainage-page">
|
||||
<div className="page-heading">
|
||||
<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>
|
||||
<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 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}
|
||||
/>
|
||||
<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 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 className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<Table columns={columns} data={filteredFields} emptyText="暂无通道报备字段" rowKey="id" />
|
||||
</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>}
|
||||
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button><Button disabled={!channelId || !code || !name} onClick={createField}>保存</Button></>}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="新增通道报备字段"
|
||||
>
|
||||
<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 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>
|
||||
) : 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="个性化引流信息报备字段"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{signatureFieldConfigOpen ? (
|
||||
<DrainageFieldConfigModal
|
||||
description="这些字段会驱动客户端签名资料补充,并映射到当前通道的签名报备导出模板。"
|
||||
fieldPool={signatureFieldPool}
|
||||
fields={signatureFields}
|
||||
onChange={setSignatureFields}
|
||||
onClose={() => setSignatureFieldConfigOpen(false)}
|
||||
title="个性化签名报备字段"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>上传企业识别图片,支持 JPG、PNG 格式,文件大小不超过 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}
|
||||
label="企业状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={status}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
function addItem() {
|
||||
adminApi.createEnterpriseBlacklist({ tenantId, phoneNumber: phone, reason, status: 'active' })
|
||||
.then(() => {
|
||||
setTenantId('');
|
||||
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);
|
||||
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>
|
||||
|
||||
@@ -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>支持 PNG、JPG、JPEG 格式,大小不超过 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>签名需履行报备,并遵照管理部门审核结果方可使用。请用 PNG、JPG 或 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>图片仅支持 PNG、JPG 或 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>彩信最多支持9帧,每帧可以是文字、图片、视频或者音频,内容总大小不超过2000KB。提交后需三大运营商审核。</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
function addItem() {
|
||||
adminApi.createGlobalBlacklist({ phoneNumber: phone, reason, status: 'active' })
|
||||
.then(() => {
|
||||
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);
|
||||
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">
|
||||
返回企业详情
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>支持 Excel、CSV,字段包含签名、引流信息、通道状态和驳回原因。</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>支持 Excel、CSV、PDF 等真实回执文件。</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('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listReportTasks()
|
||||
.then((items) => {
|
||||
setTasks(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredTasks = useMemo(() => tasks.filter((task) => {
|
||||
const text = `${task.id}${task.channel}${task.enterprise}`;
|
||||
const date = task.createdAt.slice(0, 10);
|
||||
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))
|
||||
&& (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]);
|
||||
}), [dateRange.end, dateRange.start, keyword, 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 exportTask(task: ReportTask) {
|
||||
adminApi.createReportExport(task.id, { fileName: `${task.id}.xlsx`, rowCount: 0 })
|
||||
.then(loadData)
|
||||
.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
|
||||
)));
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>支持 PNG、JPG、JPEG 格式,大小不超过 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>签名需履行报备,并遵照管理部门审核结果方可使用。请用PNG、JPG或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}>
|
||||
{filteredSignatures.map((signature) => (
|
||||
<article className="signature-card signature-card--green" 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>
|
||||
<span>用途</span>
|
||||
<strong>{signature.purpose ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>移动</span>
|
||||
<Tag tone={statusToneMap[signature.mobile]}>{statusLabelMap[signature.mobile]}</Tag>
|
||||
<span>审核状态</span>
|
||||
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</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>
|
||||
<span>材料</span>
|
||||
<strong>{signature.materials?.length ?? 0} 份</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>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</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>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="drainage-panel__footer">
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditingDrainage({ signature })} size="sm" variant="ghost">
|
||||
添加引流信息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</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 图片仅支持PNG、JPG或JPEG格式的正版文件,且大小不超过3M;3 文件格式支持pdf格式或者图片,且大小不超过10M。</span>
|
||||
<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>支持图片、PDF、Word 等真实材料文件</small>
|
||||
<input
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</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}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
{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}</p>
|
||||
<p className="template-hash">{template.hash}</p>
|
||||
<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>
|
||||
{template.variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
|
||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||
</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>
|
||||
<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}
|
||||
<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}
|
||||
/>
|
||||
) : null}
|
||||
<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,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> & {
|
||||
|
||||
@@ -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="主导航">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user