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,9 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { startTransition, useEffect, useRef, useState } from 'react';
|
||||
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
|
||||
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import {
|
||||
clientApi,
|
||||
type ClientSmsApplication,
|
||||
type ClientSmsSignatureView,
|
||||
type ClientSmsTemplate,
|
||||
} from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
import './ClientTemplatesPage.css';
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
@@ -15,7 +21,6 @@ type TemplateFormState = {
|
||||
applicationId: string;
|
||||
signatureId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
variables: TemplateVariable[];
|
||||
};
|
||||
@@ -49,8 +54,10 @@ const recommendedVariables = [
|
||||
];
|
||||
|
||||
function extractVariables(content: string): TemplateVariable[] {
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
|
||||
.map((name) => ({ name, required: true }));
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
|
||||
name,
|
||||
required: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function billingUnits(content: string) {
|
||||
@@ -77,23 +84,28 @@ function TemplateModal({
|
||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||
const initialContent = item?.signatureId
|
||||
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
||||
: item?.content ?? '';
|
||||
: (item?.content ?? '');
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
content: initialContent,
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
variables:
|
||||
item?.variables?.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example ?? undefined,
|
||||
required: variable.required ?? true,
|
||||
})) ?? [],
|
||||
});
|
||||
const initialForm = useRef(form).current;
|
||||
const [initialForm] = useState(form);
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||
const availableSignatures = signatures.filter((signature) => (
|
||||
signature.auditStatus === 'approved'
|
||||
&& (!application || signature.tenantId === application.tenantId)
|
||||
&& (!signature.applicationId || signature.applicationId === form.applicationId)
|
||||
));
|
||||
const availableSignatures = signatures.filter(
|
||||
(signature) =>
|
||||
signature.auditStatus === 'approved' &&
|
||||
(!application || signature.tenantId === application.tenantId) &&
|
||||
(!signature.applicationId || signature.applicationId === form.applicationId),
|
||||
);
|
||||
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||
|
||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||
@@ -127,7 +139,10 @@ function TemplateModal({
|
||||
}
|
||||
|
||||
function updateVariableExample(name: string, example: string) {
|
||||
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
|
||||
update(
|
||||
'variables',
|
||||
variables.map((variable) => (variable.name === name ? { ...variable, example } : variable)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -135,8 +150,15 @@ function TemplateModal({
|
||||
dirty={dirty}
|
||||
footer={({ requestClose }) => (
|
||||
<>
|
||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
<Button onClick={requestClose} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
|
||||
onClick={() => onSubmit({ ...form, variables })}
|
||||
>
|
||||
提交审核
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
@@ -144,22 +166,32 @@ function TemplateModal({
|
||||
size="xl"
|
||||
title={item ? '编辑短信模板' : '添加短信模板'}
|
||||
>
|
||||
<div className="template-form">
|
||||
<div className="template-form client-template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
|
||||
options={[
|
||||
{ label: '请选择应用', value: '' },
|
||||
...applications.map((app) => ({ label: app.name, value: app.id })),
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input
|
||||
label="模板名称"
|
||||
onChange={(event) => update('name', event.target.value)}
|
||||
placeholder="请输入模板名称"
|
||||
value={form.name}
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => selectSignature(event.target.value)}
|
||||
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
||||
options={[
|
||||
{ label: '请选择签名', value: '' },
|
||||
...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
||||
]}
|
||||
required
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||
<Textarea
|
||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||
label="模板内容"
|
||||
@@ -174,34 +206,53 @@ function TemplateModal({
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
||||
<span>
|
||||
{form.content.length} 字符,计费 {billingUnits(form.content)} 条
|
||||
</span>
|
||||
</div>
|
||||
{variablesOpen ? (
|
||||
<div className="template-variable-panel">
|
||||
<h3>推荐变量</h3>
|
||||
<div className="template-variable-buttons">
|
||||
{recommendedVariables.map(([label, value]) => (
|
||||
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
|
||||
<button key={value} onClick={() => insertVariable(value)} type="button">
|
||||
{label} ({value})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
|
||||
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}>插入</Button>
|
||||
<Input
|
||||
onChange={(event) => setCustomVariable(event.target.value)}
|
||||
placeholder="英文字符或数字"
|
||||
value={customVariable}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
insertVariable(customVariable);
|
||||
setCustomVariable('');
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="template-variable-panel">
|
||||
<h3>变量示例</h3>
|
||||
{variables.length ? variables.map((variable) => (
|
||||
<Input
|
||||
key={variable.name}
|
||||
label={`\${${variable.name}}`}
|
||||
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
||||
placeholder="请输入变量示例值"
|
||||
value={variable.example ?? ''}
|
||||
/>
|
||||
)) : <p className="muted">模板内容中暂无变量。</p>}
|
||||
{variables.length ? (
|
||||
variables.map((variable) => (
|
||||
<Input
|
||||
key={variable.name}
|
||||
label={`\${${variable.name}}`}
|
||||
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
||||
placeholder="请输入变量示例值"
|
||||
value={variable.example ?? ''}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="muted">模板内容中暂无变量。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
|
||||
function loadData(targetPage = page) {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
||||
clientApi
|
||||
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
||||
.then((templateResult) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
setTemplates(
|
||||
templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
|
||||
);
|
||||
setTotal(templateResult.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
|
||||
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
|
||||
.catch((reason: Error) => {
|
||||
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData(page);
|
||||
startTransition(() => loadData(page));
|
||||
}, [appliedKeyword, page]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
|
||||
.catch((reason: Error) => {
|
||||
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = templates;
|
||||
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
|
||||
signatureId: state.signatureId || undefined,
|
||||
name: state.name,
|
||||
content: state.content,
|
||||
category: state.category,
|
||||
variables: state.variables,
|
||||
};
|
||||
const template = existing
|
||||
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack client-templates-page">
|
||||
<div className="template-page-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-toolbar">
|
||||
<div className="client-template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称、应用、签名或内容"
|
||||
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="ui-query-actions">
|
||||
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost">重置</Button>
|
||||
<Button
|
||||
icon={<Search size={17} />}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setAppliedKeyword(keyword.trim());
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setPage(1);
|
||||
setAppliedKeyword('');
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>添加短信模板</Button>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>
|
||||
添加短信模板
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="template-card-grid">
|
||||
{visibleTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
||||
const variables =
|
||||
template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
||||
return (
|
||||
<article className="template-card template-card--green" key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
|
||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
|
||||
<p className="template-content">{template.content}</p>
|
||||
<p className="muted">
|
||||
{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
|
||||
</p>
|
||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>
|
||||
{statusLabel[template.auditStatus] ?? template.auditStatus}
|
||||
</Tag>
|
||||
<p className="client-template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
<span>变量:</span>
|
||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||
{variables.length > 0 ? (
|
||||
variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)
|
||||
) : (
|
||||
<span className="muted">无变量</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="template-card-footer">
|
||||
<div className="client-template-footer">
|
||||
<span>{formatDateTime(template.updatedAt)}</span>
|
||||
<div>
|
||||
<button onClick={() => setModalTemplate(template)} type="button">
|
||||
<Edit3 size={14} />
|
||||
<Button
|
||||
icon={<Edit3 size={14} />}
|
||||
onClick={() => setModalTemplate(template)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
|
||||
</Button>
|
||||
<DeleteRiskAction
|
||||
onCompleted={() => void loadData()}
|
||||
portal="client"
|
||||
targetId={template.id}
|
||||
targetType="template"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
|
||||
applications={applications}
|
||||
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
||||
onClose={() => setModalTemplate(null)}
|
||||
onSubmit={(state) => { void saveTemplate(state); }}
|
||||
onSubmit={(state) => {
|
||||
void saveTemplate(state);
|
||||
}}
|
||||
signatures={signatures}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user