fix: harden real backend admin workflows and ui
This commit is contained in:
@@ -1,53 +1,391 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type TemplateFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
signatureId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category: string;
|
||||
variables: TemplateVariable[];
|
||||
};
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['天数', 'days'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['案件号', 'caseNumber'],
|
||||
['链接', 'link'],
|
||||
['站点', 'station'],
|
||||
];
|
||||
|
||||
function extractVariables(content: string): TemplateVariable[] {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'rejected') return 'danger';
|
||||
if (status === 'deleted') return 'neutral';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function billingUnits(content: string) {
|
||||
if (!content) {
|
||||
return 1;
|
||||
}
|
||||
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
|
||||
}
|
||||
|
||||
function TemplateFormModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
signatures,
|
||||
tenants,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsTemplate;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: TemplateFormState) => void;
|
||||
signatures: ClientSmsSignature[];
|
||||
tenants: TenantOption[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
tenantId: item?.tenantId ?? '',
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
content: item?.content ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => signature.tenantId === form.tenantId && signature.auditStatus !== 'deleted');
|
||||
const currentVariables = 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) {
|
||||
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
|
||||
update('variables', variables);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p>模板内容和变量将写入真实后台。</p></div>}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
label="所属企业"
|
||||
onChange={(event) => update('tenantId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="所属应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择应用', value: '' },
|
||||
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
|
||||
]}
|
||||
required
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定签名', value: '' },
|
||||
...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
||||
]}
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required 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="例如:尊敬的${name},您的验证码为${code}。"
|
||||
required
|
||||
rows={8}
|
||||
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>
|
||||
{currentVariables.length ? currentVariables.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>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="模板预览">
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{item.application?.name ?? item.applicationId}</strong></div>
|
||||
<div><span>签名</span><strong>{item.signature?.name ?? '-'}</strong></div>
|
||||
<div><span>计费条数</span><strong>{billingUnits(item.content)} 条</strong></div>
|
||||
<div className="detail-grid__wide"><span>模板内容</span><strong>{item.content}</strong></div>
|
||||
<div className="detail-grid__wide"><span>变量</span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="删除确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
|
||||
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listEnterpriseTemplates({ keyword })
|
||||
.then((items) => {
|
||||
setTemplates(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业模板加载失败'));
|
||||
async function loadData() {
|
||||
try {
|
||||
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
|
||||
adminApi.listEnterpriseTemplates({ keyword: [enterpriseKeyword, templateKeyword].filter(Boolean).join(' ') }),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
adminApi.listEnterpriseSignatures(),
|
||||
]);
|
||||
setTemplates(templateItems);
|
||||
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
|
||||
setApplications(applicationItems);
|
||||
setSignatureItems(signatureList);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => templates.filter((item) => !keyword || [item.name, item.content, item.auditStatus, item.application?.name].join(' ').includes(keyword)), [keyword, templates]);
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => {
|
||||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||||
const application = item.application?.name ?? '';
|
||||
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
|
||||
&& (!templateKeyword || item.name.includes(templateKeyword) || item.content.includes(templateKeyword) || application.includes(templateKeyword));
|
||||
}), [enterpriseKeyword, templateKeyword, templates]);
|
||||
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseTemplate(existing.id, {
|
||||
applicationId: state.applicationId,
|
||||
category: state.category,
|
||||
content: state.content,
|
||||
name: state.name,
|
||||
signatureId: state.signatureId || null,
|
||||
variables: state.variables,
|
||||
});
|
||||
} else {
|
||||
await adminApi.createEnterpriseTemplate({
|
||||
applicationId: state.applicationId,
|
||||
category: state.category,
|
||||
content: state.content,
|
||||
name: state.name,
|
||||
signatureId: state.signatureId || undefined,
|
||||
tenantId: state.tenantId,
|
||||
variables: state.variables,
|
||||
});
|
||||
}
|
||||
setTemplateModal(null);
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业模板保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ClientSmsTemplate>> = [
|
||||
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'content', title: '内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
|
||||
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
|
||||
{ key: 'name', title: '模板名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', width: '240px', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
|
||||
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
|
||||
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0} 个` },
|
||||
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{record.auditStatus}</Tag> },
|
||||
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '220px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(record)} size="sm" variant="ghost">预览</Button>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['企业配置', '企业模板']} />
|
||||
<h1>企业模板</h1>
|
||||
<Breadcrumb items={['客户管理', '企业模板管理']} />
|
||||
<h1>企业模板管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}>添加模板</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-security-filter">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索模板、应用、内容或状态" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<div className="surface admin-split-filter">
|
||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); void loadData(); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}>查询</Button>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={filtered} emptyText="暂无企业模板" rowKey="id" />
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{ label: '短信模板', value: 'sms', content: <Table columns={columns} data={filteredTemplates} emptyText="暂无企业模板" rowKey="id" /> },
|
||||
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty">彩信模板待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{templateModal ? (
|
||||
<TemplateFormModal
|
||||
applications={applications}
|
||||
item={templateModal === 'new' ? undefined : templateModal}
|
||||
onClose={() => setTemplateModal(null)}
|
||||
onSubmit={(state) => { void saveTemplate(state); }}
|
||||
signatures={signatureItems}
|
||||
tenants={tenants}
|
||||
/>
|
||||
) : null}
|
||||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user