fix: harden real backend admin workflows and ui
This commit is contained in:
@@ -1,7 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TemplateFormState = {
|
||||
applicationId: string;
|
||||
signatureId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
variables: TemplateVariable[];
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
@@ -18,27 +33,161 @@ const statusLabel: Record<string, string> = {
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
function extractVariables(content: string) {
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['链接', 'link'],
|
||||
];
|
||||
|
||||
function extractVariables(content: string): TemplateVariable[] {
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
|
||||
.map((name) => ({ name, required: true }));
|
||||
}
|
||||
|
||||
function billingUnits(content: string) {
|
||||
if (!content) return 1;
|
||||
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
signatures,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsTemplate;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: TemplateFormState) => void;
|
||||
signatures: ClientSmsSignature[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
content: item?.content ?? '',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
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 variables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||
|
||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function setContent(content: string) {
|
||||
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) return;
|
||||
setContent(`${form.content}\${${normalized}}`);
|
||||
}
|
||||
|
||||
function updateVariableExample(name: string, example: string) {
|
||||
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑短信模板' : '添加短信模板'}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
options={[{ label: '不绑定签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
||||
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 label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={6} value={form.content} />
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientTemplatesPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
|
||||
.then(([applicationItems, templateItems]) => {
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()])
|
||||
.then(([applicationItems, templateItems, signatureItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
setSignatures(signatureItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
|
||||
@@ -50,21 +199,29 @@ export function ClientTemplatesPage() {
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => (
|
||||
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
|
||||
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
|
||||
)), [keyword, templates]);
|
||||
|
||||
function createTemplate() {
|
||||
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
|
||||
clientApi.createTemplate({ applicationId, name, content, variables })
|
||||
.then((created) => clientApi.submitTemplate(created.id))
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setContent('');
|
||||
loadData();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
|
||||
try {
|
||||
const payload = {
|
||||
applicationId: state.applicationId,
|
||||
signatureId: state.signatureId || undefined,
|
||||
name: state.name,
|
||||
content: state.content,
|
||||
category: state.category,
|
||||
variables: state.variables,
|
||||
};
|
||||
const template = existing
|
||||
? await clientApi.updateTemplate(existing.id, { ...payload, signatureId: state.signatureId || null })
|
||||
: await clientApi.createTemplate(payload);
|
||||
await clientApi.submitTemplate(template.id);
|
||||
setModalTemplate(null);
|
||||
loadData();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '模板提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
function disableTemplate(id: string) {
|
||||
@@ -87,22 +244,22 @@ export function ClientTemplatesPage() {
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称、应用或内容"
|
||||
placeholder="搜索模板名称、应用、签名或内容"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}>添加短信模板</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">
|
||||
{filteredTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
|
||||
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}</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="template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
@@ -110,8 +267,12 @@ export function ClientTemplatesPage() {
|
||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||
</div>
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<span>{new Date(template.updatedAt).toLocaleString('zh-CN')}</span>
|
||||
<div>
|
||||
<button onClick={() => setModalTemplate(template)} type="button">
|
||||
<Edit3 size={14} />
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
@@ -124,29 +285,15 @@ export function ClientTemplatesPage() {
|
||||
</div>
|
||||
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
size="xl"
|
||||
title="添加短信模板"
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => setName(event.target.value)} placeholder="请输入模板名称" value={name} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
|
||||
</div>
|
||||
</Modal>
|
||||
{modalTemplate ? (
|
||||
<TemplateModal
|
||||
applications={applications}
|
||||
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
||||
onClose={() => setModalTemplate(null)}
|
||||
onSubmit={(state) => { void saveTemplate(state); }}
|
||||
signatures={signatures}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user