Split customer management modules
This commit is contained in:
@@ -1,22 +1,52 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Building2, DollarSign, Plus, TrendingDown, TrendingUp } from 'lucide-react';
|
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
import { Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
getEnterpriseRecords,
|
getEnterpriseRecords,
|
||||||
|
saveEnterpriseRecords,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
toggleEnterpriseStatus,
|
toggleEnterpriseStatus,
|
||||||
type EnterpriseRecord,
|
type EnterpriseRecord,
|
||||||
} from './adminEnterpriseMock';
|
} from './adminEnterpriseMock';
|
||||||
|
|
||||||
export function AdminCustomersPage() {
|
type AdminCustomersPageProps = {
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||||
|
<Button onClick={onConfirm}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onCancel}
|
||||||
|
open
|
||||||
|
title="操作确认"
|
||||||
|
>
|
||||||
|
<p className="admin-confirm-text">{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [records, setRecords] = useState<EnterpriseRecord[]>(() => getEnterpriseRecords());
|
const [records, setRecords] = useState<EnterpriseRecord[]>(() => getEnterpriseRecords());
|
||||||
const [queryId, setQueryId] = useState('');
|
const [queryId, setQueryId] = useState('');
|
||||||
const [queryName, setQueryName] = useState('');
|
const [queryName, setQueryName] = useState('');
|
||||||
const [queryStatus, setQueryStatus] = useState('all');
|
const [queryStatus, setQueryStatus] = useState('all');
|
||||||
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: EnterpriseRecord } | null>(null);
|
||||||
|
|
||||||
|
function deleteEnterprise(id: string) {
|
||||||
|
const nextRecords = records.filter((record) => record.id !== id);
|
||||||
|
saveEnterpriseRecords(nextRecords);
|
||||||
|
setRecords(nextRecords);
|
||||||
|
}
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => records.filter((record) => {
|
const filteredRecords = useMemo(() => records.filter((record) => {
|
||||||
const matchId = filters.id ? record.id.includes(filters.id) : true;
|
const matchId = filters.id ? record.id.includes(filters.id) : true;
|
||||||
@@ -61,26 +91,34 @@ export function AdminCustomersPage() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/admin/customers/${record.id}`)}
|
onClick={() => navigate(`${basePath}/${record.id}`)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/admin/customers/${record.id}/edit`)}
|
onClick={() => navigate(`${basePath}/${record.id}/edit`)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRecords(toggleEnterpriseStatus(record.id))}
|
onClick={() => setConfirmAction({ type: 'toggle', record })}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={record.status === 'active' ? 'danger' : 'secondary'}
|
variant={record.status === 'active' ? 'danger' : 'secondary'}
|
||||||
>
|
>
|
||||||
{record.status === 'active' ? '禁用' : '启用'}
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', record })}
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -93,7 +131,7 @@ export function AdminCustomersPage() {
|
|||||||
<p className="eyebrow">管理</p>
|
<p className="eyebrow">管理</p>
|
||||||
<h1>企业管理</h1>
|
<h1>企业管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/customers/new')}>
|
<Button icon={<Plus size={16} />} onClick={() => navigate(`${basePath}/new`)}>
|
||||||
添加企业
|
添加企业
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,6 +224,23 @@ export function AdminCustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmAction ? (
|
||||||
|
<ConfirmModal
|
||||||
|
message={confirmAction.type === 'delete'
|
||||||
|
? `确认删除企业“${confirmAction.record.name}”吗?`
|
||||||
|
: `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirmAction.type === 'delete') {
|
||||||
|
deleteEnterprise(confirmAction.record.id);
|
||||||
|
} else {
|
||||||
|
setRecords(toggleEnterpriseStatus(confirmAction.record.id));
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Edit3, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
|
type SmsApp = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enterprise: string;
|
||||||
|
appId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sentToday: number;
|
||||||
|
deliveryRate: number;
|
||||||
|
unitPrice: number;
|
||||||
|
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||||
|
};
|
||||||
|
|
||||||
|
type MmsApp = Omit<SmsApp, 'cmppStatus'> & {
|
||||||
|
pointPrice: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppKind = 'sms' | 'mms';
|
||||||
|
|
||||||
|
const initialSmsApps: SmsApp[] = [
|
||||||
|
{ id: 'app-1', name: '营销推广平台', enterprise: '上海XXXXX科技有限公司', appId: 'AK_2024010912345678', enabled: true, sentToday: 1500, deliveryRate: 95, unitPrice: 0.05, cmppStatus: 'connected' },
|
||||||
|
{ id: 'app-2', name: '客户服务系统', enterprise: '重庆进载数智', appId: 'AK_2024010987654321', enabled: true, sentToday: 800, deliveryRate: 90, unitPrice: 0.06, cmppStatus: 'disconnected' },
|
||||||
|
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialMmsApps: MmsApp[] = [
|
||||||
|
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
|
||||||
|
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
|
||||||
|
{ id: 'mms-app-3', name: '会员权益彩信', enterprise: '四川骠骑企业管理', appId: 'MMS_2024020199001122', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.18, pointPrice: 60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function enabledTag(enabled: boolean) {
|
||||||
|
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||||
|
<Button onClick={onConfirm} variant={danger ? 'danger' : 'primary'}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onCancel}
|
||||||
|
open
|
||||||
|
title="操作确认"
|
||||||
|
>
|
||||||
|
<p className="admin-confirm-text">{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminEnterpriseApplicationsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [smsApps, setSmsApps] = useState(initialSmsApps);
|
||||||
|
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<
|
||||||
|
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
||||||
|
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
function confirmToggle(kind: AppKind, id: string) {
|
||||||
|
if (kind === 'sms') {
|
||||||
|
setSmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(kind: AppKind, id: string) {
|
||||||
|
if (kind === 'sms') {
|
||||||
|
setSmsApps((current) => current.filter((item) => item.id !== id));
|
||||||
|
} else {
|
||||||
|
setMmsApps((current) => current.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runConfirmedAction() {
|
||||||
|
if (!confirmAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmAction.action === 'toggle') {
|
||||||
|
confirmToggle(confirmAction.kind, confirmAction.id);
|
||||||
|
} else {
|
||||||
|
confirmDelete(confirmAction.kind, confirmAction.id);
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
|
||||||
|
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||||
|
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||||
|
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||||||
|
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||||
|
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
|
||||||
|
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||||
|
{
|
||||||
|
key: 'cmppStatus',
|
||||||
|
title: 'CMPP状态',
|
||||||
|
width: '130px',
|
||||||
|
render: (record) => (
|
||||||
|
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||||
|
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
width: '190px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||||
|
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'sms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
||||||
|
{record.enabled ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'sms', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
const mmsColumns = useMemo<Array<TableColumn<MmsApp>>>(() => [
|
||||||
|
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||||
|
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||||
|
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||||||
|
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||||
|
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
|
||||||
|
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||||
|
{ key: 'pointPrice', title: '点数', width: '100px', render: (record) => `${record.pointPrice} 分` },
|
||||||
|
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
width: '190px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/mms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||||
|
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'mms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
||||||
|
{record.enabled ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'mms', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack admin-customer-split-page">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<div className="breadcrumb-line">客户管理 / <strong>企业应用管理</strong></div>
|
||||||
|
<h1>企业应用管理</h1>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/customers/2763/sms-apps/new')}>添加应用</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface section-stack">
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={smsApps} rowKey="id" /> },
|
||||||
|
{ label: '彩信应用', value: 'mms', content: <Table columns={mmsColumns} data={mmsApps} rowKey="id" /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmAction ? (
|
||||||
|
<ConfirmModal
|
||||||
|
danger={confirmAction.action === 'delete'}
|
||||||
|
message={confirmAction.action === 'delete'
|
||||||
|
? `确认删除应用“${confirmAction.name}”吗?`
|
||||||
|
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
onConfirm={runConfirmedAction}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
|
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||||
|
type SignatureKind = 'sms' | 'mms';
|
||||||
|
|
||||||
|
type DrainageInfo = {
|
||||||
|
id: string;
|
||||||
|
siteName: string;
|
||||||
|
url: string;
|
||||||
|
mobile: CarrierStatus;
|
||||||
|
unicom: CarrierStatus;
|
||||||
|
telecom: CarrierStatus;
|
||||||
|
submittedAt: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignatureItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enterprise: string;
|
||||||
|
application: string;
|
||||||
|
mobile: CarrierStatus;
|
||||||
|
unicom: CarrierStatus;
|
||||||
|
telecom: CarrierStatus;
|
||||||
|
drainage?: DrainageInfo[];
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||||
|
approved: '已通过',
|
||||||
|
pending: '审核中',
|
||||||
|
rejected: '已驳回',
|
||||||
|
filing: '待报备',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||||
|
approved: 'success',
|
||||||
|
pending: 'info',
|
||||||
|
rejected: 'danger',
|
||||||
|
filing: 'neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '已通过', value: 'approved' },
|
||||||
|
{ label: '审核中', value: 'pending' },
|
||||||
|
{ label: '已驳回', value: 'rejected' },
|
||||||
|
{ label: '待报备', value: 'filing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialSmsSignatures: SignatureItem[] = [
|
||||||
|
{
|
||||||
|
id: 'sig-1',
|
||||||
|
name: '【科技公司】',
|
||||||
|
enterprise: '上海XXXXX科技有限公司',
|
||||||
|
application: '营销推广平台',
|
||||||
|
mobile: 'approved',
|
||||||
|
unicom: 'approved',
|
||||||
|
telecom: 'approved',
|
||||||
|
updatedAt: '2026-01-08 11:00:00',
|
||||||
|
drainage: [
|
||||||
|
{ id: 'drain-1', siteName: '官网入口', url: 'https://www.example.com', mobile: 'approved', unicom: 'approved', telecom: 'approved', submittedAt: '2026-01-08 11:00:00', remark: '官网首页引流链接,三网报备通过。' },
|
||||||
|
{ id: 'drain-2', siteName: '促销活动页', url: 'https://sale.example.com', mobile: 'approved', unicom: 'pending', telecom: 'pending', submittedAt: '2026-01-09 10:30:00', remark: '活动页待联通、电信回执。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-2',
|
||||||
|
name: '【客户服务】',
|
||||||
|
enterprise: '重庆进载数智',
|
||||||
|
application: '客户服务系统',
|
||||||
|
mobile: 'approved',
|
||||||
|
unicom: 'pending',
|
||||||
|
telecom: 'approved',
|
||||||
|
updatedAt: '2026-01-09 14:20:00',
|
||||||
|
drainage: [
|
||||||
|
{ id: 'drain-3', siteName: '客户服务中心', url: 'https://service.example.com', mobile: 'approved', unicom: 'filing', telecom: 'pending', submittedAt: '2026-01-09 14:20:00', remark: '服务入口链接报备中。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-3',
|
||||||
|
name: '【促销活动】',
|
||||||
|
enterprise: '超感世纪互三网',
|
||||||
|
application: '营销推广平台',
|
||||||
|
mobile: 'rejected',
|
||||||
|
unicom: 'approved',
|
||||||
|
telecom: 'pending',
|
||||||
|
updatedAt: '2026-01-07 10:00:00',
|
||||||
|
drainage: [
|
||||||
|
{ id: 'drain-4', siteName: '促销专区', url: 'https://sale.example.com', mobile: 'rejected', unicom: 'approved', telecom: 'approved', submittedAt: '2026-01-07 10:00:00', remark: '移动侧驳回,需补充页面备案信息。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialMmsSignatures: SignatureItem[] = [
|
||||||
|
{ id: 'mms-sig-1', name: '【活动推广】', enterprise: '上海XXXXX科技有限公司', application: '营销活动彩信', mobile: 'approved', unicom: 'approved', telecom: 'approved', updatedAt: '2026-01-12 09:10:00' },
|
||||||
|
{ id: 'mms-sig-2', name: '【节日祝福】', enterprise: '重庆进载数智', application: '节日祝福彩信', mobile: 'approved', unicom: 'pending', telecom: 'approved', updatedAt: '2026-01-13 16:35:00' },
|
||||||
|
{ id: 'mms-sig-3', name: '【优品发布】', enterprise: '四川骠骑企业管理', application: '营销活动彩信', mobile: 'pending', unicom: 'pending', telecom: 'pending', updatedAt: '2026-01-15 10:28:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusTag({ status }: { status: CarrierStatus }) {
|
||||||
|
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate<T>(items: T[], page: number, pageSize: number) {
|
||||||
|
return items.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, pageSize, total, onPageChange }: { page: number; pageSize: number; total: number; onPageChange: (page: number) => void }) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<div className="admin-split-pagination">
|
||||||
|
<span>{pageSize}条/页</span>
|
||||||
|
<Button disabled={page <= 1} icon={<ChevronLeft size={16} />} iconOnly onClick={() => onPageChange(page - 1)} variant="ghost">上一页</Button>
|
||||||
|
<Button size="sm">{page}</Button>
|
||||||
|
<span>/ {totalPages}</span>
|
||||||
|
<Button disabled={page >= totalPages} icon={<ChevronRight size={16} />} iconOnly onClick={() => onPageChange(page + 1)} variant="ghost">下一页</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureUploadBox({ label, compact = false }: { label: string; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<Upload size={compact ? 30 : 42} />
|
||||||
|
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
|
||||||
|
{!compact ? <small>支持 PNG、JPG、JPEG 格式,大小不超过 3M</small> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureFormModal({
|
||||||
|
item,
|
||||||
|
kind,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
item?: SignatureItem;
|
||||||
|
kind: SignatureKind;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (item: SignatureItem) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(item?.name ?? '');
|
||||||
|
const [enterprise, setEnterprise] = useState(item?.enterprise ?? '');
|
||||||
|
const [application, setApplication] = useState(item?.application ?? '');
|
||||||
|
const [mobile, setMobile] = useState<CarrierStatus>(item?.mobile ?? 'filing');
|
||||||
|
const [unicom, setUnicom] = useState<CarrierStatus>(item?.unicom ?? 'filing');
|
||||||
|
const [telecom, setTelecom] = useState<CarrierStatus>(item?.telecom ?? 'filing');
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
onSubmit({
|
||||||
|
id: item?.id ?? `${kind}-sig-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
enterprise,
|
||||||
|
application,
|
||||||
|
mobile,
|
||||||
|
unicom,
|
||||||
|
telecom,
|
||||||
|
drainage: kind === 'sms' ? (item?.drainage ?? []) : undefined,
|
||||||
|
updatedAt: '2026-06-30 10:00:00',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
|
<Button onClick={submit}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={(
|
||||||
|
<div className="signature-modal-title">
|
||||||
|
<h2>{item ? '编辑签名' : '添加签名'}</h2>
|
||||||
|
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="signature-form">
|
||||||
|
<section>
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<div className="signature-alert">
|
||||||
|
<Info size={18} />
|
||||||
|
<span>签名需履行报备,并遵照管理部门审核结果方可使用。请用 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M。</span>
|
||||||
|
</div>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
<Select
|
||||||
|
defaultValue={item ? 'company' : ''}
|
||||||
|
label="* 签名依据"
|
||||||
|
options={[
|
||||||
|
{ label: '请选择签名依据', value: '' },
|
||||||
|
{ label: '企事业单位证明', value: 'company' },
|
||||||
|
{ label: '商标注册证', value: 'trademark' },
|
||||||
|
{ label: '授权委托书', value: 'authorization' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input label="* 短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" value={name} />
|
||||||
|
</div>
|
||||||
|
<SignatureUploadBox label="* 资质凭证" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>公司信息</h3>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
<Input label="* 公司名称" onChange={(event) => setEnterprise(event.target.value)} placeholder="请输入公司名称" value={enterprise} />
|
||||||
|
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
|
||||||
|
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
|
||||||
|
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
|
||||||
|
<SignatureUploadBox compact label="法人身份证照片-人像面" />
|
||||||
|
<SignatureUploadBox compact label="法人身份证照片-国徽面" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>责任人信息</h3>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
<Input label="* 应用名称" onChange={(event) => setApplication(event.target.value)} placeholder="请输入应用名称" value={application} />
|
||||||
|
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
|
||||||
|
<Input className="signature-form-grid__wide" label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
|
||||||
|
<SignatureUploadBox compact label="责任人身份证照片-人像面" />
|
||||||
|
<SignatureUploadBox compact label="责任人身份证照片-国徽面" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>三网报备状态</h3>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
<Select label="移动状态" onChange={(event) => setMobile(event.target.value as CarrierStatus)} options={statusOptions} value={mobile} />
|
||||||
|
<Select label="联通状态" onChange={(event) => setUnicom(event.target.value as CarrierStatus)} options={statusOptions} value={unicom} />
|
||||||
|
<Select label="电信状态" onChange={(event) => setTelecom(event.target.value as CarrierStatus)} options={statusOptions} value={telecom} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureReportModal({ item, onClose }: { item: SignatureItem; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="签名报备详情">
|
||||||
|
<div className="admin-report-detail">
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div><span>企业名称</span><strong>{item.enterprise}</strong></div>
|
||||||
|
<div><span>应用名称</span><strong>{item.application}</strong></div>
|
||||||
|
<div><span>签名名称</span><strong>{item.name}</strong></div>
|
||||||
|
<div><span>更新时间</span><strong>{item.updatedAt}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-report-tabs">
|
||||||
|
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><StatusTag status={item.mobile} /></span></button>
|
||||||
|
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><StatusTag status={item.unicom} /></span></button>
|
||||||
|
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><StatusTag status={item.telecom} /></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrainageFormModal({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
item?: DrainageInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (item: DrainageInfo) => void;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||||
|
id: `drain-${Date.now()}`,
|
||||||
|
siteName: '',
|
||||||
|
url: '',
|
||||||
|
mobile: 'filing',
|
||||||
|
unicom: 'filing',
|
||||||
|
telecom: 'filing',
|
||||||
|
submittedAt: '2026-06-30 10:00:00',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
|
<Button onClick={() => onSubmit(form)}>保存</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={item ? '编辑引流链接' : '添加引流链接'}
|
||||||
|
>
|
||||||
|
<div className="signature-form drainage-edit-form">
|
||||||
|
<section>
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<Input
|
||||||
|
label="* 引流信息"
|
||||||
|
onChange={(event) => update('url', event.target.value)}
|
||||||
|
placeholder="请输入引流网址"
|
||||||
|
value={form.url}
|
||||||
|
/>
|
||||||
|
<div className="signature-alert drainage-form-note">
|
||||||
|
<Info size={18} />
|
||||||
|
<ol>
|
||||||
|
<li>本页面中所填的信息需与短信内容应用所包含的网站或服务保持一致;</li>
|
||||||
|
<li>图片仅支持 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M;</li>
|
||||||
|
<li>文件格式支持 pdf 格式或者图片,且大小不超过 10M。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
<SignatureUploadBox compact label="* 字段名称1" />
|
||||||
|
<Input label="* 字段名称2" placeholder="请输入字段2内容" />
|
||||||
|
<Input label="* 字段名称3" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入公司名称" value={form.siteName} />
|
||||||
|
<Input label="字段名称4" placeholder="请输入统一社会信用代码" />
|
||||||
|
<Input label="* 字段名称5" placeholder="请输入法人姓名" />
|
||||||
|
<Input label="字段名称6" placeholder="请输入法人身份证号" />
|
||||||
|
<div className="drainage-file-picker">
|
||||||
|
<span>字段名称7:</span>
|
||||||
|
<div>
|
||||||
|
<Button size="sm">请附文件</Button>
|
||||||
|
<em>未选择文件</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input label="* 字段名称8" placeholder="请输入责任人身份证号" />
|
||||||
|
<Input label="* 字段名称9" placeholder="请输入责任人姓名" />
|
||||||
|
<Input label="* 字段名称10" placeholder="请输入责任人手机号" />
|
||||||
|
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
|
||||||
|
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
|
||||||
|
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
|
||||||
|
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
||||||
|
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||||
|
<div><span>网站链接</span><strong>{item.url}</strong></div>
|
||||||
|
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
||||||
|
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
||||||
|
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
||||||
|
<div><span>提交时间</span><strong>{item.submittedAt}</strong></div>
|
||||||
|
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminEnterpriseSignaturesPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<SignatureKind>('sms');
|
||||||
|
const [smsSignatures, setSmsSignatures] = useState(initialSmsSignatures);
|
||||||
|
const [mmsSignatures, setMmsSignatures] = useState(initialMmsSignatures);
|
||||||
|
const [expandedSignatureId, setExpandedSignatureId] = useState('sig-1');
|
||||||
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
|
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [signatureModal, setSignatureModal] = useState<{ kind: SignatureKind; item?: SignatureItem } | null>(null);
|
||||||
|
const [signatureReport, setSignatureReport] = useState<SignatureItem | null>(null);
|
||||||
|
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||||
|
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; signatureKind: SignatureKind; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||||
|
const pageSize = 2;
|
||||||
|
|
||||||
|
const filteredSmsSignatures = useMemo(
|
||||||
|
() => smsSignatures.filter((item) => (
|
||||||
|
(!enterpriseKeyword || item.enterprise.includes(enterpriseKeyword))
|
||||||
|
&& (!signatureKeyword || item.name.includes(signatureKeyword) || item.application.includes(signatureKeyword))
|
||||||
|
)),
|
||||||
|
[enterpriseKeyword, signatureKeyword, smsSignatures],
|
||||||
|
);
|
||||||
|
const filteredMmsSignatures = useMemo(
|
||||||
|
() => mmsSignatures.filter((item) => (
|
||||||
|
(!enterpriseKeyword || item.enterprise.includes(enterpriseKeyword))
|
||||||
|
&& (!signatureKeyword || item.name.includes(signatureKeyword) || item.application.includes(signatureKeyword))
|
||||||
|
)),
|
||||||
|
[enterpriseKeyword, signatureKeyword, mmsSignatures],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagedSmsSignatures = paginate(filteredSmsSignatures, page, pageSize);
|
||||||
|
const pagedMmsSignatures = paginate(filteredMmsSignatures, page, pageSize);
|
||||||
|
|
||||||
|
function upsertSignature(kind: SignatureKind, nextItem: SignatureItem) {
|
||||||
|
const setter = kind === 'sms' ? setSmsSignatures : setMmsSignatures;
|
||||||
|
setter((current) => current.some((item) => item.id === nextItem.id)
|
||||||
|
? current.map((item) => item.id === nextItem.id ? nextItem : item)
|
||||||
|
: [nextItem, ...current]);
|
||||||
|
setSignatureModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertDrainage(signatureId: string, nextItem: DrainageInfo) {
|
||||||
|
setSmsSignatures((current) => current.map((signature) => {
|
||||||
|
if (signature.id !== signatureId) {
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
const drainage = signature.drainage ?? [];
|
||||||
|
const nextDrainage = drainage.some((item) => item.id === nextItem.id)
|
||||||
|
? drainage.map((item) => item.id === nextItem.id ? nextItem : item)
|
||||||
|
: [nextItem, ...drainage];
|
||||||
|
return { ...signature, drainage: nextDrainage };
|
||||||
|
}));
|
||||||
|
setDrainageModal(null);
|
||||||
|
setExpandedSignatureId(signatureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deleteTarget.kind === 'signature') {
|
||||||
|
const setter = deleteTarget.signatureKind === 'sms' ? setSmsSignatures : setMmsSignatures;
|
||||||
|
setter((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||||
|
} else {
|
||||||
|
setSmsSignatures((current) => current.map((signature) => signature.id === deleteTarget.signatureId
|
||||||
|
? { ...signature, drainage: (signature.drainage ?? []).filter((item) => item.id !== deleteTarget.id) }
|
||||||
|
: signature));
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mmsColumns = useMemo<Array<TableColumn<SignatureItem>>>(() => [
|
||||||
|
{ key: 'name', title: '签名名称', width: '170px', render: (record) => <strong>{record.name}</strong> },
|
||||||
|
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||||
|
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application },
|
||||||
|
{ key: 'mobile', title: '移动', width: '110px', render: (record) => <StatusTag status={record.mobile} /> },
|
||||||
|
{ key: 'unicom', title: '联通', width: '110px', render: (record) => <StatusTag status={record.unicom} /> },
|
||||||
|
{ key: 'telecom', title: '电信', width: '110px', render: (record) => <StatusTag status={record.telecom} /> },
|
||||||
|
{ key: 'updatedAt', title: '更新时间', width: '180px', render: (record) => record.updatedAt },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
width: '230px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button icon={<FileText size={15} />} onClick={() => setSignatureReport(record)} size="sm" variant="ghost">报备详情</Button>
|
||||||
|
<Button icon={<Edit3 size={15} />} onClick={() => setSignatureModal({ kind: 'mms', item: record })} size="sm" variant="ghost">编辑</Button>
|
||||||
|
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'mms', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const smsSignatureContent = (
|
||||||
|
<>
|
||||||
|
<div className="signature-list admin-enterprise-signature-list">
|
||||||
|
{pagedSmsSignatures.map((signature) => {
|
||||||
|
const expanded = expandedSignatureId === signature.id;
|
||||||
|
return (
|
||||||
|
<article className="signature-card signature-card--green" key={signature.id}>
|
||||||
|
<div className="signature-summary">
|
||||||
|
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
||||||
|
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
|
</button>
|
||||||
|
<div><span>签名名称</span><strong>{signature.name}</strong></div>
|
||||||
|
<div><span>企业</span><strong>{signature.enterprise}</strong></div>
|
||||||
|
<div><span>应用</span><strong>{signature.application}</strong></div>
|
||||||
|
<div><span>移动</span><StatusTag status={signature.mobile} /></div>
|
||||||
|
<div><span>联通</span><StatusTag status={signature.unicom} /></div>
|
||||||
|
<div><span>电信</span><StatusTag status={signature.telecom} /></div>
|
||||||
|
<div><span>引流信息</span><strong>{signature.drainage?.length ?? 0} 条</strong></div>
|
||||||
|
<div className="signature-actions">
|
||||||
|
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||||
|
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ kind: 'sms', item: signature })} size="sm" variant="ghost">编辑</Button>
|
||||||
|
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="ghost">删除</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="drainage-panel">
|
||||||
|
<h2>引流信息列表</h2>
|
||||||
|
{signature.drainage?.length ? (
|
||||||
|
<div className="drainage-table">
|
||||||
|
<div className="drainage-table__head">
|
||||||
|
<span>站名称</span>
|
||||||
|
<span>网站链接</span>
|
||||||
|
<span>移动</span>
|
||||||
|
<span>联通</span>
|
||||||
|
<span>电信</span>
|
||||||
|
<span>提交时间</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{signature.drainage.map((item) => (
|
||||||
|
<div className="drainage-table__row" key={item.id}>
|
||||||
|
<strong>{item.siteName}</strong>
|
||||||
|
<a href={item.url}>{item.url}</a>
|
||||||
|
<StatusTag status={item.mobile} />
|
||||||
|
<StatusTag status={item.unicom} />
|
||||||
|
<StatusTag status={item.telecom} />
|
||||||
|
<span className="muted">{item.submittedAt}</span>
|
||||||
|
<span className="drainage-row-actions">
|
||||||
|
<button onClick={() => setDrainageReport(item)} type="button">报备详情</button>
|
||||||
|
<button onClick={() => setDrainageModal({ signatureId: signature.id, item })} type="button">编辑</button>
|
||||||
|
<button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} type="button">删除</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">暂无引流信息</p>
|
||||||
|
)}
|
||||||
|
<div className="drainage-panel__footer">
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost">添加引流信息</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredSmsSignatures.length} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack admin-customer-split-page">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<div className="breadcrumb-line">客户管理 / <strong>企业签名管理</strong></div>
|
||||||
|
<h1>企业签名管理</h1>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal({ kind: activeTab })}>添加签名</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface admin-split-filter">
|
||||||
|
<Input label="企业名称" onChange={(event) => { setEnterpriseKeyword(event.target.value); setPage(1); }} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||||
|
<Input label="签名/应用" onChange={(event) => { setSignatureKeyword(event.target.value); setPage(1); }} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
|
||||||
|
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); setPage(1); }} variant="ghost">重置</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface section-stack">
|
||||||
|
<Tabs
|
||||||
|
onChange={(value) => { setActiveTab(value as SignatureKind); setPage(1); }}
|
||||||
|
value={activeTab}
|
||||||
|
items={[
|
||||||
|
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
|
||||||
|
{
|
||||||
|
label: '彩信签名',
|
||||||
|
value: 'mms',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<Table columns={mmsColumns} data={pagedMmsSignatures} rowKey="id" />
|
||||||
|
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredMmsSignatures.length} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signatureModal ? (
|
||||||
|
<SignatureFormModal
|
||||||
|
item={signatureModal.item}
|
||||||
|
kind={signatureModal.kind}
|
||||||
|
onClose={() => setSignatureModal(null)}
|
||||||
|
onSubmit={(item) => upsertSignature(signatureModal.kind, item)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
|
||||||
|
{drainageModal ? (
|
||||||
|
<DrainageFormModal
|
||||||
|
item={drainageModal.item}
|
||||||
|
onClose={() => setDrainageModal(null)}
|
||||||
|
onSubmit={(item) => upsertDrainage(drainageModal.signatureId, item)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null}
|
||||||
|
{deleteTarget ? (
|
||||||
|
<ConfirmModal
|
||||||
|
message={`确认删除“${deleteTarget.name}”吗?删除后仅影响当前本地 mock 数据。`}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Edit3, Eye, FileText, ImageIcon, Info, Music, Plus, Search, Trash2, Video } from 'lucide-react';
|
||||||
|
import { Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||||
|
|
||||||
|
type TemplateKind = 'sms' | 'mms';
|
||||||
|
|
||||||
|
type SmsTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enterprise: string;
|
||||||
|
application: string;
|
||||||
|
hash: string;
|
||||||
|
content: string;
|
||||||
|
variables: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
accent: 'green' | 'blue' | 'red';
|
||||||
|
};
|
||||||
|
|
||||||
|
type MmsTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enterprise: string;
|
||||||
|
application: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
content: string;
|
||||||
|
status: 'approved' | 'pending' | 'rejected';
|
||||||
|
updatedAt: string;
|
||||||
|
accent: 'green' | 'blue' | 'gray';
|
||||||
|
frames?: MmsFrame[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrameType = 'text' | 'image' | 'video' | 'audio';
|
||||||
|
|
||||||
|
type MmsFrame = {
|
||||||
|
id: string;
|
||||||
|
type: FrameType;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSmsTemplates: SmsTemplate[] = [
|
||||||
|
{ id: 'tpl-1', name: '喜约领券', enterprise: '上海XXXXX科技有限公司', application: '营销推广平台', hash: '1c37fb4a7c4a4a63', content: '【喜约领券】尊宾您好!您于${time}备存物品(${item})过期时间${expireTime}。', variables: ['time', 'item', 'expireTime'], updatedAt: '2026-01-04 17:45:36', accent: 'green' },
|
||||||
|
{ id: 'tpl-2', name: '南通仲裁委', enterprise: '重庆进载数智', application: '客户服务系统', hash: '8e0a32c9d7b14c6a', content: '【南通仲裁委】尊敬的仲裁员,${caseNumber}号件请前往小程序或PC端查看本案信息。', variables: ['caseNumber'], updatedAt: '2026-01-04 17:45:36', accent: 'blue' },
|
||||||
|
{ id: 'tpl-3', name: '派件通知', enterprise: '超感世纪互三网', application: '验证码服务', hash: '6e9f23a4b5c7d8e1', content: '【派件通知】${name}您的快递已到达${station},请保持电话畅通。', variables: ['name', 'station'], updatedAt: '2026-01-04 11:20:18', accent: 'red' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialMmsTemplates: MmsTemplate[] = [
|
||||||
|
{ id: 'mms-tpl-1', name: '春节祝福', enterprise: '上海XXXXX科技有限公司', application: '营销活动彩信', code: 'MMS_1a2b3c4d5e6f', title: '新春佳节,福气满满', image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80', content: '【活动推广】尊敬的客户,新春佳节来临之际,祝您新春快乐,万事如意!', status: 'approved', updatedAt: '2026-01-15 10:30:00', accent: 'green', frames: [{ id: 'mms-frame-1', type: 'text' }, { id: 'mms-frame-2', type: 'image' }] },
|
||||||
|
{ id: 'mms-tpl-2', name: '新品发布', enterprise: '重庆进载数智', application: '营销活动彩信', code: 'MMS_2b3c4d5e6f7a', title: '重磅新品震撼来袭', image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80', content: '【新品发布】优品商城重磅新品无线蓝牙耳机震撼来袭!', status: 'pending', updatedAt: '2026-01-16 14:20:00', accent: 'blue', frames: [{ id: 'mms-frame-3', type: 'text' }, { id: 'mms-frame-4', type: 'image' }] },
|
||||||
|
{ id: 'mms-tpl-3', name: '促销活动', enterprise: '超感世纪互三网', application: '节日祝福彩信', code: 'MMS_6f7a8b9c0d1e', title: '限时抢购,低至3折', image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80', content: '【活动推广】年中大促火热进行中!精选商品限时抢购。', status: 'rejected', updatedAt: '2026-01-11 15:30:00', accent: 'gray', frames: [{ id: 'mms-frame-5', type: 'video' }, { id: 'mms-frame-6', type: 'text' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const frameTypeOptions = [
|
||||||
|
{ label: '文字', value: 'text' },
|
||||||
|
{ label: '图片', value: 'image' },
|
||||||
|
{ label: '视频', value: 'video' },
|
||||||
|
{ label: '音频', value: 'audio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const frameIconMap: Record<FrameType, typeof FileText> = {
|
||||||
|
text: FileText,
|
||||||
|
image: ImageIcon,
|
||||||
|
video: Video,
|
||||||
|
audio: Music,
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameFormatMap: Record<FrameType, string> = {
|
||||||
|
text: '',
|
||||||
|
image: '支持格式:jpg, jpeg, png, gif',
|
||||||
|
video: '支持格式:mp4, mpg, 3gp, 3gpp',
|
||||||
|
audio: '支持格式:mp3, mpeg3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const applicationOptions = [
|
||||||
|
{ label: '请选择', value: '' },
|
||||||
|
{ label: '营销推广平台', value: '营销推广平台' },
|
||||||
|
{ label: '客户服务系统', value: '客户服务系统' },
|
||||||
|
{ label: '验证码服务', value: '验证码服务' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const signatureOptions = [
|
||||||
|
{ label: '请选择', value: '' },
|
||||||
|
{ label: '【科技公司】', value: '【科技公司】' },
|
||||||
|
{ label: '【客户服务】', value: '【客户服务】' },
|
||||||
|
{ label: '【促销活动】', value: '【促销活动】' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const recommendedVariables = [
|
||||||
|
['验证码', 'code'],
|
||||||
|
['手机号', 'phone'],
|
||||||
|
['姓名', 'name'],
|
||||||
|
['日期', 'date'],
|
||||||
|
['金额', 'amount'],
|
||||||
|
['时间', 'time'],
|
||||||
|
['余额', 'balance'],
|
||||||
|
['地址', 'address'],
|
||||||
|
['天数', 'days'],
|
||||||
|
['快递单号', 'trackingNumber'],
|
||||||
|
['案件号', 'caseNumber'],
|
||||||
|
['课程名称', 'courseName'],
|
||||||
|
['链接', 'link'],
|
||||||
|
['站点', 'station'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusToneMap = {
|
||||||
|
approved: 'success',
|
||||||
|
pending: 'info',
|
||||||
|
rejected: 'danger',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statusLabelMap = {
|
||||||
|
approved: '已通过',
|
||||||
|
pending: '审核中',
|
||||||
|
rejected: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractVariables(content: string) {
|
||||||
|
return Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate<T>(items: T[], page: number, pageSize: number) {
|
||||||
|
return items.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, pageSize, total, onPageChange }: { page: number; pageSize: number; total: number; onPageChange: (page: number) => void }) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<div className="admin-split-pagination">
|
||||||
|
<span>{pageSize}条/页</span>
|
||||||
|
<Button disabled={page <= 1} icon={<ChevronLeft size={16} />} iconOnly onClick={() => onPageChange(page - 1)} variant="ghost">上一页</Button>
|
||||||
|
<Button size="sm">{page}</Button>
|
||||||
|
<span>/ {totalPages}</span>
|
||||||
|
<Button disabled={page >= totalPages} icon={<ChevronRight size={16} />} iconOnly onClick={() => onPageChange(page + 1)} variant="ghost">下一页</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmsTemplateModal({ item, onClose, onSubmit }: { item?: SmsTemplate; onClose: () => void; onSubmit: (item: SmsTemplate) => void }) {
|
||||||
|
const [application, setApplication] = useState(item?.application ?? '');
|
||||||
|
const [signature, setSignature] = useState('');
|
||||||
|
const [content, setContent] = useState(item?.content ?? '');
|
||||||
|
const [customVariable, setCustomVariable] = useState('');
|
||||||
|
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const variables = extractVariables(content);
|
||||||
|
const wordCount = content.length;
|
||||||
|
const billingCount = Math.max(1, Math.ceil(wordCount / 70));
|
||||||
|
|
||||||
|
function insertVariable(name: string) {
|
||||||
|
if (!name.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContent((current) => `${current}\${${name.trim()}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!application || !signature || !content.trim()) {
|
||||||
|
setError('请填写应用、签名和模板内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
id: item?.id ?? `tpl-${Date.now()}`,
|
||||||
|
name: content.replace(/^【([^】]+)】.*$/, '$1').slice(0, 10) || '新建模板',
|
||||||
|
enterprise: item?.enterprise ?? '上海XXXXX科技有限公司',
|
||||||
|
application,
|
||||||
|
hash: item?.hash ?? Math.random().toString(16).slice(2, 18),
|
||||||
|
content,
|
||||||
|
variables,
|
||||||
|
updatedAt: '2026-06-30 10:00:00',
|
||||||
|
accent: item?.accent ?? 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
|
<Button onClick={submit}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={<div className="template-modal-title"><h2>{item ? '编辑模板' : '添加模板'}</h2><p>请填写模板信息</p></div>}
|
||||||
|
>
|
||||||
|
<div className="template-form">
|
||||||
|
<Select
|
||||||
|
error={error && !application ? '请选择应用' : undefined}
|
||||||
|
label="* 应用:"
|
||||||
|
onChange={(event) => setApplication(event.target.value)}
|
||||||
|
options={applicationOptions}
|
||||||
|
value={application}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
error={error && !signature ? '请选择签名' : undefined}
|
||||||
|
label="* 签名:"
|
||||||
|
onChange={(event) => setSignature(event.target.value)}
|
||||||
|
options={signatureOptions}
|
||||||
|
value={signature}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
error={error && !content.trim() ? '请输入模板内容' : undefined}
|
||||||
|
label="* 模板内容:"
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
placeholder="请输入模板内容"
|
||||||
|
rows={9}
|
||||||
|
value={content}
|
||||||
|
/>
|
||||||
|
<div className="template-form-meta">
|
||||||
|
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||||
|
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||||
|
</button>
|
||||||
|
<span>{wordCount} 字符(不含变量),计费 {billingCount} 条</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 || 'custom');
|
||||||
|
setCustomVariable('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
插入
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="template-info-tip">
|
||||||
|
<Info size={18} />
|
||||||
|
<span>短信字数=签名+模板内容+变量内容,普通短信 70 字符计费 1 条,长短信每 67 字符计算为 1 条短信(包含标点符号和空格)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameEditor({
|
||||||
|
frame,
|
||||||
|
index,
|
||||||
|
onRemove,
|
||||||
|
onTypeChange,
|
||||||
|
}: {
|
||||||
|
frame: MmsFrame;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
onTypeChange: (type: FrameType) => void;
|
||||||
|
}) {
|
||||||
|
const Icon = frameIconMap[frame.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mms-frame">
|
||||||
|
<div className="mms-frame__top">
|
||||||
|
<strong>第 {index + 1} 帧</strong>
|
||||||
|
<Select
|
||||||
|
className="mms-frame-type"
|
||||||
|
onChange={(event) => onTypeChange(event.target.value as FrameType)}
|
||||||
|
options={frameTypeOptions}
|
||||||
|
value={frame.type}
|
||||||
|
/>
|
||||||
|
<button aria-label="删除帧" onClick={onRemove} type="button">
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{frame.type === 'text' ? (
|
||||||
|
<Textarea defaultValue={frame.text} placeholder="请输入文字内容" />
|
||||||
|
) : (
|
||||||
|
<div className="mms-file-drop">
|
||||||
|
<Icon size={22} />
|
||||||
|
<div>
|
||||||
|
<strong>选择文件</strong>
|
||||||
|
<span>未选择任何文件</span>
|
||||||
|
</div>
|
||||||
|
<small>{frameFormatMap[frame.type]}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MmsTemplateModal({ item, onClose, onSubmit }: { item?: MmsTemplate; onClose: () => void; onSubmit: (item: MmsTemplate) => void }) {
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [name, setName] = useState(item?.name ?? '');
|
||||||
|
const [enterprise] = useState(item?.enterprise ?? '上海XXXXX科技有限公司');
|
||||||
|
const [application, setApplication] = useState(item?.application ?? '营销活动彩信');
|
||||||
|
const [title, setTitle] = useState(item?.title ?? '');
|
||||||
|
const [content] = useState(item?.content ?? '这里展示当前彩信模板的文字、图片、视频或音频帧内容。');
|
||||||
|
const [frames, setFrames] = useState<MmsFrame[]>(item?.frames ?? [
|
||||||
|
{ id: 'new-mms-frame-1', type: 'text' },
|
||||||
|
{ id: 'new-mms-frame-2', type: 'image' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalSize = useMemo(() => {
|
||||||
|
const textSize = frames.filter((frame) => frame.type === 'text').length * 0.2;
|
||||||
|
const mediaSize = frames.filter((frame) => frame.type !== 'text').length * 180;
|
||||||
|
return Math.min(2000, textSize + mediaSize).toFixed(1);
|
||||||
|
}, [frames]);
|
||||||
|
|
||||||
|
function addFrame() {
|
||||||
|
if (frames.length >= 9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFrames((items) => [...items, { id: `new-mms-frame-${Date.now()}`, type: 'text' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFrame(id: string) {
|
||||||
|
setFrames((items) => items.filter((frame) => frame.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeFrameType(id: string, type: FrameType) {
|
||||||
|
setFrames((items) => items.map((frame) => (frame.id === id ? { ...frame, type } : frame)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
onSubmit({
|
||||||
|
id: item?.id ?? `mms-tpl-${Date.now()}`,
|
||||||
|
name: name || '新建彩信模板',
|
||||||
|
enterprise,
|
||||||
|
application,
|
||||||
|
code: item?.code ?? `MMS_${Math.random().toString(16).slice(2, 14)}`,
|
||||||
|
title,
|
||||||
|
image: item?.image ?? 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
|
||||||
|
content,
|
||||||
|
status: item?.status ?? 'pending',
|
||||||
|
updatedAt: '2026-06-30 10:00:00',
|
||||||
|
accent: item?.accent ?? 'blue',
|
||||||
|
frames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setPreviewOpen(true)} variant="secondary">预览</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
|
<Button className="mms-save-button" onClick={submit}>确认</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={(
|
||||||
|
<div className="mms-template-modal-title">
|
||||||
|
<h2>{item ? '编辑彩信模板' : '创建彩信模板'}</h2>
|
||||||
|
<p>彩信最多支持9帧,每帧可以是文字、图片、视频或者音频,内容总大小不超过2000KB。提交后需三大运营商审核。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mms-template-form">
|
||||||
|
<div className="mms-template-form-grid">
|
||||||
|
<Input label="彩信模板名称 *" onChange={(event) => setName(event.target.value)} placeholder="春节祝福" value={name} />
|
||||||
|
<Select
|
||||||
|
label="彩信应用 *"
|
||||||
|
onChange={(event) => setApplication(event.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: '营销活动彩信', value: '营销活动彩信' },
|
||||||
|
{ label: '节日祝福彩信', value: '节日祝福彩信' },
|
||||||
|
]}
|
||||||
|
value={application}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
defaultValue="【活动推广】"
|
||||||
|
label="签名 *"
|
||||||
|
options={[
|
||||||
|
{ label: '【活动推广】', value: '【活动推广】' },
|
||||||
|
{ label: '【优品发布】', value: '【优品发布】' },
|
||||||
|
{ label: '【节日祝福】', value: '【节日祝福】' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input label="彩信标题 *" onChange={(event) => setTitle(event.target.value)} placeholder="新春佳节,福气满满" value={title} />
|
||||||
|
|
||||||
|
<div className="mms-frame-header">
|
||||||
|
<div>
|
||||||
|
<strong>彩信内容 *</strong>
|
||||||
|
<span>({frames.length}/9 帧,已使用 {totalSize}KB/2000KB)</span>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={addFrame} variant="ghost">添加帧</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mms-frame-list">
|
||||||
|
{frames.map((frame, index) => (
|
||||||
|
<FrameEditor
|
||||||
|
frame={frame}
|
||||||
|
index={index}
|
||||||
|
key={frame.id}
|
||||||
|
onRemove={() => removeFrame(frame.id)}
|
||||||
|
onTypeChange={(type) => changeFrameType(frame.id, type)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
footer={<Button onClick={() => setPreviewOpen(false)}>关闭</Button>}
|
||||||
|
onClose={() => setPreviewOpen(false)}
|
||||||
|
open={previewOpen}
|
||||||
|
size="md"
|
||||||
|
title={<div className="template-modal-title"><h2>当前模板预览</h2><p>{name || '新建彩信模板'}</p></div>}
|
||||||
|
>
|
||||||
|
<div className="mms-preview">
|
||||||
|
{item?.image ? <img alt={item.name} src={item.image} /> : null}
|
||||||
|
<h3>{title || '新春佳节,福气满满'}</h3>
|
||||||
|
<p>{content}</p>
|
||||||
|
<div className="mms-preview-frames">
|
||||||
|
{frames.map((frame, index) => {
|
||||||
|
const Icon = frameIconMap[frame.type];
|
||||||
|
return (
|
||||||
|
<span key={frame.id}>
|
||||||
|
<Icon size={15} />
|
||||||
|
第 {index + 1} 帧 · {frameTypeOptions.find((option) => option.value === frame.type)?.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminEnterpriseTemplatesPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TemplateKind>('sms');
|
||||||
|
const [smsTemplates, setSmsTemplates] = useState(initialSmsTemplates);
|
||||||
|
const [mmsTemplates, setMmsTemplates] = useState(initialMmsTemplates);
|
||||||
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
|
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [smsModal, setSmsModal] = useState<SmsTemplate | null | undefined>(undefined);
|
||||||
|
const [mmsModal, setMmsModal] = useState<MmsTemplate | null | undefined>(undefined);
|
||||||
|
const [preview, setPreview] = useState<MmsTemplate | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ kind: TemplateKind; id: string; name: string } | null>(null);
|
||||||
|
const pageSize = 2;
|
||||||
|
|
||||||
|
const filteredSmsTemplates = useMemo(
|
||||||
|
() => smsTemplates.filter((template) => (
|
||||||
|
(!enterpriseKeyword || template.enterprise.includes(enterpriseKeyword))
|
||||||
|
&& (!templateKeyword || template.name.includes(templateKeyword) || template.application.includes(templateKeyword) || template.content.includes(templateKeyword))
|
||||||
|
)),
|
||||||
|
[enterpriseKeyword, smsTemplates, templateKeyword],
|
||||||
|
);
|
||||||
|
const filteredMmsTemplates = useMemo(
|
||||||
|
() => mmsTemplates.filter((template) => (
|
||||||
|
(!enterpriseKeyword || template.enterprise.includes(enterpriseKeyword))
|
||||||
|
&& (!templateKeyword || template.name.includes(templateKeyword) || template.application.includes(templateKeyword) || template.title.includes(templateKeyword) || template.content.includes(templateKeyword))
|
||||||
|
)),
|
||||||
|
[enterpriseKeyword, mmsTemplates, templateKeyword],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagedSmsTemplates = paginate(filteredSmsTemplates, page, pageSize);
|
||||||
|
const pagedMmsTemplates = paginate(filteredMmsTemplates, page, pageSize);
|
||||||
|
|
||||||
|
function upsertSmsTemplate(nextTemplate: SmsTemplate) {
|
||||||
|
setSmsTemplates((current) => current.some((item) => item.id === nextTemplate.id)
|
||||||
|
? current.map((item) => item.id === nextTemplate.id ? nextTemplate : item)
|
||||||
|
: [nextTemplate, ...current]);
|
||||||
|
setSmsModal(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMmsTemplate(nextTemplate: MmsTemplate) {
|
||||||
|
setMmsTemplates((current) => current.some((item) => item.id === nextTemplate.id)
|
||||||
|
? current.map((item) => item.id === nextTemplate.id ? nextTemplate : item)
|
||||||
|
: [nextTemplate, ...current]);
|
||||||
|
setMmsModal(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deleteTarget.kind === 'sms') {
|
||||||
|
setSmsTemplates((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||||
|
} else {
|
||||||
|
setMmsTemplates((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smsContent = (
|
||||||
|
<>
|
||||||
|
<div className="template-card-grid">
|
||||||
|
{pagedSmsTemplates.map((template) => (
|
||||||
|
<article className={`template-card template-card--${template.accent}`} key={template.id}>
|
||||||
|
<h2>{template.name}</h2>
|
||||||
|
<p className="muted">{template.enterprise} / {template.application}</p>
|
||||||
|
<p className="template-hash">{template.hash}</p>
|
||||||
|
<p className="template-content">{template.content}</p>
|
||||||
|
<div className="template-vars">
|
||||||
|
<span>变量:</span>
|
||||||
|
{template.variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)}
|
||||||
|
</div>
|
||||||
|
<div className="template-card-footer">
|
||||||
|
<span>{template.updatedAt}</span>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setSmsModal(template)} type="button"><Edit3 size={15} />编辑</button>
|
||||||
|
<button onClick={() => setDeleteTarget({ kind: 'sms', id: template.id, name: template.name })} type="button"><Trash2 size={15} />删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredSmsTemplates.length} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mmsContent = (
|
||||||
|
<>
|
||||||
|
<div className="mms-template-grid">
|
||||||
|
{pagedMmsTemplates.map((template) => (
|
||||||
|
<article className={`mms-template-card mms-template-card--${template.accent}`} key={template.id}>
|
||||||
|
<span className="mms-template-app-tag">{template.enterprise}</span>
|
||||||
|
<div className="mms-template-card__body">
|
||||||
|
<div className="mms-template-meta">
|
||||||
|
<h2>{template.name}</h2>
|
||||||
|
<p className="mms-template-code">{template.code}</p>
|
||||||
|
<h3>{template.title}</h3>
|
||||||
|
</div>
|
||||||
|
<img alt={template.name} src={template.image} />
|
||||||
|
<p className="mms-template-content">{template.content}</p>
|
||||||
|
<div className="mms-template-status">
|
||||||
|
<span>应用:{template.application}</span>
|
||||||
|
<Tag tone={statusToneMap[template.status]}>{statusLabelMap[template.status]}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer className="mms-template-card__footer">
|
||||||
|
<span>{template.updatedAt}</span>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setPreview(template)} type="button"><Eye size={17} />预览</button>
|
||||||
|
<button onClick={() => setMmsModal(template)} type="button"><Edit3 size={15} />编辑</button>
|
||||||
|
<button onClick={() => setDeleteTarget({ kind: 'mms', id: template.id, name: template.name })} type="button"><Trash2 size={15} />删除</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination onPageChange={setPage} page={page} pageSize={pageSize} total={filteredMmsTemplates.length} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack admin-customer-split-page">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<div className="breadcrumb-line">客户管理 / <strong>企业模板管理</strong></div>
|
||||||
|
<h1>企业模板管理</h1>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => (activeTab === 'sms' ? setSmsModal(null) : setMmsModal(null))}>添加模板</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface admin-split-filter">
|
||||||
|
<Input label="企业名称" onChange={(event) => { setEnterpriseKeyword(event.target.value); setPage(1); }} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
|
||||||
|
<Input label="模板/应用" onChange={(event) => { setTemplateKeyword(event.target.value); setPage(1); }} placeholder="请输入模板、应用或内容关键词" prefix={<Search size={16} />} value={templateKeyword} />
|
||||||
|
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); setPage(1); }} variant="ghost">重置</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface section-stack">
|
||||||
|
<Tabs
|
||||||
|
onChange={(value) => { setActiveTab(value as TemplateKind); setPage(1); }}
|
||||||
|
value={activeTab}
|
||||||
|
items={[
|
||||||
|
{ label: '短信模板', value: 'sms', content: smsContent },
|
||||||
|
{ label: '彩信模板', value: 'mms', content: mmsContent },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{smsModal !== undefined ? <SmsTemplateModal item={smsModal ?? undefined} onClose={() => setSmsModal(undefined)} onSubmit={upsertSmsTemplate} /> : null}
|
||||||
|
{mmsModal !== undefined ? <MmsTemplateModal item={mmsModal ?? undefined} onClose={() => setMmsModal(undefined)} onSubmit={upsertMmsTemplate} /> : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
footer={<Button onClick={() => setPreview(null)}>关闭</Button>}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
open={Boolean(preview)}
|
||||||
|
title="彩信预览"
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
<div className="mms-preview">
|
||||||
|
<img alt={preview.name} src={preview.image} />
|
||||||
|
<h3>{preview.title}</h3>
|
||||||
|
<p>{preview.content}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{deleteTarget ? (
|
||||||
|
<ConfirmModal
|
||||||
|
message={`确认删除“${deleteTarget.name}”吗?删除后仅影响当前本地 mock 数据。`}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Building2,
|
Building2,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
|
FilePenLine,
|
||||||
Gauge,
|
Gauge,
|
||||||
Hash,
|
Hash,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -41,6 +42,16 @@ export function AdminLayout() {
|
|||||||
{ label: '客户管理', to: '/admin/customers', icon: Building2 },
|
{ label: '客户管理', to: '/admin/customers', icon: Building2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '客户管理',
|
||||||
|
icon: Building2,
|
||||||
|
items: [
|
||||||
|
{ label: '企业管理', to: '/admin/customer-enterprises', icon: Building2 },
|
||||||
|
{ label: '企业应用管理', to: '/admin/enterprise-applications', icon: Layers3 },
|
||||||
|
{ label: '企业签名管理', to: '/admin/enterprise-signatures', icon: FilePenLine },
|
||||||
|
{ label: '企业模板管理', to: '/admin/enterprise-templates', icon: FileCheck2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '审核中心',
|
title: '审核中心',
|
||||||
icon: FileCheck2,
|
icon: FileCheck2,
|
||||||
@@ -95,7 +106,6 @@ export function AdminLayout() {
|
|||||||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||||||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||||||
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
|
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||||
{ label: '系统配置', to: '/admin/settings', icon: Settings },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
|
|||||||
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
|
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
|
||||||
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
|
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
|
||||||
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
|
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
|
||||||
|
import { AdminEnterpriseApplicationsPage } from '@/apps/admin/AdminEnterpriseApplicationsPage';
|
||||||
import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlacklistPage';
|
import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlacklistPage';
|
||||||
|
import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSignaturesPage';
|
||||||
|
import { AdminEnterpriseTemplatesPage } from '@/apps/admin/AdminEnterpriseTemplatesPage';
|
||||||
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
||||||
import { AdminHome } from '@/apps/admin/AdminHome';
|
import { AdminHome } from '@/apps/admin/AdminHome';
|
||||||
import { AdminMmsApplicationFormPage } from '@/apps/admin/AdminMmsApplicationFormPage';
|
import { AdminMmsApplicationFormPage } from '@/apps/admin/AdminMmsApplicationFormPage';
|
||||||
@@ -93,6 +96,13 @@ export function AppRoutes() {
|
|||||||
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
|
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
|
||||||
<Route path="customers/:enterpriseId/mms-apps/new" element={<AdminMmsApplicationFormPage />} />
|
<Route path="customers/:enterpriseId/mms-apps/new" element={<AdminMmsApplicationFormPage />} />
|
||||||
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<AdminMmsApplicationFormPage />} />
|
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<AdminMmsApplicationFormPage />} />
|
||||||
|
<Route path="customer-enterprises" element={<AdminCustomersPage basePath="/admin/customer-enterprises" />} />
|
||||||
|
<Route path="customer-enterprises/new" element={<AdminCustomerFormPage />} />
|
||||||
|
<Route path="customer-enterprises/:enterpriseId" element={<AdminCustomerDetailPage />} />
|
||||||
|
<Route path="customer-enterprises/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
|
||||||
|
<Route path="enterprise-applications" element={<AdminEnterpriseApplicationsPage />} />
|
||||||
|
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
|
||||||
|
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
|
||||||
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
||||||
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
||||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||||
|
|||||||
@@ -2469,6 +2469,83 @@ h3 {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-enterprise-signature-list .signature-summary {
|
||||||
|
gap: var(--space-2);
|
||||||
|
grid-template-columns: 22px minmax(96px, 0.9fr) minmax(132px, 1fr) minmax(104px, 0.85fr) repeat(4, minmax(64px, 0.55fr)) minmax(206px, auto);
|
||||||
|
min-width: 1040px;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-enterprise-signature-list .signature-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
justify-self: end;
|
||||||
|
min-width: 206px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-enterprise-signature-list .signature-actions .ui-button {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-split-filter {
|
||||||
|
align-items: end;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-split-pagination {
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-split-pagination span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-confirm-text {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid div {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.signature-modal-title h2 {
|
.signature-modal-title h2 {
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
}
|
}
|
||||||
@@ -7480,6 +7557,8 @@ h3 {
|
|||||||
.admin-system-toolbar,
|
.admin-system-toolbar,
|
||||||
.admin-system-modal-form,
|
.admin-system-modal-form,
|
||||||
.admin-drainage-toolbar,
|
.admin-drainage-toolbar,
|
||||||
|
.admin-split-filter,
|
||||||
|
.detail-grid,
|
||||||
.admin-task-detail-grid,
|
.admin-task-detail-grid,
|
||||||
.admin-task-metrics,
|
.admin-task-metrics,
|
||||||
.admin-carrier-grid,
|
.admin-carrier-grid,
|
||||||
|
|||||||
Reference in New Issue
Block a user