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 * { 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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user