@@ -316,7 +313,6 @@ export function ClientHttpApiPage() {
{ label: '接口概览', value: 'overview', content: overview },
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
- { label: '接口文档', value: 'docs', content: docsPanel },
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
];
@@ -325,7 +321,7 @@ export function ClientHttpApiPage() {
接口对接
-
管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。
+
管理 HTTP 访问凭据、回调地址及真实投递记录。接入说明请查看「接口文档」。
) : null}
- {applicationId ?
: docsPanel}
+ {applicationId ? (
+
+ ) : (
+
暂无可选应用;可从左侧「接口文档」查看通用接入说明。
+ )}
);
}
diff --git a/src/apps/client/ClientQueryPages.test.tsx b/src/apps/client/ClientQueryPages.test.tsx
index 03ea15d..e17d7bf 100644
--- a/src/apps/client/ClientQueryPages.test.tsx
+++ b/src/apps/client/ClientQueryPages.test.tsx
@@ -5,39 +5,102 @@ import { ClientBatchTasksPage } from './ClientBatchTasksPage';
import { ClientSendDetailPage } from './ClientSendDetailPage';
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
-const { clientApi } = vi.hoisted(() => ({ clientApi: {
- listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
-} }));
+const { clientApi } = vi.hoisted(() => ({
+ clientApi: {
+ listApplicationOptions: vi.fn(),
+ listBatchTasksPage: vi.fn(),
+ listMessages: vi.fn(),
+ listUplinkMessagesPage: vi.fn(),
+ },
+}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
describe('explicit client queries', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
clientApi.listApplicationOptions.mockResolvedValue([]);
- for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
+ for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage])
+ method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
});
+ it('shows canonical receipt state and Beijing receipt time while keeping missing receipts empty', async () => {
+ clientApi.listMessages.mockResolvedValue({
+ items: [
+ {
+ id: 'delivered',
+ content: '有回执',
+ phoneNumber: '13800138000',
+ billingUnits: 1,
+ status: 'delivered',
+ queuedAt: '2026-09-14T06:00:00Z',
+ receiptStatus: 'delivered',
+ deliveredAt: '2026-09-14T07:34:41.935Z',
+ receiptRecords: [{ rawStatus: 'UNDELIV', deliveredAt: '2026-09-13T01:00:00Z' }],
+ },
+ {
+ id: 'pending',
+ content: '等待回执',
+ phoneNumber: '13800138001',
+ billingUnits: 1,
+ status: 'submitted',
+ queuedAt: '2026-09-14T06:00:00Z',
+ receiptStatus: null,
+ deliveredAt: null,
+ },
+ ],
+ total: 2,
+ });
+ render(
);
+ expect(await screen.findByText('送达成功')).toBeInTheDocument();
+ expect(screen.getByText('15:34:41')).toBeInTheDocument();
+ expect(screen.getByText('暂无回执')).toBeInTheDocument();
+ expect(screen.queryByText('未送达')).not.toBeInTheDocument();
+ });
it.each([
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
- { Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' },
- ])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => {
- render(
);
- await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
- fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); });
- expect(method).toHaveBeenCalledTimes(1);
- fireEvent.click(screen.getByRole('button', { name: '下一页' }));
- await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })));
- fireEvent.click(screen.getByRole('button', { name: '查询' }));
- await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
- fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
- fireEvent.click(screen.getByRole('button', { name: '下一页' }));
- await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })));
- fireEvent.click(screen.getByRole('button', { name: '上一页' }));
- await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
- fireEvent.click(screen.getByRole('button', { name: '重置' }));
- await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })));
- expect(screen.getByLabelText(label)).toHaveValue('');
- });
+ {
+ Component: ClientUplinkMessagesPage,
+ method: clientApi.listUplinkMessagesPage,
+ label: '上行内容',
+ key: 'keyword',
+ },
+ ])(
+ '$label only applies filters on Query or Reset, including pagination back to page one',
+ async ({ Component, method, label, key }) => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
+ fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 350));
+ });
+ expect(method).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ await waitFor(() =>
+ expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '查询' }));
+ await waitFor(() =>
+ expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
+ );
+ fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ await waitFor(() =>
+ expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '上一页' }));
+ await waitFor(() =>
+ expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '重置' }));
+ await waitFor(() =>
+ expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })),
+ );
+ expect(screen.getByLabelText(label)).toHaveValue('');
+ },
+ );
});
diff --git a/src/apps/client/ClientSendDetailPage.tsx b/src/apps/client/ClientSendDetailPage.tsx
index a4bee3c..bb01168 100644
--- a/src/apps/client/ClientSendDetailPage.tsx
+++ b/src/apps/client/ClientSendDetailPage.tsx
@@ -1,4 +1,4 @@
-import { Fragment, useEffect, useMemo, useState } from 'react';
+import { Fragment, startTransition, useEffect, useMemo, useState } from 'react';
import { FileText, Search, Smartphone } from 'lucide-react';
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import {
@@ -12,7 +12,7 @@ import {
Tag,
type DateRangeValue,
} from '@/components/ui';
-import { recentBeijingDateRange } from '@/utils/dateTime';
+import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
const statusLabelMap: Record
= {
delivered: '成功',
@@ -44,25 +44,34 @@ const statusToneMap: Record =
timeout: 'danger',
};
-function getDate(value?: string | null) {
- return value ? value.slice(0, 10) : '';
-}
-
function getReceipt(record: SmsMessageRecord) {
- const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
+ const latest = record.receiptRecords?.[0] as
+ { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
- status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
- time: latest?.deliveredAt ?? record.deliveredAt,
+ status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
+ time: record.deliveredAt ?? latest?.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
- if (!status) return '-';
+ if (!status) return '暂无回执';
const normalized = status.trim().toUpperCase();
- return {
- DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
- EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
- }[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
+ return (
+ {
+ DELIVERED: '送达成功',
+ FAILED: '送达失败',
+ TIMEOUT: '回执超时',
+ DELIVRD: '送达成功',
+ ACCEPTD: '已受理',
+ UNDELIV: '未送达',
+ REJECTD: '已拒绝',
+ EXPIRED: '已过期',
+ DELETED: '已删除',
+ UNKNOWN: '状态未知',
+ }[normalized] ??
+ statusLabelMap[status.toLowerCase()] ??
+ '状态未知'
+ );
}
export function ClientSendDetailPage() {
@@ -81,16 +90,17 @@ export function ClientSendDetailPage() {
function loadData(targetPage = page) {
setLoading(true);
- clientApi.listMessages({
- applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
- phoneNumber: applied.phoneKeyword.trim() || undefined,
- status: applied.status === 'all' ? undefined : applied.status,
- contentKeyword: applied.contentKeyword.trim() || undefined,
- queuedAtFrom: applied.dateRange.start || undefined,
- queuedAtTo: applied.dateRange.end || undefined,
- page: targetPage,
- pageSize: 10,
- })
+ clientApi
+ .listMessages({
+ applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
+ phoneNumber: applied.phoneKeyword.trim() || undefined,
+ status: applied.status === 'all' ? undefined : applied.status,
+ contentKeyword: applied.contentKeyword.trim() || undefined,
+ queuedAtFrom: applied.dateRange.start || undefined,
+ queuedAtTo: applied.dateRange.end || undefined,
+ page: targetPage,
+ pageSize: 10,
+ })
.then((result) => {
setRecords(result.items);
setTotal(result.total);
@@ -101,20 +111,18 @@ export function ClientSendDetailPage() {
}
useEffect(() => {
- loadData(page);
+ startTransition(() => loadData(page));
}, [applied, page]);
useEffect(() => {
- clientApi.listApplicationOptions()
+ clientApi
+ .listApplicationOptions()
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
}, []);
const applicationOptions = useMemo(() => {
- return [
- { label: '全部应用', value: 'all' },
- ...applications.map((item) => ({ label: item.name, value: item.id })),
- ];
+ return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
}, [applications]);
const filteredRows = records;
@@ -129,7 +137,13 @@ export function ClientSendDetailPage() {
}
function reset() {
- const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' };
+ const defaults = {
+ applicationId: 'all',
+ status: 'all',
+ dateRange: recentBeijingDateRange(7),
+ contentKeyword: '',
+ phoneKeyword: '',
+ };
setApplicationId(defaults.applicationId);
setStatus(defaults.status);
setDateRange(defaults.dateRange);
@@ -150,9 +164,18 @@ export function ClientSendDetailPage() {
共找到 {total} 条发送记录>}
+ summary={
+ <>
+ 共找到 {total} 条发送记录
+ >
+ }
>
-
diff --git a/src/apps/client/ClientTemplatesPage.css b/src/apps/client/ClientTemplatesPage.css
new file mode 100644
index 0000000..7a97e60
--- /dev/null
+++ b/src/apps/client/ClientTemplatesPage.css
@@ -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%;
+ }
+}
diff --git a/src/apps/client/ClientTemplatesPage.test.tsx b/src/apps/client/ClientTemplatesPage.test.tsx
new file mode 100644
index 0000000..9598715
--- /dev/null
+++ b/src/apps/client/ClientTemplatesPage.test.tsx
@@ -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(
);
+ 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();
+ });
+});
diff --git a/src/apps/client/ClientTemplatesPage.tsx b/src/apps/client/ClientTemplatesPage.tsx
index 804fcac..d2c8125 100644
--- a/src/apps/client/ClientTemplatesPage.tsx
+++ b/src/apps/client/ClientTemplatesPage.tsx
@@ -1,9 +1,15 @@
-import { useEffect, useRef, useState } from 'react';
-import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
+import { startTransition, useEffect, useRef, useState } from 'react';
+import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
-import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
+import {
+ clientApi,
+ type ClientSmsApplication,
+ type ClientSmsSignatureView,
+ type ClientSmsTemplate,
+} from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
+import './ClientTemplatesPage.css';
type TemplateVariable = {
name: string;
@@ -15,7 +21,6 @@ type TemplateFormState = {
applicationId: string;
signatureId: string;
name: string;
- category: string;
content: string;
variables: TemplateVariable[];
};
@@ -49,8 +54,10 @@ const recommendedVariables = [
];
function extractVariables(content: string): TemplateVariable[] {
- return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
- .map((name) => ({ name, required: true }));
+ return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
+ name,
+ required: true,
+ }));
}
function billingUnits(content: string) {
@@ -77,23 +84,28 @@ function TemplateModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
- : item?.content ?? '';
+ : (item?.content ?? '');
const [form, setForm] = useState
({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
- category: item?.category ?? '行业通知',
content: initialContent,
- variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
+ variables:
+ item?.variables?.map((variable) => ({
+ name: variable.name,
+ example: variable.example ?? undefined,
+ required: variable.required ?? true,
+ })) ?? [],
});
- const initialForm = useRef(form).current;
+ const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const application = applications.find((candidate) => candidate.id === form.applicationId);
- const availableSignatures = signatures.filter((signature) => (
- signature.auditStatus === 'approved'
- && (!application || signature.tenantId === application.tenantId)
- && (!signature.applicationId || signature.applicationId === form.applicationId)
- ));
+ const availableSignatures = signatures.filter(
+ (signature) =>
+ signature.auditStatus === 'approved' &&
+ (!application || signature.tenantId === application.tenantId) &&
+ (!signature.applicationId || signature.applicationId === form.applicationId),
+ );
const variables = form.variables.length ? form.variables : extractVariables(form.content);
function update(key: Key, value: TemplateFormState[Key]) {
@@ -127,7 +139,10 @@ function TemplateModal({
}
function updateVariableExample(name: string, example: string) {
- update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
+ update(
+ 'variables',
+ variables.map((variable) => (variable.name === name ? { ...variable, example } : variable)),
+ );
}
return (
@@ -135,8 +150,15 @@ function TemplateModal({
dirty={dirty}
footer={({ requestClose }) => (
<>
-
-
+
+
>
)}
onClose={onClose}
@@ -144,22 +166,32 @@ function TemplateModal({
size="xl"
title={item ? '编辑短信模板' : '添加短信模板'}
>
-
+
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}
/>
+ update('name', event.target.value)}
+ placeholder="请输入模板名称"
+ value={form.name}
+ />
selectSignature(event.target.value)}
- options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
+ options={[
+ { label: '请选择签名', value: '' },
+ ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
+ ]}
required
value={form.signatureId}
/>
- update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
- update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
{variablesOpen ? (
) : null}
变量示例
- {variables.length ? variables.map((variable) => (
-
updateVariableExample(variable.name, event.target.value)}
- placeholder="请输入变量示例值"
- value={variable.example ?? ''}
- />
- )) :
模板内容中暂无变量。
}
+ {variables.length ? (
+ variables.map((variable) => (
+
updateVariableExample(variable.name, event.target.value)}
+ placeholder="请输入变量示例值"
+ value={variable.example ?? ''}
+ />
+ ))
+ ) : (
+
模板内容中暂无变量。
+ )}
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
- clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
+ clientApi
+ .listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
.then((templateResult) => {
if (sequence !== requestSequence.current) return;
- setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
+ setTemplates(
+ templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
+ );
setTotal(templateResult.total);
setError('');
})
- .catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
- .finally(() => { if (sequence === requestSequence.current) setLoading(false); });
+ .catch((reason: Error) => {
+ if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
+ })
+ .finally(() => {
+ if (sequence === requestSequence.current) setLoading(false);
+ });
}
useEffect(() => {
- loadData(page);
+ startTransition(() => loadData(page));
}, [appliedKeyword, page]);
useEffect(() => {
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
.catch((reason: Error) => {
if (!cancelled) setError(reason.message || '模板选项加载失败');
});
- return () => { cancelled = true; };
+ return () => {
+ cancelled = true;
+ };
}, []);
const filteredTemplates = templates;
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
signatureId: state.signatureId || undefined,
name: state.name,
content: state.content,
- category: state.category,
variables: state.variables,
};
const template = existing
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
}
return (
-
+
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
-
+
setKeyword(event.target.value)}
placeholder="搜索模板名称、应用、签名或内容"
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
value={keyword}
/>
- } onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}>查询
-
+ }
+ onClick={() => {
+ setPage(1);
+ setAppliedKeyword(keyword.trim());
+ }}
+ >
+ 查询
+
+
-
} onClick={() => setModalTemplate('new')}>添加短信模板
+
} onClick={() => setModalTemplate('new')}>
+ 添加短信模板
+
{loading ?
正在加载短信模板...
: null}
{error ?
{error}
: null}
{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 (
{template.name}
- {template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
- {statusLabel[template.auditStatus] ?? template.auditStatus}
- {template.content}
+
+ {template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
+
+
+ {statusLabel[template.auditStatus] ?? template.auditStatus}
+
+ {template.content}
变量:
- {variables.length > 0 ? variables.map((item) => ${`{${item}}`}) : 无变量}
+ {variables.length > 0 ? (
+ variables.map((item) => ${`{${item}}`})
+ ) : (
+ 无变量
+ )}
-
+
{formatDateTime(template.updatedAt)}
-
- void loadData()} portal="client" targetId={template.id} targetType="template" />
+
+ void loadData()}
+ portal="client"
+ targetId={template.id}
+ targetType="template"
+ />
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
applications={applications}
item={modalTemplate === 'new' ? undefined : modalTemplate}
onClose={() => setModalTemplate(null)}
- onSubmit={(state) => { void saveTemplate(state); }}
+ onSubmit={(state) => {
+ void saveTemplate(state);
+ }}
signatures={signatures}
/>
) : null}
diff --git a/src/apps/client/http-docs/ClientHttpDocsPage.tsx b/src/apps/client/http-docs/ClientHttpDocsPage.tsx
new file mode 100644
index 0000000..64718af
--- /dev/null
+++ b/src/apps/client/http-docs/ClientHttpDocsPage.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'react-router-dom';
+import './HttpDeveloperDocs.css';
+export function ClientHttpDocsPage() {
+ return (
+
+
+
+
接口文档
+
按接入步骤查看签名方法、接口参数和对应示例。
+
+
接口配置
+
+
+
+ );
+}
diff --git a/src/layouts/ClientLayout.tsx b/src/layouts/ClientLayout.tsx
index dd9bcb0..e1c289e 100644
--- a/src/layouts/ClientLayout.tsx
+++ b/src/layouts/ClientLayout.tsx
@@ -54,6 +54,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
{ label: '模板管理', to: '/client/templates', icon: FileText },
{ label: '接口对接', to: '/client/http-api', icon: Cable },
+ { label: '接口文档', to: '/client/http-docs', icon: FileText },
],
},
{
diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx
index 8b9a335..ec33f40 100644
--- a/src/routes/AppRoutes.tsx
+++ b/src/routes/AppRoutes.tsx
@@ -150,6 +150,7 @@ const ClientEnterpriseAuthPage = lazyNamed(
'ClientEnterpriseAuthPage',
);
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
+const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
@@ -185,6 +186,7 @@ export function AppRoutes() {
} />
} />
} />
+
} />
} />
} />
} />
diff --git a/tools/quality/css-ownership.json b/tools/quality/css-ownership.json
index 4b32de0..bdc9fa1 100644
--- a/tools/quality/css-ownership.json
+++ b/tools/quality/css-ownership.json
@@ -290,9 +290,15 @@
},
{
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
- "owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx"],
+ "owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx", "src/apps/client/http-docs/ClientHttpDocsPage.tsx"],
"stylelintLegacy": false,
"roots": ["client-http-docs"]
+ },
+ {
+ "file": "src/apps/client/ClientTemplatesPage.css",
+ "owners": ["src/apps/client/ClientTemplatesPage.tsx"],
+ "stylelintLegacy": false,
+ "roots": ["client-templates-page"]
}
]
}