fix: clarify signature reporting status

This commit is contained in:
hectorzhao
2026-07-13 10:16:12 +08:00
parent 551edfd8ca
commit 344b2afe3b
13 changed files with 140 additions and 26 deletions
@@ -128,4 +128,20 @@ describe('DictionariesService', () => {
); );
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1); expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
}); });
it('only accepts Arabic numerals and English letters in report field codes', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
await expect(service.createDrainageField({ code: 'License2026', name: '营业执照', fieldType: 'image' })).resolves.toEqual(
expect.objectContaining({ code: 'License2026' }),
);
expect(() => service.createDrainageField({ code: 'license_code', name: '营业执照', fieldType: 'image' })).toThrow(
'code must contain only Arabic numerals and English letters',
);
expect(() => service.createDrainageField({ code: '营业执照', name: '营业执照', fieldType: 'image' })).toThrow(
'code must contain only Arabic numerals and English letters',
);
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
});
}); });
+5 -1
View File
@@ -255,12 +255,16 @@ export class DictionariesService {
} }
createDrainageField(data: CreateDrainageFieldDto) { createDrainageField(data: CreateDrainageFieldDto) {
const code = data.code?.trim();
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
throw new BadRequestException('code must contain only Arabic numerals and English letters');
}
if (!['string', 'image', 'file'].includes(data.fieldType)) { if (!['string', 'image', 'file'].includes(data.fieldType)) {
throw new BadRequestException('fieldType must be string, image or file'); throw new BadRequestException('fieldType must be string, image or file');
} }
return this.prisma.drainageField.create({ return this.prisma.drainageField.create({
data: { data: {
code: data.code, code,
name: data.name, name: data.name,
fieldType: data.fieldType, fieldType: data.fieldType,
required: data.required ?? false, required: data.required ?? false,
@@ -181,7 +181,7 @@
### 4.7 通道签名报备 ### 4.7 通道签名报备
1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段类型只允许字符串、图片、文件三种。通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段代码只允许阿拉伯数字和英文大小写字母,字段类型只允许字符串、图片、文件三种。通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。
2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。 2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。
3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。 3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。
4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。 4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。
@@ -421,6 +421,8 @@
- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。 - 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。
- 企业应用新增/编辑必须展示发送队列等级,并真实保存普通队列或优先队列配置;列表或详情应能看到该配置,便于运营核对高优先级应用。 - 企业应用新增/编辑必须展示发送队列等级,并真实保存普通队列或优先队列配置;列表或详情应能看到该配置,便于运营核对高优先级应用。
- 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。 - 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。
- 所有企业和企业应用下拉控件必须支持按名称搜索;企业应用管理列表不展示 AppID。
- 企业短信模板列表必须采用自适应布局,在常用桌面及平板视口下无需水平滚动即可看到编辑、删除等操作。
### 5.12 运营端审核 ### 5.12 运营端审核
+4
View File
@@ -3205,6 +3205,10 @@ npm run verify:phase8
| --- | --- | --- | | --- | --- | --- |
| TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端充值套餐、账单流水、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态套餐、任务、模板、签名或最近发送记录。 | | TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端充值套餐、账单流水、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态套餐、任务、模板、签名或最近发送记录。 |
| TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 | | TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 |
| TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 |
| TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 |
| TC-ADMIN-REPORT-FIELD-CODE-001 | 在报备字段库分别提交 `License2026`、`license_code`、中文和空白代码,并直接调用真实新增 API 复验。 | 只有 `License2026` 写入 PostgreSQL;前端阻止非法值,API 同样返回 400,不依赖前端校验。 |
| TC-ADMIN-CHANNEL-REPORT-SIGNATURE-001 | 打开包含数据库签名 `【安徽航天信息】` 的通道报备详情及签名详情弹窗。 | 两处均只显示单层 `【安徽航天信息】`,不出现重复中括号。 |
| TC-MOCK-CLEAN-003 | 运营端创建企业、编辑企业、禁用/启用企业、删除企业,再刷新页面和重新登录客户端。 | Tenant 状态持久化;列表刷新后状态不丢;禁用/删除企业阻断客户端业务访问;动作写系统日志。 | | TC-MOCK-CLEAN-003 | 运营端创建企业、编辑企业、禁用/启用企业、删除企业,再刷新页面和重新登录客户端。 | Tenant 状态持久化;列表刷新后状态不丢;禁用/删除企业阻断客户端业务访问;动作写系统日志。 |
| TC-MOCK-CLEAN-004 | 客户端提交签名材料文件、创建模板并提交审核,运营端查看企业签名和企业模板列表。 | 文件元数据和材料关联写入后端;签名/模板进入真实审核状态;运营端列表可查到同一条记录。 | | TC-MOCK-CLEAN-004 | 客户端提交签名材料文件、创建模板并提交审核,运营端查看企业签名和企业模板列表。 | 文件元数据和材料关联写入后端;签名/模板进入真实审核状态;运营端列表可查到同一条记录。 |
| TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 | | TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 |
+7
View File
@@ -1645,9 +1645,16 @@ git diff --check
- 已执行 `send-chain.service.spec.ts` 40 项通过,覆盖全局 reporting 可发、主通道未通过而备用通道通过时选备用,以及所有候选均未通过时拒绝;API build 和前端 build 通过。 - 已执行 `send-chain.service.spec.ts` 40 项通过,覆盖全局 reporting 可发、主通道未通过而备用通道通过时选备用,以及所有候选均未通过时拒绝;API build 和前端 build 通过。
- 已提交并 push `ade06058` 后部署生产;部署前完成 PostgreSQL 与 `eab05958` 运行源码备份,无待执行 migration。生产 `.deployed-commit=ade06058``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 与外部 HTTP 200。生产源码已确认包含 approved 通道候选过滤;【安徽航天信息】当前仍为全局 reporting、一个通道 approved、一个通道 pending,未主动发送计费短信,交由用户使用真实业务流量验证只走 approved 通道。 - 已提交并 push `ade06058` 后部署生产;部署前完成 PostgreSQL 与 `eab05958` 运行源码备份,无待执行 migration。生产 `.deployed-commit=ade06058``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 与外部 HTTP 200。生产源码已确认包含 approved 通道候选过滤;【安徽航天信息】当前仍为全局 reporting、一个通道 approved、一个通道 pending,未主动发送计费短信,交由用户使用真实业务流量验证只走 approved 通道。
## 2026-07-13 企业签名审核与通道报备状态展示分离
- 生产核查【安徽航天信息】确认状态保存成功:两个 `carrier=all` 通道分别为 approved、reporting,三网真实汇总均为 reporting 且 1/2 通过;Bug 在于前端将 reporting 转换成旧 pending,再显示成“审核中”,并丢失通过数/总数。
- 企业签名列表和报备详情改为直接使用 API `carrierReportSummary.status/approved/total`:展示未报备、报备中、部分通过(x/y)、全部通过(x/y)、资料待补充、报备失败和不适用;签名 `auditStatus` 另列显示草稿、待审核、已通过、已驳回。详情同时列出每个目标通道的真实状态,不再读取 `drainageInfo.carrierStatus` 冒充当前汇总。
- 按用户授权,本次将工作区其他会话的字段代码校验、Select 搜索、应用列表及样式等改动一并测试和发布;API 全量 13 suites、137 项通过,API build、前端 build、`git diff --check` 通过。
## 2026-07-13 企业页面可用性与报备展示修正 ## 2026-07-13 企业页面可用性与报备展示修正
- 企业短信模板卡片改为自适应列宽,卡片和操作区允许换行,避免中等视口下固定三列造成水平滚动。 - 企业短信模板卡片改为自适应列宽,卡片和操作区允许换行,避免中等视口下固定三列造成水平滚动。
- 企业应用管理列表隐藏 AppID 列;共用 `Select` 对标签中包含“企业”或“应用”的下拉自动提供名称搜索,选项仍来自各页已接入的真实 API。 - 企业应用管理列表隐藏 AppID 列;共用 `Select` 对标签中包含“企业”或“应用”的下拉自动提供名称搜索,选项仍来自各页已接入的真实 API。
- 报备字段代码在前端和 NestJS API 双层限制为 `A-Z/a-z/0-9`;API 会拒绝下划线、中文、空白等非法代码,防止绕过页面写入 PostgreSQL。 - 报备字段代码在前端和 NestJS API 双层限制为 `A-Z/a-z/0-9`;API 会拒绝下划线、中文、空白等非法代码,防止绕过页面写入 PostgreSQL。
- 运营端浏览器标题更新为“聆界短信管理平台”。通道报备详情和签名详情弹窗在展示前剔除数据中已存在的外层中/英文括号,统一只渲染一层中括号。 - 运营端浏览器标题更新为“聆界短信管理平台”。通道报备详情和签名详情弹窗在展示前剔除数据中已存在的外层中/英文括号,统一只渲染一层中括号。
- 本地验证:API 全量 13 suites、137 项通过,API build、前端 build 和 `git diff --check` 通过;前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由、非空 DOM、无框架错误覆盖,且标题为“聆界短信管理平台”;因本地未启动真实 API,验证码请求返回 502,无法进入登录后页面完成模板响应式、下拉搜索和签名实数据的视觉点击验收,未注入 mock 或绕过认证。截图阶段应用内浏览器页面挂载超时,未将截图标记为通过。
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/logo/fav.ico" /> <link rel="icon" href="/logo/fav.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMPP 短信平台</title> <title>聆界短信管理平台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7 -2
View File
@@ -26,6 +26,11 @@ function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
} }
function formatSignatureName(value?: string | null) {
const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, '');
return `${name || '-'}`;
}
function drainageItems(signature?: ClientSmsSignature) { function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo); const payload = asRecord(signature?.drainageInfo);
return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : []; return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : [];
@@ -47,7 +52,7 @@ function DetailModal({ drainage, signature, task, onClose }: { drainage?: Draina
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
<div className="channel-report-detail"> <div className="channel-report-detail">
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : `${signature?.name ?? task.signature?.name ?? '-'}`}</strong> <strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p> <p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p> <p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null} {!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
@@ -170,7 +175,7 @@ export function AdminChannelReportPage() {
return <div key={task.id}> return <div key={task.id}>
<div className="channel-report-row channel-report-row--signature"> <div className="channel-report-row channel-report-row--signature">
<span /> <span />
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{signature?.name ?? task.signature?.name ?? '-'}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div> <div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
<ReportStatus value={task.status} /> <ReportStatus value={task.status} />
<DateTime value={task.createdAt} /> <DateTime value={task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} /> <DateTime value={imported?.createdAt ?? exported?.createdAt} />
+3 -2
View File
@@ -34,6 +34,7 @@ export function AdminDrainageFieldsPage() {
const [fieldType, setFieldType] = useState<ReportFieldType>('string'); const [fieldType, setFieldType] = useState<ReportFieldType>('string');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
function loadData() { function loadData() {
adminApi.listDrainageFields() adminApi.listDrainageFields()
@@ -106,7 +107,7 @@ export function AdminDrainageFieldsPage() {
footer={( footer={(
<> <>
<Button onClick={() => setCreating(false)} variant="ghost"></Button> <Button onClick={() => setCreating(false)} variant="ghost"></Button>
<Button disabled={!code || !name} onClick={createField}></Button> <Button disabled={!code || !name || Boolean(codeError)} onClick={createField}></Button>
</> </>
)} )}
onClose={() => setCreating(false)} onClose={() => setCreating(false)}
@@ -114,7 +115,7 @@ export function AdminDrainageFieldsPage() {
title="添加报备字段" title="添加报备字段"
> >
<div className="admin-system-modal-form"> <div className="admin-system-modal-form">
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} /> <Input error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} /> <Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select <Select
label="字段类型" label="字段类型"
@@ -331,7 +331,6 @@ export function AdminEnterpriseApplicationsPage() {
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [ const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> }, { key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise }, { key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` }, { key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')}` },
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` }, { key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)}` }, { key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)}` },
@@ -78,6 +78,26 @@ function StatusTag({ status }: { status: CarrierStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>; return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
} }
type CarrierReportSummary = { status: string; approved: number; total: number };
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
if (summary.status === 'approved') return <Tag tone="success">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'failed' || summary.status === 'rejected') return <Tag tone="danger">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'waiting_material') return <Tag tone="warning">{summary.approved}/{summary.total}</Tag>;
if (summary.approved > 0) return <Tag tone="info">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'reporting' || summary.status === 'exporting') return <Tag tone="info">0/{summary.total}</Tag>;
return <Tag tone="neutral">0/{summary.total}</Tag>;
}
function AuditStatusTag({ status }: { status: string }) {
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
};
const current = meta[status] ?? { label: status || '-', tone: 'neutral' as const };
return <Tag tone={current.tone}>{current.label}</Tag>;
}
function readDrainagePayload(signature: ClientSmsSignature) { function readDrainagePayload(signature: ClientSmsSignature) {
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {}; const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {}; const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
@@ -524,7 +544,6 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
} }
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) { function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
const payload = readDrainagePayload(item);
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情">
<div className="admin-report-detail"> <div className="admin-report-detail">
@@ -532,13 +551,15 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div> <div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div> <div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.name}</strong></div> <div><span></span><strong>{item.name}</strong></div>
<div><span></span><AuditStatusTag status={item.auditStatus} /></div>
<div><span></span><strong>{formatDate(item.updatedAt)}</strong></div> <div><span></span><strong>{formatDate(item.updatedAt)}</strong></div>
</div> </div>
<div className="admin-report-tabs"> <div className="admin-report-tabs">
<button className="admin-report-carrier--mobile active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.mobile} /></span></button> <button className="admin-report-carrier--mobile active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
<button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.unicom} /></span></button> <button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><StatusTag status={payload.carrierStatus.telecom} /></span></button> <button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
</div> </div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{target.channel.carrier}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</div> </div>
</Modal> </Modal>
); );
@@ -572,11 +593,6 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
</Modal>; </Modal>;
} }
function summaryStatus(signature: ClientSmsSignature, carrier: 'mobile' | 'unicom' | 'telecom'): CarrierStatus {
const status = signature.carrierReportSummary?.[carrier]?.status;
return status === 'approved' ? 'approved' : status === 'failed' ? 'rejected' : status === 'reporting' ? 'pending' : 'filing';
}
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) { function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情">
@@ -737,10 +753,11 @@ export function AdminEnterpriseSignaturesPage() {
<div className="signature-list admin-enterprise-signature-list"> <div className="signature-list admin-enterprise-signature-list">
{visibleSignatures.map((signature) => { {visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
const effectiveCarrierStatus = { mobile: summaryStatus(signature, 'mobile'), unicom: summaryStatus(signature, 'unicom'), telecom: summaryStatus(signature, 'telecom') }; const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status);
const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray';
const expanded = expandedSignatureId === signature.id; const expanded = expandedSignatureId === signature.id;
return ( return (
<article className={`signature-card signature-card--${signatureCardTone(effectiveCarrierStatus)}`} key={signature.id}> <article className={`signature-card signature-card--${cardTone}`} key={signature.id}>
<div className="signature-summary"> <div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button"> <button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />} {expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
@@ -748,9 +765,10 @@ export function AdminEnterpriseSignaturesPage() {
<div><span></span><strong>{signature.name}</strong></div> <div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div> <div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
<div><span></span><strong>{signature.application?.name ?? '-'}</strong></div> <div><span></span><strong>{signature.application?.name ?? '-'}</strong></div>
<div><span></span><StatusTag status={effectiveCarrierStatus.mobile} /></div> <div><span></span><AuditStatusTag status={signature.auditStatus} /></div>
<div><span></span><StatusTag status={effectiveCarrierStatus.unicom} /></div> <div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
<div><span></span><StatusTag status={effectiveCarrierStatus.telecom} /></div> <div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
<div><span></span><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
<div><span></span><strong>{payload.links.length} </strong></div> <div><span></span><strong>{payload.links.length} </strong></div>
<div className="signature-actions"> <div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button> <Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
+26 -3
View File
@@ -1,6 +1,6 @@
import type { SelectHTMLAttributes } from 'react'; import type { SelectHTMLAttributes } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown, Search } from 'lucide-react';
export type SelectOption = { export type SelectOption = {
label: string; label: string;
@@ -20,6 +20,8 @@ type SelectProps = NativeSelectProps & {
value?: string; value?: string;
defaultValue?: string; defaultValue?: string;
placeholder?: string; placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
onChange?: (event: { target: { value: string } }) => void; onChange?: (event: { target: { value: string } }) => void;
}; };
@@ -35,23 +37,33 @@ export function Select({
onChange, onChange,
disabled, disabled,
placeholder, placeholder,
searchable,
searchPlaceholder,
required, required,
...props ...props
}: SelectProps) { }: SelectProps) {
const selectId = id ?? props.name; const selectId = id ?? props.name;
const rootRef = useRef<HTMLLabelElement | null>(null); const rootRef = useRef<HTMLLabelElement | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? ''); const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
const selectedValue = value ?? internalValue; const selectedValue = value ?? internalValue;
const selectedOption = useMemo( const selectedOption = useMemo(
() => options.find((option) => option.value === selectedValue), () => options.find((option) => option.value === selectedValue),
[options, selectedValue], [options, selectedValue],
); );
const searchEnabled = searchable ?? (typeof label === 'string' && (label.includes('企业') || label.includes('应用')));
const visibleOptions = useMemo(() => {
const normalizedKeyword = searchKeyword.trim().toLocaleLowerCase();
if (!searchEnabled || !normalizedKeyword) return options;
return options.filter((option) => option.label.toLocaleLowerCase().includes(normalizedKeyword));
}, [options, searchEnabled, searchKeyword]);
useEffect(() => { useEffect(() => {
function handlePointerDown(event: PointerEvent) { function handlePointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) { if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false); setOpen(false);
setSearchKeyword('');
} }
} }
@@ -63,6 +75,7 @@ export function Select({
setInternalValue(nextValue); setInternalValue(nextValue);
onChange?.({ target: { value: nextValue } }); onChange?.({ target: { value: nextValue } });
setOpen(false); setOpen(false);
setSearchKeyword('');
} }
return ( return (
@@ -90,7 +103,10 @@ export function Select({
aria-haspopup="listbox" aria-haspopup="listbox"
disabled={disabled} disabled={disabled}
id={selectId} id={selectId}
onClick={() => setOpen((current) => !current)} onClick={() => setOpen((current) => {
if (current) setSearchKeyword('');
return !current;
})}
type="button" type="button"
> >
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}> <span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
@@ -100,7 +116,13 @@ export function Select({
</button> </button>
{open ? ( {open ? (
<div className="ui-select__dropdown" role="listbox"> <div className="ui-select__dropdown" role="listbox">
{options.map((option) => ( {searchEnabled ? (
<label className="ui-select__search">
<Search size={15} />
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
</label>
) : null}
{visibleOptions.map((option) => (
<button <button
aria-selected={option.value === selectedValue} aria-selected={option.value === selectedValue}
key={option.value} key={option.value}
@@ -111,6 +133,7 @@ export function Select({
{option.label} {option.label}
</button> </button>
))} ))}
{visibleOptions.length === 0 ? <span className="ui-select__empty"></span> : null}
</div> </div>
) : null} ) : null}
</span> </span>
+30
View File
@@ -664,6 +664,36 @@
color: var(--color-text-inverse); color: var(--color-text-inverse);
} }
.ui-select__search {
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
display: flex;
gap: var(--space-2);
margin: var(--space-1);
min-height: 36px;
padding: 0 var(--space-3);
}
.ui-select__search:focus-within {
border-color: var(--color-selected);
}
.ui-select__search input {
background: transparent;
border: 0;
color: var(--color-text);
min-width: 0;
outline: 0;
width: 100%;
}
.ui-select__empty {
color: var(--color-text-muted);
padding: var(--space-3);
text-align: center;
}
.ui-tag { .ui-tag {
align-items: center; align-items: center;
border-radius: var(--radius-full); border-radius: var(--radius-full);
+6 -1
View File
@@ -3648,7 +3648,8 @@ h3 {
.template-card-grid { .template-card-grid {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
grid-template-columns: repeat(3, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
min-width: 0;
} }
.template-card { .template-card {
@@ -3658,6 +3659,7 @@ h3 {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
min-height: 300px; min-height: 300px;
min-width: 0;
overflow: hidden; overflow: hidden;
padding: var(--space-6); padding: var(--space-6);
position: relative; position: relative;
@@ -3726,6 +3728,8 @@ h3 {
align-items: center; align-items: center;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: space-between; justify-content: space-between;
margin-top: auto; margin-top: auto;
padding-top: var(--space-4); padding-top: var(--space-4);
@@ -3737,6 +3741,7 @@ h3 {
.template-card-footer div { .template-card-footer div {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-3); gap: var(--space-3);
} }