fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 22:53:34 +08:00
parent 7f9abe3da0
commit 4665079ca3
22 changed files with 1305 additions and 762 deletions
+178 -43
View File
@@ -1,47 +1,182 @@
.http-developer-docs { margin: 0; color: #1f2937; background: #f6f7f9; font: 14px/1.6 system-ui, sans-serif; }
.http-developer-docs * { box-sizing: border-box; }
.http-developer-docs .http-doc-header { padding: 24px; border-bottom: 1px solid #e5e7eb; background: #fff; display: flex; gap: 20px; justify-content: space-between; align-items: center; }
.http-developer-docs h1 { font-size: 24px; margin: 8px 0; }
.http-developer-docs h2 { font-size: 20px; margin: 0 0 16px; }
.http-developer-docs h3 { font-size: 16px; margin: 20px 0 12px; }
.http-developer-docs p { overflow-wrap: anywhere; }
.http-developer-docs a { color: #2563eb; text-decoration: none; overflow-wrap: anywhere; }
.http-developer-docs a:hover { text-decoration: underline; }
.http-developer-docs .http-doc-actions { display: flex; gap: 16px; flex-wrap: wrap; }
.http-developer-docs .http-doc-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); }
.http-developer-docs nav { padding: 20px; position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; }
.http-developer-docs nav a { display: block; padding: 7px 0; font-size: 13px; }
.http-developer-docs nav label { display: block; margin-top: 20px; }
.http-developer-docs input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; }
.http-developer-docs main { min-width: 0; }
.http-developer-docs section { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); border-bottom: 1px solid #e5e7eb; scroll-margin-top: 20px; }
.http-developer-docs section[hidden] { display: none; }
.http-developer-docs .http-doc-body { padding: 24px; background: #fff; min-width: 0; }
.http-developer-docs aside { min-width: 0; padding: 24px 16px; }
.http-developer-docs .http-doc-sample { margin-bottom: 16px; border: 1px solid #d1d5db; border-radius: 8px; overflow: hidden; background: #fff; }
.http-developer-docs .http-doc-sample-bar { padding: 10px; display: flex; gap: 12px; justify-content: space-between; align-items: center; font-size: 12px; color: #6b7280; }
.http-developer-docs button { padding: 5px 12px; border: 1px solid #d1d5db; border-radius: 6px; color: #1f2937; background: #fff; cursor: pointer; flex-shrink: 0; }
.http-developer-docs button:focus-visible, .http-developer-docs a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.http-developer-docs pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 13px; background: #f4f6f8; }
.http-developer-docs code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
.http-developer-docs .http-doc-table { overflow-x: auto; }
.http-developer-docs table { border-collapse: collapse; min-width: 100%; }
.http-developer-docs td { padding: 9px; border: 1px solid #e5e7eb; min-width: 100px; overflow-wrap: anywhere; }
.http-developer-docs tr:first-child { font-weight: 600; background: #f4f6f8; }
.http-developer-docs .http-doc-copy-status { position: fixed; bottom: 12px; right: 12px; max-width: 80vw; background: #fff; border-radius: 6px; padding: 8px; box-shadow: 0 2px 12px #0002; }
.http-developer-docs .http-doc-copy-status:empty { display: none; }
@media (width <= 1400px) {
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs aside { padding: 16px 24px; }
.http-developer-docs aside:empty { display: none; }
.http-developer-docs {
margin: 0;
color: #1f2937;
background: #f6f7f9;
font:
14px/1.6 system-ui,
sans-serif;
}
.http-developer-docs * {
box-sizing: border-box;
}
.http-developer-docs .http-doc-header {
padding: 24px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
display: flex;
gap: 20px;
justify-content: space-between;
align-items: center;
}
.http-developer-docs h1 {
font-size: 24px;
margin: 8px 0;
}
.http-developer-docs h2 {
font-size: 20px;
margin: 0 0 16px;
}
.http-developer-docs h3 {
font-size: 16px;
margin: 20px 0 12px;
}
.http-developer-docs p {
overflow-wrap: anywhere;
}
.http-developer-docs a {
color: #2563eb;
text-decoration: none;
overflow-wrap: anywhere;
}
.http-developer-docs a:hover {
text-decoration: underline;
}
.http-developer-docs .http-doc-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.http-developer-docs .http-doc-layout {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
}
.http-developer-docs nav {
padding: 20px;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
}
.http-developer-docs nav a {
display: block;
padding: 7px 0;
font-size: 13px;
}
.http-developer-docs nav label {
display: block;
margin-top: 20px;
}
.http-developer-docs input {
width: 100%;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
.http-developer-docs main {
min-width: 0;
}
.http-developer-docs section {
display: block;
border-bottom: 1px solid #e5e7eb;
scroll-margin-top: 20px;
}
.http-developer-docs section[hidden] {
display: none;
}
.http-developer-docs .http-doc-body {
padding: 24px;
background: #fff;
min-width: 0;
}
.http-developer-docs .http-doc-sample {
margin-bottom: 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.http-developer-docs .http-doc-sample-bar {
padding: 10px;
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #6b7280;
}
.http-developer-docs button {
padding: 5px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #1f2937;
background: #fff;
cursor: pointer;
flex-shrink: 0;
}
.http-developer-docs button:focus-visible,
.http-developer-docs a:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.http-developer-docs pre {
margin: 0;
padding: 16px;
overflow: auto;
max-height: 560px;
font-size: 13px;
background: #f4f6f8;
}
.http-developer-docs code {
font-family: ui-monospace, monospace;
overflow-wrap: anywhere;
}
.http-developer-docs .http-doc-table {
overflow-x: auto;
}
.http-developer-docs table {
border-collapse: collapse;
min-width: 100%;
}
.http-developer-docs td {
padding: 9px;
border: 1px solid #e5e7eb;
min-width: 100px;
overflow-wrap: anywhere;
}
.http-developer-docs tr:first-child {
font-weight: 600;
background: #f4f6f8;
}
.http-developer-docs .http-doc-copy-status {
position: fixed;
bottom: 12px;
right: 12px;
max-width: 80vw;
background: #fff;
border-radius: 6px;
padding: 8px;
box-shadow: 0 2px 12px #0002;
}
.http-developer-docs .http-doc-copy-status:empty {
display: none;
}
@media (width <= 700px) {
.http-developer-docs .http-doc-header { padding: 16px; display: block; }
.http-developer-docs .http-doc-layout { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs nav { position: static; max-height: none; padding: 16px; }
.http-developer-docs .http-doc-body, .http-developer-docs aside { padding: 16px; }
.http-developer-docs .http-doc-header {
padding: 16px;
display: block;
}
.http-developer-docs .http-doc-layout {
grid-template-columns: minmax(0, 1fr);
}
.http-developer-docs nav {
position: static;
max-height: none;
padding: 16px;
}
.http-developer-docs .http-doc-body {
padding: 16px;
}
}
.http-developer-docs .http-doc-sample-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.http-developer-docs .http-doc-sample-tabs button[aria-pressed="true"] { background: #eff6ff; color: #2563eb; border-color: #2563eb; }
-7
View File
@@ -1,13 +1,6 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const tab = event.target.closest('button[data-show-sample]');
if (tab) {
const aside = tab.closest('aside');
aside.querySelectorAll('.http-doc-sample').forEach((sample, index) => { sample.hidden = index !== Number(tab.dataset.showSample); });
aside.querySelectorAll('button[data-show-sample]').forEach((button) => button.setAttribute('aria-pressed', String(button === tab)));
return;
}
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
+6 -5
View File
@@ -23,8 +23,8 @@ function inline(value: string): string {
}
export function renderHttpGuide(markdown: string, origin: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const sections: Array<{ id: string; title: string; body: string[]; samples: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
const sections: Array<{ id: string; title: string; body: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[] };
sections.push(current);
let code: string[] | null = null;
let language = '';
@@ -35,7 +35,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.samples.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
current.body.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制代码</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
code = null;
} else code.push(line);
continue;
@@ -46,7 +46,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] };
current = { id: 'section-' + sections.length, title: heading[2], body: [] };
sections.push(current);
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
continue;
@@ -58,8 +58,9 @@ export function renderHttpGuide(markdown: string, origin: string) {
continue;
}
closeTable();
if (line.trim().endsWith('') && line.trim().length < 70) sampleTitle = line.trim().replace(/$/, '');
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div><aside aria-label="${escapeHtml(section.title)} 示例">${section.samples.length > 1 ? '<div class="http-doc-sample-tabs" role="group" aria-label="切换示例">' + section.samples.map((_sample, index) => '<button type="button" data-show-sample="' + index + '" aria-pressed="' + (index === 0) + '">示例 ' + (index + 1) + '</button>').join('') + '</div>' : ''}${section.samples.map((sample, index) => sample.replace('class="http-doc-sample"', 'class="http-doc-sample"' + (index ? ' hidden' : ''))).join('')}</aside></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
}
@@ -167,6 +167,17 @@ describe('HTTP API remediation boundaries', () => {
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
expect(send).not.toHaveBeenCalled();
});
it('keeps named code examples beside their source paragraphs', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'',
);
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
expect(html).toContain('五行原文 · text');
expect(html).not.toContain('data-show-sample');
expect(html).not.toContain('<aside');
expect(html).not.toMatch(/>示例 \d+</);
});
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
@@ -372,6 +372,8 @@ describe('OperationsService', () => {
id: true,
content: true,
hasDrainageContent: true,
receiptStatus: true,
deliveredAt: true,
tenant: { select: { id: true, name: true } },
}),
}),
@@ -90,6 +90,8 @@ export class OperationsMessageQueries {
amountCents: true,
status: true,
submitStatus: true,
receiptStatus: true,
deliveredAt: true,
queuedAt: true,
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# 客户端页面与接口文档整改
## 2026-09-14 客户端页面与接口文档整理设计
本轮按用户要求提交、推送并部署测试环境;不操作预生产,不发送或重新入队短信。基于 main 7f9abe3 实施,发布包含上一轮引流运营商迁移,须独立复核兼容。
- 模板页添加按钮按内容宽度,工具栏使用输入+查询操作+新增三列,窄屏堆叠;正文固定 132px、14px 字体和纵向滚动,覆盖本需求对常规三行截断的例外。页脚使用公共 Button/DeleteRiskAction,移除本页对旧通用 footer 按钮样式的依赖,保留删除风险确认。编辑顺序为应用、名称、签名、内容;不再展示或发送 category,旧数据库分类保留,不清空历史数据。
- 接口文档新增独立客户端路由 /client/http-docs 和导航,从接口配置页移除文档 Tab。共享服务端 /api/client-docs 仍为唯一内容源;示例紧随所属正文,使用说明名称,不按数字分组。第 11 节示例按鉴权、发送、查询、上行、回调、错误处理归并,不修改签名协议/GET 兼容摘要、认证或安全边界。
- 工作台复用批次统一中文状态映射;最近批次列改为短信内容并读取 content,避免使用 category;保留详情入口及批次范围。
- 回执 Bug:测试库已送达记录存在 receiptStatus/deliveredAt,而分页投影未查询两字段,客户白名单视图输出 null。分页补两个主记录标量,不全量加载回执关联、不改回执消费/计费/数据。展示以消息最终聚合状态为准,时间统一北京时间;真实未收到回执保留空时间并明确显示暂无回执。
- 验收覆盖真实 API/PG 的有回执、无回执、失败回执与分页/租户边界;UI 三尺寸、首次进入/刷新/跨路由、长正文滚动、按钮 hover、编辑顺序、文档示例归属及检索/复制;完整测试、类型、构建、样式、安全/标准发布门禁。
@@ -2302,3 +2302,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
- 引流报备按引流资料、通道、运营商独立配置,只有本运营商通过的通道可进入对应短信路由;显式未通过不能继承旧通道级通过状态。材料变化使旧审批失效。
- 发送详情保留敏感词命中及明确异常,不显示正常的零命中检查;审计数据保留。
- 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。
## 2026-09-14 客户端页面与文档调整
模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。
+15
View File
@@ -5506,3 +5506,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 |
执行结果与未执行边界见 testing-progress.md 本日记录。
## 2026-09-14 客户端整改验收
| 编号 | 场景及预期 |
|---|---|
| CLIENT-0914-01 | 模板新增按钮宽度适配文字;长短正文均高 132px,长内容纵向滚动;删除 hover 沿用公共危险按钮,风险确认流程保留。 |
| CLIENT-0914-02 | 新增/编辑依次显示应用、名称、签名、内容;无分类输入,提交不覆写历史 category;签名切换与变量插入保留。 |
| CLIENT-0914-03 | /client/http-docs 独立导航、刷新可达;原接口对接页保留概览/凭据/回调/日志;无应用时仍能读文档。 |
| CLIENT-0914-04 | 3.2 三步签名说明与五行原文/公式相邻;各接口参数后有具名示例,无编号示例切换或独立汇总章节;检索与复制正常且不执行请求。 |
| CLIENT-0914-05 | 工作台列名短信内容,显示 content 而非 category;各批次状态使用共享中文映射。 |
| CLIENT-0914-06 | 分页 API 返回 PG 主记录 receiptStatus/deliveredAt,租户条件保持,不加载全量回执关系;成功、失败、未回执三种展示正确;UTC 07:34:41 显示北京时间 15:34:41。 |
| CLIENT-0914-07 | 最终聚合回执优先于旧尝试回执;无回执显示暂无回执且时间为空;三尺寸、刷新、路由切换和查询/分页语义回归。 |
执行证据与在线验收边界见 testing-progress.md 本轮记录。
+12
View File
@@ -4997,3 +4997,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。
- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。
- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。
## 2026-09-14 客户端模板、接口文档、工作台与回执整改(发布前)
- 起点 main 7f9abe3,实际远端 ac64490,测试机 97d133442350b9725422ed4e55386f47e37004fd。用户明确要求修改、提交、推送、测试部署;预生产、短信发送/重投、客户配置变更不在范围。测试发布包含上一轮引流三网报备提交和 20260914093000_drainage_carrier_reports 兼容迁移。既有 metrics、tools/release 和文档草稿全部保护。
- 只读根因:测试库 http-cmu0xgl6o000k5mle3hrw4avd 为 deliveredreceiptStatus=delivered、deliveredAt=2026-09-14T07:34:41.935Z,回执表原码 DELIVRD;另外两条送达样本一致。旧 listClientMessagesPage 投影三条均 receiptStatus=null、deliveredAt=null、receiptRecords=[],因 listMessagesPage 未选择回执标量。仅补主记录两个标量,不加载全量尝试关系,不改消息状态/回执消费/账务。页面优先聚合最终回执并格式化北京时间。
- 模板 toolbar 改为输入/查询操作/添加三列、窄屏堆叠;正文 132px 纵向滚动;页脚脱离 legacy button 规则,使用公共按钮和原风险确认流程;表单应用、名称、签名、内容,不提交 category,历史值保留。
- /client/http-docs 独立路由与菜单;原接口配置页移除文档 Tab,保留其余功能。读者把示例移回所属正文,取消编号侧栏切换;第 11 节示例分配回鉴权与各接口章节,3.2 重写为三个步骤,37 段非 text 示例哈希逐一保持。新页面与 CSS 同目录登记所有权。
- 工作台复用 batchTaskStatusMeta/normalizeBatchTaskStatus,短信内容列读取 content。设计见 client-ui-remediation-20260914.md,用例 CLIENT-0914-01 至 07。
- 本地 API 74 suites / 812 tests 通过,API 构建通过;前端最终 31 files / 146 testsmaxWorkers=2)通过,类型/生产构建通过;代码 Lint 无错误(3 条旧依赖/any 提示),Stylelint、CSS治理15项、格式、包体及安全门禁通过。保留初始 worker 启动超时、回执标签断言、类型错误、CSS所有权/语法失败,后续修正和重测分开记录。精确发布候选仍需标准 validate。
- 浏览器连接器仍 nodeRepl.fetch request failed;使用已安装 Playwright/Chrome。独立本地 PG 16414 + 真实服务 HTTP 适配器16419 + production preview16420,未启动 Gateway/发送Worker:模板长短正文高度均132px、按钮143px,三尺寸1600×1000/1366×768/390×844无横向溢出;编辑顺序、删除hover、真实回执成功/失败/空值及15:34:41时间、工作台正文/已完成、文档三步示例、检索与复制通过。复制未执行请求。此为真实服务组件验收,非完整在线登录/鉴权验收。
- 本机证据 %TEMP%/cmpp-client-polish-20260914api-full.log、frontend-final.log、css-final.log、style-final.log、format-final.log、browser-results.json 与 templates/receipts/home/docs 三尺寸截图。隔离库仅使用本轮专用样本;测试线上只读抽样。客户端登录账号尚待用户提供,不恢复旧账号、不造认证会话;在线页面与发布结果后续追加。
+52 -23
View File
@@ -1,20 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import {
BadgeCheck,
ClipboardList,
FileText,
PenLine,
Plus,
ReceiptText,
Send,
WalletCards,
} from 'lucide-react';
import { BadgeCheck, ClipboardList, FileText, PenLine, Plus, ReceiptText, Send, WalletCards } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
type RecentTaskRow = {
@@ -28,10 +20,23 @@ type RecentTaskRow = {
const columns: Array<TableColumn<RecentTaskRow>> = [
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
{
key: 'scene',
title: '短信内容',
width: '300px',
render: (record) => <span className="ui-table__long-text">{record.scene}</span>,
},
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')}` },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
{
key: 'status',
title: '状态',
render: (record) => (
<Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>
{batchTaskStatusMeta[normalizeBatchTaskStatus(record.status)].label}
</Tag>
),
},
];
export function ClientHome() {
@@ -40,7 +45,8 @@ export function ClientHome() {
const [error, setError] = useState('');
useEffect(() => {
clientApi.getDashboard()
clientApi
.getDashboard()
.then(setDashboard)
.catch((err) => {
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
@@ -51,18 +57,23 @@ export function ClientHome() {
const account = dashboard?.accounts[0];
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
const recentMessages = useMemo<RecentTaskRow[]>(
() =>
(dashboard?.recentTasks ?? []).map((task) => ({
id: String(task.id ?? task.taskNo),
taskNo: String(task.taskNo ?? task.id),
scene: String(task.category ?? task.content ?? '短信发送'),
scene: String(task.content ?? '-'),
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
status: String(task.status ?? 'unknown'),
})), [dashboard]);
})),
[dashboard],
);
const latestRecharge = dashboard?.recentRecharges[0];
const sendTrendOption = useMemo(
() => createLineOption({
() =>
createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
@@ -72,12 +83,16 @@ export function ClientHome() {
[dashboard],
);
const channelShareOption = useMemo(() => createPieOption({
const channelShareOption = useMemo(
() =>
createPieOption({
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
name: item.status,
value: item._sum.currentConnections ?? item._count._all,
})),
}), [dashboard]);
}),
[dashboard],
);
return (
<section className="page-stack">
@@ -91,7 +106,9 @@ export function ClientHome() {
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
</Button>
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}></Button>
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>
</Button>
</div>
</div>
@@ -179,7 +196,11 @@ export function ClientHome() {
</div>
<div className="overview-grid overview-grid--three">
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/templates')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/templates')}
type="button"
>
<BadgeCheck size={22} />
<div>
<span></span>
@@ -187,7 +208,11 @@ export function ClientHome() {
<small></small>
</div>
</button>
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/signatures')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/signatures')}
type="button"
>
<PenLine size={22} />
<div>
<span></span>
@@ -195,7 +220,11 @@ export function ClientHome() {
<small></small>
</div>
</button>
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/batch-tasks')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/batch-tasks')}
type="button"
>
<ClipboardList size={22} />
<div>
<span></span>
+6 -6
View File
@@ -1,4 +1,3 @@
import { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
import { useEffect, useRef, useState } from 'react';
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import {
@@ -269,8 +268,6 @@ export function ClientHttpApiPage() {
</div>
);
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
const logsPanel = (
<div className="page-stack">
<div className="surface" style={{ padding: 16 }}>
@@ -316,7 +313,6 @@ export function ClientHttpApiPage() {
{ label: '接口概览', value: 'overview', content: overview },
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
{ label: '接口文档', value: 'docs', content: docsPanel },
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
];
@@ -325,7 +321,7 @@ export function ClientHttpApiPage() {
<div className="page-heading">
<div>
<h1></h1>
<p> HTTP 访</p>
<p> HTTP 访</p>
</div>
<Select
label="企业应用"
@@ -360,7 +356,11 @@ export function ClientHttpApiPage() {
</Button>
</div>
) : null}
{applicationId ? <Tabs items={tabs} /> : docsPanel}
{applicationId ? (
<Tabs items={tabs} />
) : (
<p className="muted"></p>
)}
</section>
);
}
+77 -14
View File
@@ -5,39 +5,102 @@ import { ClientBatchTasksPage } from './ClientBatchTasksPage';
import { ClientSendDetailPage } from './ClientSendDetailPage';
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
const { clientApi } = vi.hoisted(() => ({ clientApi: {
listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
} }));
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listApplicationOptions: vi.fn(),
listBatchTasksPage: vi.fn(),
listMessages: vi.fn(),
listUplinkMessagesPage: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
describe('explicit client queries', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
clientApi.listApplicationOptions.mockResolvedValue([]);
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage])
method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
});
it('shows canonical receipt state and Beijing receipt time while keeping missing receipts empty', async () => {
clientApi.listMessages.mockResolvedValue({
items: [
{
id: 'delivered',
content: '有回执',
phoneNumber: '13800138000',
billingUnits: 1,
status: 'delivered',
queuedAt: '2026-09-14T06:00:00Z',
receiptStatus: 'delivered',
deliveredAt: '2026-09-14T07:34:41.935Z',
receiptRecords: [{ rawStatus: 'UNDELIV', deliveredAt: '2026-09-13T01:00:00Z' }],
},
{
id: 'pending',
content: '等待回执',
phoneNumber: '13800138001',
billingUnits: 1,
status: 'submitted',
queuedAt: '2026-09-14T06:00:00Z',
receiptStatus: null,
deliveredAt: null,
},
],
total: 2,
});
render(<ClientSendDetailPage />);
expect(await screen.findByText('送达成功')).toBeInTheDocument();
expect(screen.getByText('15:34:41')).toBeInTheDocument();
expect(screen.getByText('暂无回执')).toBeInTheDocument();
expect(screen.queryByText('未送达')).not.toBeInTheDocument();
});
it.each([
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
{ Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' },
])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => {
render(<MemoryRouter><Component /></MemoryRouter>);
{
Component: ClientUplinkMessagesPage,
method: clientApi.listUplinkMessagesPage,
label: '上行内容',
key: 'keyword',
},
])(
'$label only applies filters on Query or Reset, including pagination back to page one',
async ({ Component, method, label, key }) => {
render(
<MemoryRouter>
<Component />
</MemoryRouter>,
);
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
expect(method).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })),
);
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
);
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })),
);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
);
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })),
);
expect(screen.getByLabelText(label)).toHaveValue('');
});
},
);
});
+87 -38
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, startTransition, useEffect, useMemo, useState } from 'react';
import { FileText, Search, Smartphone } from 'lucide-react';
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import {
@@ -12,7 +12,7 @@ import {
Tag,
type DateRangeValue,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
const statusLabelMap: Record<string, string> = {
delivered: '成功',
@@ -44,25 +44,34 @@ const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> =
timeout: 'danger',
};
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
const latest = record.receiptRecords?.[0] as
{ rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
time: latest?.deliveredAt ?? record.deliveredAt,
status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
time: record.deliveredAt ?? latest?.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
if (!status) return '-';
if (!status) return '暂无回执';
const normalized = status.trim().toUpperCase();
return {
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
return (
{
DELIVERED: '送达成功',
FAILED: '送达失败',
TIMEOUT: '回执超时',
DELIVRD: '送达成功',
ACCEPTD: '已受理',
UNDELIV: '未送达',
REJECTD: '已拒绝',
EXPIRED: '已过期',
DELETED: '已删除',
UNKNOWN: '状态未知',
}[normalized] ??
statusLabelMap[status.toLowerCase()] ??
'状态未知'
);
}
export function ClientSendDetailPage() {
@@ -81,7 +90,8 @@ export function ClientSendDetailPage() {
function loadData(targetPage = page) {
setLoading(true);
clientApi.listMessages({
clientApi
.listMessages({
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
phoneNumber: applied.phoneKeyword.trim() || undefined,
status: applied.status === 'all' ? undefined : applied.status,
@@ -101,20 +111,18 @@ export function ClientSendDetailPage() {
}
useEffect(() => {
loadData(page);
startTransition(() => loadData(page));
}, [applied, page]);
useEffect(() => {
clientApi.listApplicationOptions()
clientApi
.listApplicationOptions()
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
}, []);
const applicationOptions = useMemo(() => {
return [
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
];
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
}, [applications]);
const filteredRows = records;
@@ -129,7 +137,13 @@ export function ClientSendDetailPage() {
}
function reset() {
const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' };
const defaults = {
applicationId: 'all',
status: 'all',
dateRange: recentBeijingDateRange(7),
contentKeyword: '',
phoneKeyword: '',
};
setApplicationId(defaults.applicationId);
setStatus(defaults.status);
setDateRange(defaults.dateRange);
@@ -150,9 +164,18 @@ export function ClientSendDetailPage() {
<QueryPanel
title="查询条件"
summary={<> <strong>{total}</strong> </>}
summary={
<>
<strong>{total}</strong>
</>
}
>
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
<Select
label="应用名称"
onChange={(event) => setApplicationId(event.target.value)}
options={applicationOptions}
value={applicationId}
/>
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
<Select
label="发送状态"
@@ -202,20 +225,33 @@ export function ClientSendDetailPage() {
</thead>
<tbody>
{loading ? (
<tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
<tr>
<td className="ui-table__empty" colSpan={9}>
...
</td>
</tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={9}></td></tr>
) : visibleRows.map((record) => {
<tr>
<td className="ui-table__empty" colSpan={9}>
</td>
</tr>
) : (
visibleRows.map((record) => {
const receipt = getReceipt(record);
const region = record.province ?? '-';
return (
<Fragment key={record.id}>
<tr className="send-detail-main-row">
<td><strong className="send-detail-app-name">{record.application?.name ?? record.applicationId ?? '-'}</strong></td>
<td>
<strong className="send-detail-app-name">
{record.application?.name ?? record.applicationId ?? '-'}
</strong>
</td>
<td>
<span className="send-detail-time">
{record.queuedAt.slice(0, 10)}
<small>{record.queuedAt.slice(11, 19)}</small>
{formatDateTime(record.queuedAt).slice(0, 10)}
<small>{formatDateTime(record.queuedAt).slice(11, 19)}</small>
</span>
</td>
<td style={{ textAlign: 'center' }}>
@@ -224,18 +260,30 @@ export function ClientSendDetailPage() {
<small>{record.billingUnits}</small>
</span>
</td>
<td><strong>{record.phoneNumber}</strong></td>
<td>
<strong>{record.phoneNumber}</strong>
</td>
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
<td><span className="send-detail-region">{region}</span></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
<td>
<span className="send-detail-region">{region}</span>
</td>
<td style={{ textAlign: 'center' }}>
<Tag tone={statusToneMap[record.status] ?? 'info'}>
{statusLabelMap[record.status] ?? '状态未知'}
</Tag>
</td>
<td style={{ textAlign: 'center' }}>
<strong className="send-detail-receipt-code">{receipt.status}</strong>
</td>
<td>
{receipt.time ? (
<span className="send-detail-time">
{receipt.time.slice(0, 10)}
<small>{receipt.time.slice(11, 19)}</small>
{formatDateTime(receipt.time).slice(0, 10)}
<small>{formatDateTime(receipt.time).slice(11, 19)}</small>
</span>
) : <span className="muted">-</span>}
) : (
<span className="muted">-</span>
)}
</td>
</tr>
<tr className="send-detail-content-row">
@@ -248,7 +296,8 @@ export function ClientSendDetailPage() {
</tr>
</Fragment>
);
})}
})
)}
</tbody>
</table>
</div>
+49
View File
@@ -0,0 +1,49 @@
.client-templates-page .client-template-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-4);
}
.client-templates-page .client-template-content {
height: 132px;
overflow-y: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--color-text-muted);
}
.client-templates-page .client-template-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border-top: 1px solid var(--color-border);
padding-top: var(--space-4);
margin-top: auto;
}
.client-templates-page .client-template-footer > span {
font-size: 13px;
color: var(--color-text-muted);
}
.client-templates-page .client-template-footer > div {
display: flex;
gap: var(--space-2);
}
@media (width <= 700px) {
.client-templates-page .client-template-toolbar {
grid-template-columns: minmax(0, 1fr);
justify-items: start;
}
.client-templates-page .client-template-toolbar > .ui-field {
width: 100%;
}
}
@@ -0,0 +1,40 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ClientTemplatesPage } from './ClientTemplatesPage';
vi.mock('@/api/adminApi', () => ({
clientApi: {
listTemplatesPage: vi.fn().mockResolvedValue({
items: [
{
id: 't',
name: '测试模板',
content: '【签名】正文',
auditStatus: 'approved',
applicationId: 'a',
signatureId: 's',
variables: [],
updatedAt: '2026-09-14T00:00:00Z',
},
],
total: 1,
}),
listApplicationOptions: vi.fn().mockResolvedValue([{ id: 'a', name: '应用', status: 'active' }]),
listSignatureOptions: vi
.fn()
.mockResolvedValue([{ id: 's', name: '【签名】', applicationId: 'a', auditStatus: 'approved' }]),
},
}));
describe('client template form', () => {
it('orders requested fields and keeps standard deletion action', async () => {
render(<ClientTemplatesPage />);
await screen.findByText('测试模板');
expect(screen.getByRole('button', { name: '删除' })).toHaveClass('ui-button');
fireEvent.click(screen.getByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog');
const labels = Array.from(dialog.querySelectorAll('.ui-field__label')).map((x) =>
x.textContent?.replace(/\*/g, ''),
);
expect(labels.slice(0, 4)).toEqual(['短信应用', '模板名称', '短信签名', '模板内容']);
expect(within(dialog).queryByText('模板分类')).not.toBeInTheDocument();
});
});
+150 -52
View File
@@ -1,9 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { startTransition, useEffect, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
import {
clientApi,
type ClientSmsApplication,
type ClientSmsSignatureView,
type ClientSmsTemplate,
} from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
import './ClientTemplatesPage.css';
type TemplateVariable = {
name: string;
@@ -15,7 +21,6 @@ type TemplateFormState = {
applicationId: string;
signatureId: string;
name: string;
category: string;
content: string;
variables: TemplateVariable[];
};
@@ -49,8 +54,10 @@ const recommendedVariables = [
];
function extractVariables(content: string): TemplateVariable[] {
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
.map((name) => ({ name, required: true }));
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
name,
required: true,
}));
}
function billingUnits(content: string) {
@@ -77,23 +84,28 @@ function TemplateModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
: (item?.content ?? '');
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
category: item?.category ?? '行业通知',
content: initialContent,
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
variables:
item?.variables?.map((variable) => ({
name: variable.name,
example: variable.example ?? undefined,
required: variable.required ?? true,
})) ?? [],
});
const initialForm = useRef(form).current;
const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const application = applications.find((candidate) => candidate.id === form.applicationId);
const availableSignatures = signatures.filter((signature) => (
signature.auditStatus === 'approved'
&& (!application || signature.tenantId === application.tenantId)
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const availableSignatures = signatures.filter(
(signature) =>
signature.auditStatus === 'approved' &&
(!application || signature.tenantId === application.tenantId) &&
(!signature.applicationId || signature.applicationId === form.applicationId),
);
const variables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
@@ -127,7 +139,10 @@ function TemplateModal({
}
function updateVariableExample(name: string, example: string) {
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
update(
'variables',
variables.map((variable) => (variable.name === name ? { ...variable, example } : variable)),
);
}
return (
@@ -135,8 +150,15 @@ function TemplateModal({
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost"></Button>
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}></Button>
<Button onClick={requestClose} variant="ghost">
</Button>
<Button
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
onClick={() => onSubmit({ ...form, variables })}
>
</Button>
</>
)}
onClose={onClose}
@@ -144,22 +166,32 @@ function TemplateModal({
size="xl"
title={item ? '编辑短信模板' : '添加短信模板'}
>
<div className="template-form">
<div className="template-form client-template-form">
<Select
label="短信应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
options={[
{ label: '请选择应用', value: '' },
...applications.map((app) => ({ label: app.name, value: app.id })),
]}
value={form.applicationId}
/>
<Input
label="模板名称"
onChange={(event) => update('name', event.target.value)}
placeholder="请输入模板名称"
value={form.name}
/>
<Select
label="短信签名"
onChange={(event) => selectSignature(event.target.value)}
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
options={[
{ label: '请选择签名', value: '' },
...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
]}
required
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
@@ -174,26 +206,42 @@ function TemplateModal({
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
<span>
{form.content.length} {billingUnits(form.content)}
</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>
<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); setCustomVariable(''); }}></Button>
<Input
onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable);
setCustomVariable('');
}}
>
</Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{variables.length ? variables.map((variable) => (
{variables.length ? (
variables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
@@ -201,7 +249,10 @@ function TemplateModal({
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</Modal>
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
clientApi
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
.then((templateResult) => {
if (sequence !== requestSequence.current) return;
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setTemplates(
templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
);
setTotal(templateResult.total);
setError('');
})
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
.catch((reason: Error) => {
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
})
.finally(() => {
if (sequence === requestSequence.current) setLoading(false);
});
}
useEffect(() => {
loadData(page);
startTransition(() => loadData(page));
}, [appliedKeyword, page]);
useEffect(() => {
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
.catch((reason: Error) => {
if (!cancelled) setError(reason.message || '模板选项加载失败');
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
const filteredTemplates = templates;
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
signatureId: state.signatureId || undefined,
name: state.name,
content: state.content,
category: state.category,
variables: state.variables,
};
const template = existing
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
}
return (
<section className="page-stack">
<section className="page-stack client-templates-page">
<div className="template-page-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
</div>
</div>
<div className="template-toolbar">
<div className="client-template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称、应用、签名或内容"
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
value={keyword}
/>
<div className="ui-query-actions">
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}></Button>
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost"></Button>
<Button
icon={<Search size={17} />}
onClick={() => {
setPage(1);
setAppliedKeyword(keyword.trim());
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setPage(1);
setAppliedKeyword('');
}}
variant="ghost"
>
</Button>
</div>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>
</Button>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{visibleTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
const variables =
template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
return (
<article className="template-card template-card--green" key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<p className="template-content">{template.content}</p>
<p className="muted">
{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>
{statusLabel[template.auditStatus] ?? template.auditStatus}
</Tag>
<p className="client-template-content">{template.content}</p>
<div className="template-vars">
<span></span>
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted"></span>}
{variables.length > 0 ? (
variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)
) : (
<span className="muted"></span>
)}
</div>
<div className="template-card-footer">
<div className="client-template-footer">
<span>{formatDateTime(template.updatedAt)}</span>
<div>
<button onClick={() => setModalTemplate(template)} type="button">
<Edit3 size={14} />
<Button
icon={<Edit3 size={14} />}
onClick={() => setModalTemplate(template)}
size="sm"
variant="ghost"
>
</button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="client"
targetId={template.id}
targetType="template"
/>
</div>
</div>
</article>
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
applications={applications}
item={modalTemplate === 'new' ? undefined : modalTemplate}
onClose={() => setModalTemplate(null)}
onSubmit={(state) => { void saveTemplate(state); }}
onSubmit={(state) => {
void saveTemplate(state);
}}
signatures={signatures}
/>
) : null}
@@ -0,0 +1,16 @@
import { Link } from 'react-router-dom';
import './HttpDeveloperDocs.css';
export function ClientHttpDocsPage() {
return (
<section className="page-stack client-http-docs">
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
<Link to="/client/http-api"></Link>
</div>
<iframe className="client-http-docs-reader" title="HTTP接口接入文档" src="/api/client-docs" />
</section>
);
}
+1
View File
@@ -54,6 +54,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
{ label: '模板管理', to: '/client/templates', icon: FileText },
{ label: '接口对接', to: '/client/http-api', icon: Cable },
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
],
},
{
+2
View File
@@ -150,6 +150,7 @@ const ClientEnterpriseAuthPage = lazyNamed(
'ClientEnterpriseAuthPage',
);
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
@@ -185,6 +186,7 @@ export function AppRoutes() {
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
<Route path="applications" element={<ClientApplicationsPage />} />
<Route path="http-api" element={<ClientHttpApiPage />} />
<Route path="http-docs" element={<ClientHttpDocsPage />} />
<Route path="templates" element={<ClientTemplatesPage />} />
<Route path="signatures" element={<ClientSignaturesPage />} />
<Route path="mms-signatures" element={<PagePlaceholder />} />
+7 -1
View File
@@ -290,9 +290,15 @@
},
{
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx"],
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx", "src/apps/client/http-docs/ClientHttpDocsPage.tsx"],
"stylelintLegacy": false,
"roots": ["client-http-docs"]
},
{
"file": "src/apps/client/ClientTemplatesPage.css",
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
"stylelintLegacy": false,
"roots": ["client-templates-page"]
}
]
}