feat: polish reporting templates and shared controls

This commit is contained in:
hectorzhao
2026-07-15 16:31:49 +08:00
parent 28fb8e9038
commit a7a4e8d9f6
21 changed files with 488 additions and 119 deletions
+6
View File
@@ -1055,10 +1055,16 @@ export const adminApi = {
listChannels: () => request<AdminChannel[]>('/admin/channels'),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)),
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/profit/export', query)),
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/quality/export', query)),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
+35 -30
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
import { Database, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
type DrainageField = DictionaryItem & {
@@ -114,25 +114,9 @@ export function AdminDrainageFieldsPage() {
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
}
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
{ key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => <Tag tone={(record.usageCount ?? 0) > 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0}</Tag> },
{ key: 'commonUsageCount', title: '通用配置数', width: '130px', render: (record) => <Tag tone={(record.commonUsageCount ?? 0) > 0 ? 'info' : 'neutral'}>{record.commonUsageCount ?? 0}</Tag> },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0 || (record.commonUsageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button> },
], []);
const commonColumns = useMemo<Array<TableColumn<CommonReportField>>>(() => [
{ key: 'name', title: '字段名称', render: (record) => <strong>{String(record.drainageField.name ?? record.drainageField.code ?? '-')}</strong> },
{ key: 'code', title: '字段代码', width: '170px', render: (record) => String(record.drainageField.code ?? '-') },
{ key: 'reportType', title: '资料用途', width: '180px', render: (record) => <Tag tone={record.reportType === 'signature' ? 'info' : 'warning'}>{record.reportType === 'signature' ? '签名报备资料' : '引流信息报备资料'}</Tag> },
{ key: 'fieldType', title: '字段类型', width: '130px', render: (record) => typeLabels[String(record.drainageField.fieldType ?? '')] ?? String(record.drainageField.fieldType ?? '-') },
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'neutral'}>{record.required ? '必填' : '选填'}</Tag> },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setCommonDeleteTarget(record)} size="sm" variant="danger"></Button> },
], []);
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
return (
<section className="page-stack admin-system-page admin-drainage-page">
@@ -140,10 +124,18 @@ export function AdminDrainageFieldsPage() {
<div>
<Breadcrumb items={['基础配置', '报备字段库']} />
<h1></h1>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)} size="sm"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="admin-drainage-summary">
<article><span><Database size={18} /></span><div><strong>{fields.length}</strong><p></p></div></article>
<article><span><FileCheck2 size={18} /></span><div><strong>{commonFields.length}</strong><p></p></div></article>
<article><span><Link2 size={18} /></span><div><strong>{referencedCount}</strong><p></p></div></article>
</div>
<div className="surface admin-drainage-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
@@ -151,23 +143,32 @@ export function AdminDrainageFieldsPage() {
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}></Button>
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}></Button>
</div>
<div className="surface admin-system-table-card admin-drainage-table-card">
<div className="page-heading">
<div className="surface admin-drainage-section">
<div className="admin-drainage-section__heading">
<div>
<h2></h2>
<p></p>
<p></p>
</div>
<Button icon={<Plus size={18} />} onClick={() => setConfiguringCommon(true)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setConfiguringCommon(true)} size="sm" variant="secondary"></Button>
</div>
<div className="admin-drainage-common-grid">
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onDelete={setCommonDeleteTarget} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onDelete={setCommonDeleteTarget} tone="warning" />
</div>
<Table columns={commonColumns} data={commonFields} emptyText="暂无通用字段配置" rowKey="id" />
</div>
<div className="surface admin-system-table-card admin-drainage-table-card">
<div className="page-heading"><div><h2></h2><p></p></div></div>
<Table columns={columns} data={filteredFields} emptyText="暂无字段" rowKey="id" />
<div className="surface admin-drainage-section">
<div className="admin-drainage-section__heading"><div><h2></h2><p> {filteredFields.length} </p></div></div>
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
return <article className="admin-drainage-field-card" key={field.id}>
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost"></Button></div>
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
<div className="admin-drainage-field-card__meta"><span> <strong>{field.usageCount ?? 0}</strong></span><span> <strong>{field.commonUsageCount ?? 0}</strong></span></div>
</article>;
})}</div> : <div className="admin-drainage-empty"></div>}
</div>
<Modal
@@ -235,3 +236,7 @@ export function AdminDrainageFieldsPage() {
</section>
);
}
function CommonFieldGroup({ fields, label, onDelete, tone }: { fields: CommonReportField[]; label: string; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
}
@@ -3,6 +3,7 @@ import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
type TemplateFormState = {
tenantId: string;
@@ -85,17 +86,25 @@ function TemplateFormModal({
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
content: item?.content ?? '',
content: initialContent,
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 tenantSignatures = signatures.filter((signature) => (
signature.tenantId === form.tenantId
&& signature.auditStatus !== 'deleted'
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
@@ -106,6 +115,14 @@ function TemplateFormModal({
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
}
function selectSignature(signatureId: string) {
const signature = tenantSignatures.find((candidate) => candidate.id === signatureId);
setForm((current) => {
const content = replaceLeadingSmsSignature(current.content, signature?.name);
return { ...current, signatureId, content, variables: extractVariables(content) };
});
}
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) {
@@ -132,7 +149,7 @@ function TemplateFormModal({
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
</>
)}
onClose={onClose}
@@ -164,19 +181,21 @@ function TemplateFormModal({
/>
<Select
label="签名"
onChange={(event) => update('signatureId', event.target.value)}
onChange={(event) => selectSignature(event.target.value)}
options={[
{ label: '不绑定签名', value: '' },
{ label: '请选择签名', value: '' },
...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
]}
required
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
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
onChange={(event) => setContent(event.target.value)}
placeholder="例如:尊敬的${name},您的验证码为${code}。"
placeholder="请选择签名后填写正文,例如:尊敬的${name},您的验证码为${code}。"
required
rows={8}
ref={contentRef}
+12 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
@@ -21,6 +21,7 @@ export function AdminProfitReportsPage() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
@@ -45,6 +46,13 @@ export function AdminProfitReportsPage() {
}
}
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined }), '利润报表.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '利润报表导出失败'); }
finally { setExporting(false); }
}
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -52,7 +60,7 @@ export function AdminProfitReportsPage() {
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['报表对账', '利润报表']} /><h1></h1></div>
<Tag tone="info">T+1 · T-4T-1</Tag>
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 · T-4T-1</Tag></div>
</div>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(270px, 1.3fr) minmax(180px, .8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
@@ -92,3 +100,5 @@ function defaultDateRange(): DateRangeValue {
function localDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
+12 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Download, Search } from 'lucide-react';
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tabs, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -24,6 +24,7 @@ export function AdminQualityReportsPage() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
@@ -54,6 +55,13 @@ export function AdminQualityReportsPage() {
}
}
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportQualityReports({ dimensionType: dimension, dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: dimension === 'channel' ? undefined : tenantId || undefined, applicationId: dimension === 'channel' ? undefined : applicationId || undefined, channelId: dimension === 'channel' ? channelId || undefined : undefined }), '发送质量报表.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '发送质量报表导出失败'); }
finally { setExporting(false); }
}
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -89,7 +97,7 @@ export function AdminQualityReportsPage() {
return (
<section className="page-stack">
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1></h1></div><Tag tone="info"> 5% · T-4T-1</Tag></div>
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1></h1></div><div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info"> 5% · T-4T-1</Tag></div></div>
<Tabs value={dimension} onChange={changeDimension} items={(Object.keys(dimensionLabels) as QualityDimension[]).map((value) => ({ value, label: dimensionLabels[value], content: reportPanel }))} />
</section>
);
@@ -111,3 +119,5 @@ function defaultDateRange(): DateRangeValue {
function localDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Download, Search } from 'lucide-react';
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -17,6 +17,7 @@ export function AdminReconciliationReportsPage() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()])
@@ -49,6 +50,13 @@ export function AdminReconciliationReportsPage() {
}
}
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: tenantId || undefined, applicationId: applicationId || undefined }), '对账单.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '对账单导出失败'); }
finally { setExporting(false); }
}
const availableApplications = useMemo(
() => applications.filter((application) => !tenantId || application.tenantId === tenantId),
[applications, tenantId],
@@ -59,7 +67,7 @@ export function AdminReconciliationReportsPage() {
<section className="page-stack">
<div className="page-heading">
<div><Breadcrumb items={['报表对账', '对账单']} /><h1></h1></div>
<Tag tone="info">T+1 · T-4T-1</Tag>
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 · T-4T-1</Tag></div>
</div>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
@@ -98,3 +106,5 @@ function defaultDateRange(): DateRangeValue {
function localDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
+2 -2
View File
@@ -198,13 +198,13 @@ export function AdminUsersPage() {
</div>
</div>
<div className="surface admin-system-toolbar">
<div className="surface admin-system-toolbar admin-user-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}></Button>
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={16} />} onClick={openCreate}></Button>
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm"></Button>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface admin-system-table-card">
+28 -5
View File
@@ -3,6 +3,7 @@ import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
type TemplateVariable = {
name: string;
@@ -73,12 +74,16 @@ function TemplateModal({
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
category: item?.category ?? '行业通知',
content: item?.content ?? '',
content: initialContent,
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);
@@ -97,6 +102,14 @@ function TemplateModal({
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
}
function selectSignature(signatureId: string) {
const signature = availableSignatures.find((candidate) => candidate.id === signatureId);
setForm((current) => {
const content = replaceLeadingSmsSignature(current.content, signature?.name);
return { ...current, signatureId, content, variables: extractVariables(content) };
});
}
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) return;
@@ -120,7 +133,7 @@ function TemplateModal({
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}></Button>
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}></Button>
</>
)}
onClose={onClose}
@@ -137,13 +150,23 @@ function TemplateModal({
/>
<Select
label="短信签名"
onChange={(event) => update('signatureId', event.target.value)}
options={[{ label: '不绑定签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
onChange={(event) => selectSignature(event.target.value)}
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 label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" ref={contentRef} rows={6} value={form.content} />
<Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
onChange={(event) => setContent(event.target.value)}
placeholder="请选择签名后填写正文,变量格式:${code}"
ref={contentRef}
required
rows={6}
value={form.content}
/>
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
+1 -1
View File
@@ -158,7 +158,7 @@ export function ClientUsersPage() {
<span className="sms-send-title__icon"><Users size={22} /></span>
<h1></h1>
</div>
<Button icon={<Plus size={18} />} onClick={() => openEditor()}></Button>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button>
</div>
<div className="system-filter-row">
+65 -25
View File
@@ -1,5 +1,6 @@
import type { SelectHTMLAttributes } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, SelectHTMLAttributes } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search } from 'lucide-react';
export type SelectOption = {
@@ -22,6 +23,7 @@ type SelectProps = NativeSelectProps & {
placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
dropdownPortal?: boolean;
onChange?: (event: { target: { value: string } }) => void;
};
@@ -39,12 +41,15 @@ export function Select({
placeholder,
searchable,
searchPlaceholder,
dropdownPortal = true,
required,
...props
}: SelectProps) {
const selectId = id ?? props.name;
const rootRef = useRef<HTMLLabelElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [portalStyle, setPortalStyle] = useState<CSSProperties | null>(null);
const [searchKeyword, setSearchKeyword] = useState('');
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
const selectedValue = value ?? internalValue;
@@ -61,7 +66,7 @@ export function Select({
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
setOpen(false);
setSearchKeyword('');
}
@@ -71,6 +76,40 @@ export function Select({
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, []);
useLayoutEffect(() => {
if (!open || !dropdownPortal) {
setPortalStyle(null);
return;
}
function updatePosition() {
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const gap = 6;
const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
const spaceAbove = rect.top - gap - 8;
const openAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
const available = openAbove ? spaceAbove : spaceBelow;
setPortalStyle({
left: rect.left,
width: rect.width,
maxHeight: Math.min(320, Math.max(140, available)),
...(openAbove
? { bottom: window.innerHeight - rect.top + gap, top: 'auto' }
: { top: rect.bottom + gap, bottom: 'auto' }),
});
}
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [dropdownPortal, open]);
function selectOption(nextValue: string) {
setInternalValue(nextValue);
onChange?.({ target: { value: nextValue } });
@@ -78,6 +117,28 @@ export function Select({
setSearchKeyword('');
}
const dropdown = (
<div
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
ref={dropdownRef}
role="listbox"
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
>
{searchEnabled ? (
<label className="ui-select__search">
<Search size={15} />
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
</label>
) : null}
{visibleOptions.map((option) => (
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
{option.label}
</button>
))}
{visibleOptions.length === 0 ? <span className="ui-select__empty"></span> : null}
</div>
);
return (
<label
className={['ui-field', className].filter(Boolean).join(' ')}
@@ -114,28 +175,7 @@ export function Select({
</span>
<ChevronDown size={16} />
</button>
{open ? (
<div className="ui-select__dropdown" role="listbox">
{searchEnabled ? (
<label className="ui-select__search">
<Search size={15} />
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
</label>
) : null}
{visibleOptions.map((option) => (
<button
aria-selected={option.value === selectedValue}
key={option.value}
onClick={() => selectOption(option.value)}
role="option"
type="button"
>
{option.label}
</button>
))}
{visibleOptions.length === 0 ? <span className="ui-select__empty"></span> : null}
</div>
) : null}
{open ? (dropdownPortal ? createPortal(dropdown, document.body) : dropdown) : null}
</span>
{error ? <span className="ui-field__error">{error}</span> : null}
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
+17
View File
@@ -647,11 +647,28 @@
}
.ui-select__dropdown button {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--color-text);
display: flex;
font-size: var(--font-size-md);
height: 36px;
justify-content: flex-start;
padding: 0 var(--space-3);
text-align: left;
width: 100%;
}
.ui-select__dropdown--portal {
bottom: auto;
left: auto;
max-height: 320px;
position: fixed;
right: auto;
top: auto;
z-index: calc(var(--z-modal) + 1);
}
.ui-select__dropdown button:hover {
+46 -1
View File
@@ -9261,6 +9261,15 @@ h3 {
grid-template-columns: minmax(360px, 1fr) auto;
}
.admin-user-toolbar {
grid-template-columns: minmax(360px, 1fr) auto auto;
}
.admin-user-toolbar > .ui-button {
justify-self: end;
width: max-content;
}
.phone-segment-overview {
display: grid;
gap: var(--space-4);
@@ -9442,9 +9451,38 @@ h3 {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(420px, 1fr) minmax(180px, 220px) minmax(180px, 220px) auto;
grid-template-columns: minmax(320px, 1fr) minmax(160px, 220px) auto;
}
.admin-drainage-page .page-heading > div > p { color: var(--color-text-muted); margin: 6px 0 0; }
.admin-drainage-summary { display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.admin-drainage-summary article { align-items: center; background: linear-gradient(135deg, #fff, var(--color-bg-subtle)); border: 1px solid var(--color-border); border-radius: var(--radius-lg); display: flex; gap: 14px; min-height: 96px; padding: 20px; }
.admin-drainage-summary article > span { align-items: center; background: #eef4ff; border-radius: 12px; color: var(--color-primary); display: flex; height: 42px; justify-content: center; width: 42px; }
.admin-drainage-summary strong { color: var(--color-text-strong); font-size: 24px; }
.admin-drainage-summary p { color: var(--color-text-muted); margin: 3px 0 0; }
.admin-drainage-section { padding: 22px; }
.admin-drainage-section__heading { align-items: flex-start; display: flex; gap: 20px; justify-content: space-between; margin-bottom: 18px; }
.admin-drainage-section__heading h2 { margin: 0 0 6px; }
.admin-drainage-section__heading p { color: var(--color-text-muted); margin: 0; }
.admin-drainage-common-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.admin-drainage-common-group { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); min-width: 0; padding: 16px; }
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
.admin-drainage-common-list { display: grid; gap: 8px; }
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto; padding: 11px 12px; }
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
.admin-drainage-field-card > p { color: var(--color-text-muted); flex: 1; line-height: 1.6; margin: 12px 0; }
.admin-drainage-field-card__meta { border-top: 1px solid var(--color-border); color: var(--color-text-muted); display: flex; font-size: 13px; gap: 18px; padding-top: 12px; }
.admin-drainage-field-card__meta strong { color: var(--color-text-strong); }
.admin-drainage-empty { color: var(--color-text-muted); padding: 42px; text-align: center; }
.admin-drainage-type {
align-items: center;
background: var(--color-bg-subtle);
@@ -9458,6 +9496,13 @@ h3 {
padding: 0 var(--space-3);
}
@media (max-width: 900px) {
.admin-drainage-summary, .admin-drainage-common-grid { grid-template-columns: 1fr; }
.admin-drainage-toolbar { grid-template-columns: 1fr; }
.admin-drainage-section__heading { align-items: stretch; flex-direction: column; }
.admin-user-toolbar { grid-template-columns: 1fr; }
}
.admin-drainage-actions .ui-button--ghost {
color: var(--color-text);
}
+11
View File
@@ -0,0 +1,11 @@
const LEADING_SMS_SIGNATURE = /^【[^】]+】/;
export function formatSmsSignature(name?: string | null) {
const innerName = (name ?? '').trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
return innerName ? `${innerName}` : '';
}
export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) {
const body = content.replace(LEADING_SMS_SIGNATURE, '');
return `${formatSmsSignature(signatureName)}${body}`;
}