fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -1,47 +1,182 @@
|
|||||||
.http-developer-docs { margin: 0; color: #1f2937; background: #f6f7f9; font: 14px/1.6 system-ui, sans-serif; }
|
.http-developer-docs {
|
||||||
.http-developer-docs * { box-sizing: border-box; }
|
margin: 0;
|
||||||
.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; }
|
color: #1f2937;
|
||||||
.http-developer-docs h1 { font-size: 24px; margin: 8px 0; }
|
background: #f6f7f9;
|
||||||
.http-developer-docs h2 { font-size: 20px; margin: 0 0 16px; }
|
font:
|
||||||
.http-developer-docs h3 { font-size: 16px; margin: 20px 0 12px; }
|
14px/1.6 system-ui,
|
||||||
.http-developer-docs p { overflow-wrap: anywhere; }
|
sans-serif;
|
||||||
.http-developer-docs a { color: #2563eb; text-decoration: none; overflow-wrap: anywhere; }
|
}
|
||||||
.http-developer-docs a:hover { text-decoration: underline; }
|
.http-developer-docs * {
|
||||||
.http-developer-docs .http-doc-actions { display: flex; gap: 16px; flex-wrap: wrap; }
|
box-sizing: border-box;
|
||||||
.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 .http-doc-header {
|
||||||
.http-developer-docs nav a { display: block; padding: 7px 0; font-size: 13px; }
|
padding: 24px;
|
||||||
.http-developer-docs nav label { display: block; margin-top: 20px; }
|
border-bottom: 1px solid #e5e7eb;
|
||||||
.http-developer-docs input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font: inherit; }
|
background: #fff;
|
||||||
.http-developer-docs main { min-width: 0; }
|
display: flex;
|
||||||
.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; }
|
gap: 20px;
|
||||||
.http-developer-docs section[hidden] { display: none; }
|
justify-content: space-between;
|
||||||
.http-developer-docs .http-doc-body { padding: 24px; background: #fff; min-width: 0; }
|
align-items: center;
|
||||||
.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 h1 {
|
||||||
.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; }
|
font-size: 24px;
|
||||||
.http-developer-docs button { padding: 5px 12px; border: 1px solid #d1d5db; border-radius: 6px; color: #1f2937; background: #fff; cursor: pointer; flex-shrink: 0; }
|
margin: 8px 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 h2 {
|
||||||
.http-developer-docs code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
|
font-size: 20px;
|
||||||
.http-developer-docs .http-doc-table { overflow-x: auto; }
|
margin: 0 0 16px;
|
||||||
.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 h3 {
|
||||||
.http-developer-docs tr:first-child { font-weight: 600; background: #f4f6f8; }
|
font-size: 16px;
|
||||||
.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; }
|
margin: 20px 0 12px;
|
||||||
.http-developer-docs .http-doc-copy-status:empty { display: none; }
|
}
|
||||||
|
.http-developer-docs p {
|
||||||
@media (width <= 1400px) {
|
overflow-wrap: anywhere;
|
||||||
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
|
}
|
||||||
.http-developer-docs aside { padding: 16px 24px; }
|
.http-developer-docs a {
|
||||||
.http-developer-docs aside:empty { display: none; }
|
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) {
|
@media (width <= 700px) {
|
||||||
.http-developer-docs .http-doc-header { padding: 16px; display: block; }
|
.http-developer-docs .http-doc-header {
|
||||||
.http-developer-docs .http-doc-layout { grid-template-columns: minmax(0, 1fr); }
|
padding: 16px;
|
||||||
.http-developer-docs nav { position: static; max-height: none; padding: 16px; }
|
display: block;
|
||||||
.http-developer-docs .http-doc-body, .http-developer-docs aside { padding: 16px; }
|
}
|
||||||
|
.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; }
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
/* global document, navigator, window, Event */
|
/* global document, navigator, window, Event */
|
||||||
const copyStatus = document.getElementById('copy-status');
|
const copyStatus = document.getElementById('copy-status');
|
||||||
document.addEventListener('click', async (event) => {
|
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]');
|
const button = event.target.closest('button[data-copy]');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const content = document.getElementById(button.dataset.copy);
|
const content = document.getElementById(button.dataset.copy);
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ function inline(value: string): string {
|
|||||||
}
|
}
|
||||||
export function renderHttpGuide(markdown: string, origin: string) {
|
export function renderHttpGuide(markdown: string, origin: string) {
|
||||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
const sections: Array<{ id: string; title: string; body: string[]; samples: string[] }> = [];
|
const sections: Array<{ id: string; title: string; body: string[] }> = [];
|
||||||
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
|
let current = { id: 'introduction', title: '接入指南', body: [] as string[] };
|
||||||
sections.push(current);
|
sections.push(current);
|
||||||
let code: string[] | null = null;
|
let code: string[] | null = null;
|
||||||
let language = '';
|
let language = '';
|
||||||
@@ -35,7 +35,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (code) {
|
if (code) {
|
||||||
if (/^```/.test(line)) {
|
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;
|
code = null;
|
||||||
} else code.push(line);
|
} else code.push(line);
|
||||||
continue;
|
continue;
|
||||||
@@ -46,7 +46,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
closeTable();
|
closeTable();
|
||||||
sampleTitle = heading[2];
|
sampleTitle = heading[2];
|
||||||
if (heading[1].length === 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);
|
sections.push(current);
|
||||||
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
|
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
|
||||||
continue;
|
continue;
|
||||||
@@ -58,8 +58,9 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
closeTable();
|
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>`);
|
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
|
||||||
}
|
}
|
||||||
closeTable();
|
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' }) });
|
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
|
||||||
expect(send).not.toHaveBeenCalled();
|
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', () => {
|
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
|
||||||
const html = renderHttpGuide(
|
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```',
|
'**接口版本: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,
|
id: true,
|
||||||
content: true,
|
content: true,
|
||||||
hasDrainageContent: true,
|
hasDrainageContent: true,
|
||||||
|
receiptStatus: true,
|
||||||
|
deliveredAt: true,
|
||||||
tenant: { select: { id: true, name: true } },
|
tenant: { select: { id: true, name: true } },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export class OperationsMessageQueries {
|
|||||||
amountCents: true,
|
amountCents: true,
|
||||||
status: true,
|
status: true,
|
||||||
submitStatus: true,
|
submitStatus: true,
|
||||||
|
receiptStatus: true,
|
||||||
|
deliveredAt: true,
|
||||||
queuedAt: true,
|
queuedAt: true,
|
||||||
tenant: { select: { id: true, name: true } },
|
tenant: { select: { id: true, name: true } },
|
||||||
application: { select: { id: true, name: true } },
|
application: { select: { id: true, name: true } },
|
||||||
|
|||||||
+504
-502
File diff suppressed because it is too large
Load Diff
@@ -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-引流唯一性与通道运营商报备设计) 为准。
|
- 兼容、迁移和验收以 [引流门禁方案第 11 节](drainage-send-gating-plan-20260910.md#11-2026-09-14-引流唯一性与通道运营商报备设计) 为准。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端页面与文档调整
|
||||||
|
|
||||||
|
模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。
|
||||||
|
|||||||
@@ -5506,3 +5506,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 |
|
| DRN-CARRIER-10 | 三尺寸首次打开、保存、刷新、切换详情;检查真实响应及数据库,不把隔离服务适配器视为完整认证/Gateway 验收。 |
|
||||||
|
|
||||||
执行结果与未执行边界见 testing-progress.md 本日记录。
|
执行结果与未执行边界见 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 本轮记录。
|
||||||
|
|||||||
@@ -4997,3 +4997,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
|||||||
- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。
|
- 本轮仅本地修改、文档和本地提交;未推送、未部署测试、未部署预生产。迁移和代码未在两套线上环境生效。提交号见本条记录所在提交;最终汇报提供精确 SHA。
|
||||||
- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。
|
- 隔离 HTTP 适配器直接调用真实业务服务与 PG,不包含完整 Nest 全局认证、生产反向代理和 worker;不得将其当作在线全功能验收。测试环境/预生产完整登录页面、权限与租户隔离在线回归、MinIO 报备文件导出/导入实物、Redis/Gateway 实际短信发送与计费闭环本轮未执行。原链路单元回归通过不替代物理发送专项验收。
|
||||||
- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。
|
- 53 项已有保护文件在最终核对中保持摘要(仅本轮文档采用追加并精确暂存);其余脏文件和草稿不纳入提交。历史重复资料不自动清理,历史通道级审批不批量重写。上线须先按标准发布流程执行新索引迁移,回退不可直接删除三网任务或重建旧索引。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 客户端模板、接口文档、工作台与回执整改(发布前)
|
||||||
|
|
||||||
|
- 起点 main 7f9abe3,实际远端 ac64490,测试机 97d133442350b9725422ed4e55386f47e37004fd。用户明确要求修改、提交、推送、测试部署;预生产、短信发送/重投、客户配置变更不在范围。测试发布包含上一轮引流三网报备提交和 20260914093000_drainage_carrier_reports 兼容迁移。既有 metrics、tools/release 和文档草稿全部保护。
|
||||||
|
- 只读根因:测试库 http-cmu0xgl6o000k5mle3hrw4avd 为 delivered,receiptStatus=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 tests(maxWorkers=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-20260914:api-full.log、frontend-final.log、css-final.log、style-final.log、format-final.log、browser-results.json 与 templates/receipts/home/docs 三尺寸截图。隔离库仅使用本轮专用样本;测试线上只读抽样。客户端登录账号尚待用户提供,不恢复旧账号、不造认证会话;在线页面与发布结果后续追加。
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { BadgeCheck, ClipboardList, FileText, PenLine, Plus, ReceiptText, Send, WalletCards } from 'lucide-react';
|
||||||
BadgeCheck,
|
|
||||||
ClipboardList,
|
|
||||||
FileText,
|
|
||||||
PenLine,
|
|
||||||
Plus,
|
|
||||||
ReceiptText,
|
|
||||||
Send,
|
|
||||||
WalletCards,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { Chart } from '@/components/ui/Chart';
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
|
||||||
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
|
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
|
||||||
|
|
||||||
type RecentTaskRow = {
|
type RecentTaskRow = {
|
||||||
@@ -28,10 +20,23 @@ type RecentTaskRow = {
|
|||||||
|
|
||||||
const columns: Array<TableColumn<RecentTaskRow>> = [
|
const columns: Array<TableColumn<RecentTaskRow>> = [
|
||||||
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
|
{ 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: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
{ 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() {
|
export function ClientHome() {
|
||||||
@@ -40,7 +45,8 @@ export function ClientHome() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.getDashboard()
|
clientApi
|
||||||
|
.getDashboard()
|
||||||
.then(setDashboard)
|
.then(setDashboard)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
|
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
|
||||||
@@ -51,18 +57,23 @@ export function ClientHome() {
|
|||||||
const account = dashboard?.accounts[0];
|
const account = dashboard?.accounts[0];
|
||||||
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
|
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
|
||||||
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 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),
|
id: String(task.id ?? task.taskNo),
|
||||||
taskNo: String(task.taskNo ?? task.id),
|
taskNo: String(task.taskNo ?? task.id),
|
||||||
scene: String(task.category ?? task.content ?? '短信发送'),
|
scene: String(task.content ?? '-'),
|
||||||
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||||
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
||||||
status: String(task.status ?? 'unknown'),
|
status: String(task.status ?? 'unknown'),
|
||||||
})), [dashboard]);
|
})),
|
||||||
|
[dashboard],
|
||||||
|
);
|
||||||
const latestRecharge = dashboard?.recentRecharges[0];
|
const latestRecharge = dashboard?.recentRecharges[0];
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() =>
|
||||||
|
createLineOption({
|
||||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||||
series: [
|
series: [
|
||||||
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||||
@@ -72,12 +83,16 @@ export function ClientHome() {
|
|||||||
[dashboard],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const channelShareOption = useMemo(() => createPieOption({
|
const channelShareOption = useMemo(
|
||||||
|
() =>
|
||||||
|
createPieOption({
|
||||||
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
|
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
|
||||||
name: item.status,
|
name: item.status,
|
||||||
value: item._sum.currentConnections ?? item._count._all,
|
value: item._sum.currentConnections ?? item._count._all,
|
||||||
})),
|
})),
|
||||||
}), [dashboard]);
|
}),
|
||||||
|
[dashboard],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
@@ -91,7 +106,9 @@ export function ClientHome() {
|
|||||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
|
||||||
新建模板
|
新建模板
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>发送短信</Button>
|
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>
|
||||||
|
发送短信
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +196,11 @@ export function ClientHome() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overview-grid overview-grid--three">
|
<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} />
|
<BadgeCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>模板状态</span>
|
<span>模板状态</span>
|
||||||
@@ -187,7 +208,11 @@ export function ClientHome() {
|
|||||||
<small>点击进入模板明细</small>
|
<small>点击进入模板明细</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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} />
|
<PenLine size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>签名状态</span>
|
<span>签名状态</span>
|
||||||
@@ -195,7 +220,11 @@ export function ClientHome() {
|
|||||||
<small>点击进入签名明细</small>
|
<small>点击进入签名明细</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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} />
|
<ClipboardList size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>批量任务</span>
|
<span>批量任务</span>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -269,8 +268,6 @@ export function ClientHttpApiPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
|
|
||||||
|
|
||||||
const logsPanel = (
|
const logsPanel = (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<div className="surface" style={{ padding: 16 }}>
|
<div className="surface" style={{ padding: 16 }}>
|
||||||
@@ -316,7 +313,6 @@ export function ClientHttpApiPage() {
|
|||||||
{ label: '接口概览', value: 'overview', content: overview },
|
{ label: '接口概览', value: 'overview', content: overview },
|
||||||
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
|
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
|
||||||
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
|
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
|
||||||
{ label: '接口文档', value: 'docs', content: docsPanel },
|
|
||||||
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
|
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -325,7 +321,7 @@ export function ClientHttpApiPage() {
|
|||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>接口对接</h1>
|
<h1>接口对接</h1>
|
||||||
<p>管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。</p>
|
<p>管理 HTTP 访问凭据、回调地址及真实投递记录。接入说明请查看「接口文档」。</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="企业应用"
|
label="企业应用"
|
||||||
@@ -360,7 +356,11 @@ export function ClientHttpApiPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{applicationId ? <Tabs items={tabs} /> : docsPanel}
|
{applicationId ? (
|
||||||
|
<Tabs items={tabs} />
|
||||||
|
) : (
|
||||||
|
<p className="muted">暂无可选应用;可从左侧「接口文档」查看通用接入说明。</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,39 +5,102 @@ import { ClientBatchTasksPage } from './ClientBatchTasksPage';
|
|||||||
import { ClientSendDetailPage } from './ClientSendDetailPage';
|
import { ClientSendDetailPage } from './ClientSendDetailPage';
|
||||||
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
|
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
|
||||||
|
|
||||||
const { clientApi } = vi.hoisted(() => ({ clientApi: {
|
const { clientApi } = vi.hoisted(() => ({
|
||||||
listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
|
clientApi: {
|
||||||
} }));
|
listApplicationOptions: vi.fn(),
|
||||||
|
listBatchTasksPage: vi.fn(),
|
||||||
|
listMessages: vi.fn(),
|
||||||
|
listUplinkMessagesPage: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
||||||
|
|
||||||
describe('explicit client queries', () => {
|
describe('explicit client queries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
||||||
clientApi.listApplicationOptions.mockResolvedValue([]);
|
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([
|
it.each([
|
||||||
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
|
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
|
||||||
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
|
{ 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 }) => {
|
Component: ClientUplinkMessagesPage,
|
||||||
render(<MemoryRouter><Component /></MemoryRouter>);
|
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));
|
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
|
||||||
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
|
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);
|
expect(method).toHaveBeenCalledTimes(1);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
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: '查询' }));
|
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.change(screen.getByLabelText(label), { target: { value: '未提交' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
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: '上一页' }));
|
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: '重置' }));
|
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('');
|
expect(screen.getByLabelText(label)).toHaveValue('');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { FileText, Search, Smartphone } from 'lucide-react';
|
||||||
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
type DateRangeValue,
|
type DateRangeValue,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { recentBeijingDateRange } from '@/utils/dateTime';
|
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
|
||||||
|
|
||||||
const statusLabelMap: Record<string, string> = {
|
const statusLabelMap: Record<string, string> = {
|
||||||
delivered: '成功',
|
delivered: '成功',
|
||||||
@@ -44,25 +44,34 @@ const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> =
|
|||||||
timeout: 'danger',
|
timeout: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDate(value?: string | null) {
|
|
||||||
return value ? value.slice(0, 10) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReceipt(record: SmsMessageRecord) {
|
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 {
|
return {
|
||||||
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
|
status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
|
||||||
time: latest?.deliveredAt ?? record.deliveredAt,
|
time: record.deliveredAt ?? latest?.deliveredAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function receiptStatusLabel(status?: string | null) {
|
function receiptStatusLabel(status?: string | null) {
|
||||||
if (!status) return '-';
|
if (!status) return '暂无回执';
|
||||||
const normalized = status.trim().toUpperCase();
|
const normalized = status.trim().toUpperCase();
|
||||||
return {
|
return (
|
||||||
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
|
{
|
||||||
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
|
DELIVERED: '送达成功',
|
||||||
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
|
FAILED: '送达失败',
|
||||||
|
TIMEOUT: '回执超时',
|
||||||
|
DELIVRD: '送达成功',
|
||||||
|
ACCEPTD: '已受理',
|
||||||
|
UNDELIV: '未送达',
|
||||||
|
REJECTD: '已拒绝',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
DELETED: '已删除',
|
||||||
|
UNKNOWN: '状态未知',
|
||||||
|
}[normalized] ??
|
||||||
|
statusLabelMap[status.toLowerCase()] ??
|
||||||
|
'状态未知'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSendDetailPage() {
|
export function ClientSendDetailPage() {
|
||||||
@@ -81,7 +90,8 @@ export function ClientSendDetailPage() {
|
|||||||
|
|
||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listMessages({
|
clientApi
|
||||||
|
.listMessages({
|
||||||
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
||||||
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
||||||
status: applied.status === 'all' ? undefined : applied.status,
|
status: applied.status === 'all' ? undefined : applied.status,
|
||||||
@@ -101,20 +111,18 @@ export function ClientSendDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
startTransition(() => loadData(page));
|
||||||
}, [applied, page]);
|
}, [applied, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.listApplicationOptions()
|
clientApi
|
||||||
|
.listApplicationOptions()
|
||||||
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
||||||
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
|
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applicationOptions = useMemo(() => {
|
const applicationOptions = useMemo(() => {
|
||||||
return [
|
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
|
||||||
{ label: '全部应用', value: 'all' },
|
|
||||||
...applications.map((item) => ({ label: item.name, value: item.id })),
|
|
||||||
];
|
|
||||||
}, [applications]);
|
}, [applications]);
|
||||||
|
|
||||||
const filteredRows = records;
|
const filteredRows = records;
|
||||||
@@ -129,7 +137,13 @@ export function ClientSendDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
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);
|
setApplicationId(defaults.applicationId);
|
||||||
setStatus(defaults.status);
|
setStatus(defaults.status);
|
||||||
setDateRange(defaults.dateRange);
|
setDateRange(defaults.dateRange);
|
||||||
@@ -150,9 +164,18 @@ export function ClientSendDetailPage() {
|
|||||||
|
|
||||||
<QueryPanel
|
<QueryPanel
|
||||||
title="查询条件"
|
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} />
|
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||||
<Select
|
<Select
|
||||||
label="发送状态"
|
label="发送状态"
|
||||||
@@ -202,20 +225,33 @@ export function ClientSendDetailPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td className="ui-table__empty" colSpan={9}>正在加载真实发送记录...</td></tr>
|
<tr>
|
||||||
|
<td className="ui-table__empty" colSpan={9}>
|
||||||
|
正在加载真实发送记录...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
<tr>
|
||||||
) : visibleRows.map((record) => {
|
<td className="ui-table__empty" colSpan={9}>
|
||||||
|
暂无发送记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
visibleRows.map((record) => {
|
||||||
const receipt = getReceipt(record);
|
const receipt = getReceipt(record);
|
||||||
const region = record.province ?? '-';
|
const region = record.province ?? '-';
|
||||||
return (
|
return (
|
||||||
<Fragment key={record.id}>
|
<Fragment key={record.id}>
|
||||||
<tr className="send-detail-main-row">
|
<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>
|
<td>
|
||||||
<span className="send-detail-time">
|
<span className="send-detail-time">
|
||||||
{record.queuedAt.slice(0, 10)}
|
{formatDateTime(record.queuedAt).slice(0, 10)}
|
||||||
<small>{record.queuedAt.slice(11, 19)}</small>
|
<small>{formatDateTime(record.queuedAt).slice(11, 19)}</small>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
@@ -224,18 +260,30 @@ export function ClientSendDetailPage() {
|
|||||||
<small>{record.billingUnits}条</small>
|
<small>{record.billingUnits}条</small>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><strong>{record.phoneNumber}</strong></td>
|
<td>
|
||||||
|
<strong>{record.phoneNumber}</strong>
|
||||||
|
</td>
|
||||||
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
||||||
<td><span className="send-detail-region">{region}</span></td>
|
<td>
|
||||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
|
<span className="send-detail-region">{region}</span>
|
||||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
|
</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>
|
<td>
|
||||||
{receipt.time ? (
|
{receipt.time ? (
|
||||||
<span className="send-detail-time">
|
<span className="send-detail-time">
|
||||||
{receipt.time.slice(0, 10)}
|
{formatDateTime(receipt.time).slice(0, 10)}
|
||||||
<small>{receipt.time.slice(11, 19)}</small>
|
<small>{formatDateTime(receipt.time).slice(11, 19)}</small>
|
||||||
</span>
|
</span>
|
||||||
) : <span className="muted">-</span>}
|
) : (
|
||||||
|
<span className="muted">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="send-detail-content-row">
|
<tr className="send-detail-content-row">
|
||||||
@@ -248,7 +296,8 @@ export function ClientSendDetailPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { startTransition, useEffect, useRef, useState } from 'react';
|
||||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
|
||||||
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
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 { formatDateTime } from '@/utils/dateTime';
|
||||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||||
|
import './ClientTemplatesPage.css';
|
||||||
|
|
||||||
type TemplateVariable = {
|
type TemplateVariable = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,7 +21,6 @@ type TemplateFormState = {
|
|||||||
applicationId: string;
|
applicationId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
variables: TemplateVariable[];
|
variables: TemplateVariable[];
|
||||||
};
|
};
|
||||||
@@ -49,8 +54,10 @@ const recommendedVariables = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function extractVariables(content: string): TemplateVariable[] {
|
function extractVariables(content: string): TemplateVariable[] {
|
||||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
|
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
|
||||||
.map((name) => ({ name, required: true }));
|
name,
|
||||||
|
required: true,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function billingUnits(content: string) {
|
function billingUnits(content: string) {
|
||||||
@@ -77,23 +84,28 @@ function TemplateModal({
|
|||||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||||
const initialContent = item?.signatureId
|
const initialContent = item?.signatureId
|
||||||
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
||||||
: item?.content ?? '';
|
: (item?.content ?? '');
|
||||||
const [form, setForm] = useState<TemplateFormState>({
|
const [form, setForm] = useState<TemplateFormState>({
|
||||||
applicationId: item?.applicationId ?? '',
|
applicationId: item?.applicationId ?? '',
|
||||||
signatureId: item?.signatureId ?? '',
|
signatureId: item?.signatureId ?? '',
|
||||||
name: item?.name ?? '',
|
name: item?.name ?? '',
|
||||||
category: item?.category ?? '行业通知',
|
|
||||||
content: initialContent,
|
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 dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||||
const availableSignatures = signatures.filter((signature) => (
|
const availableSignatures = signatures.filter(
|
||||||
signature.auditStatus === 'approved'
|
(signature) =>
|
||||||
&& (!application || signature.tenantId === application.tenantId)
|
signature.auditStatus === 'approved' &&
|
||||||
&& (!signature.applicationId || signature.applicationId === form.applicationId)
|
(!application || signature.tenantId === application.tenantId) &&
|
||||||
));
|
(!signature.applicationId || signature.applicationId === form.applicationId),
|
||||||
|
);
|
||||||
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||||
|
|
||||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||||
@@ -127,7 +139,10 @@ function TemplateModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateVariableExample(name: string, example: string) {
|
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 (
|
return (
|
||||||
@@ -135,8 +150,15 @@ function TemplateModal({
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
footer={({ requestClose }) => (
|
footer={({ requestClose }) => (
|
||||||
<>
|
<>
|
||||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
<Button onClick={requestClose} variant="ghost">
|
||||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
|
||||||
|
onClick={() => onSubmit({ ...form, variables })}
|
||||||
|
>
|
||||||
|
提交审核
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -144,22 +166,32 @@ function TemplateModal({
|
|||||||
size="xl"
|
size="xl"
|
||||||
title={item ? '编辑短信模板' : '添加短信模板'}
|
title={item ? '编辑短信模板' : '添加短信模板'}
|
||||||
>
|
>
|
||||||
<div className="template-form">
|
<div className="template-form client-template-form">
|
||||||
<Select
|
<Select
|
||||||
label="短信应用"
|
label="短信应用"
|
||||||
onChange={(event) => update('applicationId', event.target.value)}
|
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}
|
value={form.applicationId}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="模板名称"
|
||||||
|
onChange={(event) => update('name', event.target.value)}
|
||||||
|
placeholder="请输入模板名称"
|
||||||
|
value={form.name}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="短信签名"
|
label="短信签名"
|
||||||
onChange={(event) => selectSignature(event.target.value)}
|
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
|
required
|
||||||
value={form.signatureId}
|
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
|
<Textarea
|
||||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||||
label="模板内容"
|
label="模板内容"
|
||||||
@@ -174,26 +206,42 @@ function TemplateModal({
|
|||||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||||
</button>
|
</button>
|
||||||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
<span>
|
||||||
|
{form.content.length} 字符,计费 {billingUnits(form.content)} 条
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{variablesOpen ? (
|
{variablesOpen ? (
|
||||||
<div className="template-variable-panel">
|
<div className="template-variable-panel">
|
||||||
<h3>推荐变量</h3>
|
<h3>推荐变量</h3>
|
||||||
<div className="template-variable-buttons">
|
<div className="template-variable-buttons">
|
||||||
{recommendedVariables.map(([label, value]) => (
|
{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>
|
</div>
|
||||||
<h3>自定义变量</h3>
|
<h3>自定义变量</h3>
|
||||||
<div className="template-custom-variable">
|
<div className="template-custom-variable">
|
||||||
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
|
<Input
|
||||||
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}>插入</Button>
|
onChange={(event) => setCustomVariable(event.target.value)}
|
||||||
|
placeholder="英文字符或数字"
|
||||||
|
value={customVariable}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
insertVariable(customVariable);
|
||||||
|
setCustomVariable('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
插入
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="template-variable-panel">
|
<div className="template-variable-panel">
|
||||||
<h3>变量示例</h3>
|
<h3>变量示例</h3>
|
||||||
{variables.length ? variables.map((variable) => (
|
{variables.length ? (
|
||||||
|
variables.map((variable) => (
|
||||||
<Input
|
<Input
|
||||||
key={variable.name}
|
key={variable.name}
|
||||||
label={`\${${variable.name}}`}
|
label={`\${${variable.name}}`}
|
||||||
@@ -201,7 +249,10 @@ function TemplateModal({
|
|||||||
placeholder="请输入变量示例值"
|
placeholder="请输入变量示例值"
|
||||||
value={variable.example ?? ''}
|
value={variable.example ?? ''}
|
||||||
/>
|
/>
|
||||||
)) : <p className="muted">模板内容中暂无变量。</p>}
|
))
|
||||||
|
) : (
|
||||||
|
<p className="muted">模板内容中暂无变量。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
|
|||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
const sequence = ++requestSequence.current;
|
const sequence = ++requestSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
clientApi
|
||||||
|
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
||||||
.then((templateResult) => {
|
.then((templateResult) => {
|
||||||
if (sequence !== requestSequence.current) return;
|
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);
|
setTotal(templateResult.total);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
|
.catch((reason: Error) => {
|
||||||
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
|
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
startTransition(() => loadData(page));
|
||||||
}, [appliedKeyword, page]);
|
}, [appliedKeyword, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
|
|||||||
.catch((reason: Error) => {
|
.catch((reason: Error) => {
|
||||||
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredTemplates = templates;
|
const filteredTemplates = templates;
|
||||||
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
|
|||||||
signatureId: state.signatureId || undefined,
|
signatureId: state.signatureId || undefined,
|
||||||
name: state.name,
|
name: state.name,
|
||||||
content: state.content,
|
content: state.content,
|
||||||
category: state.category,
|
|
||||||
variables: state.variables,
|
variables: state.variables,
|
||||||
};
|
};
|
||||||
const template = existing
|
const template = existing
|
||||||
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack client-templates-page">
|
||||||
<div className="template-page-header">
|
<div className="template-page-header">
|
||||||
<div className="sms-send-title">
|
<div className="sms-send-title">
|
||||||
<span className="sms-send-title__icon">
|
<span className="sms-send-title__icon">
|
||||||
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="template-toolbar">
|
<div className="client-template-toolbar">
|
||||||
<Input
|
<Input
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
placeholder="搜索模板名称、应用、签名或内容"
|
placeholder="搜索模板名称、应用、签名或内容"
|
||||||
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
|
|||||||
value={keyword}
|
value={keyword}
|
||||||
/>
|
/>
|
||||||
<div className="ui-query-actions">
|
<div className="ui-query-actions">
|
||||||
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}>查询</Button>
|
<Button
|
||||||
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost">重置</Button>
|
icon={<Search size={17} />}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setAppliedKeyword(keyword.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setPage(1);
|
||||||
|
setAppliedKeyword('');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>添加短信模板</Button>
|
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>
|
||||||
|
添加短信模板
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="template-card-grid">
|
<div className="template-card-grid">
|
||||||
{visibleTemplates.map((template) => {
|
{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 (
|
return (
|
||||||
<article className="template-card template-card--green" key={template.id}>
|
<article className="template-card template-card--green" key={template.id}>
|
||||||
<h2>{template.name}</h2>
|
<h2>{template.name}</h2>
|
||||||
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
|
<p className="muted">
|
||||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
|
{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
|
||||||
<p className="template-content">{template.content}</p>
|
</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">
|
<div className="template-vars">
|
||||||
<span>变量:</span>
|
<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>
|
||||||
<div className="template-card-footer">
|
<div className="client-template-footer">
|
||||||
<span>{formatDateTime(template.updatedAt)}</span>
|
<span>{formatDateTime(template.updatedAt)}</span>
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => setModalTemplate(template)} type="button">
|
<Button
|
||||||
<Edit3 size={14} />
|
icon={<Edit3 size={14} />}
|
||||||
|
onClick={() => setModalTemplate(template)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</Button>
|
||||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
|
<DeleteRiskAction
|
||||||
|
onCompleted={() => void loadData()}
|
||||||
|
portal="client"
|
||||||
|
targetId={template.id}
|
||||||
|
targetType="template"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
|
|||||||
applications={applications}
|
applications={applications}
|
||||||
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
||||||
onClose={() => setModalTemplate(null)}
|
onClose={() => setModalTemplate(null)}
|
||||||
onSubmit={(state) => { void saveTemplate(state); }}
|
onSubmit={(state) => {
|
||||||
|
void saveTemplate(state);
|
||||||
|
}}
|
||||||
signatures={signatures}
|
signatures={signatures}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
|||||||
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
||||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||||
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
{ label: '接口对接', to: '/client/http-api', icon: Cable },
|
||||||
|
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ const ClientEnterpriseAuthPage = lazyNamed(
|
|||||||
'ClientEnterpriseAuthPage',
|
'ClientEnterpriseAuthPage',
|
||||||
);
|
);
|
||||||
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
|
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 ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
|
||||||
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
|
||||||
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
|
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
|
||||||
@@ -185,6 +186,7 @@ export function AppRoutes() {
|
|||||||
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
|
||||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||||
<Route path="http-api" element={<ClientHttpApiPage />} />
|
<Route path="http-api" element={<ClientHttpApiPage />} />
|
||||||
|
<Route path="http-docs" element={<ClientHttpDocsPage />} />
|
||||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||||
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
<Route path="mms-signatures" element={<PagePlaceholder />} />
|
||||||
|
|||||||
@@ -290,9 +290,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
|
"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,
|
"stylelintLegacy": false,
|
||||||
"roots": ["client-http-docs"]
|
"roots": ["client-http-docs"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/client/ClientTemplatesPage.css",
|
||||||
|
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["client-templates-page"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user