fix: harden real backend workflows and channel connections
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type ChannelStatus = 'normal' | 'stopped';
|
||||
@@ -50,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
|
||||
};
|
||||
|
||||
const statusLabels: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
normal: '通道启用',
|
||||
stopped: '通道停用',
|
||||
};
|
||||
|
||||
@@ -75,6 +74,38 @@ function StatusTag({ status }: { status: ChannelStatus }) {
|
||||
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
title,
|
||||
subtitle,
|
||||
channel,
|
||||
status,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
channel?: AdminChannel;
|
||||
status: ChannelStatus;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="channel-route-card">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
<p>{channel?.name ?? '未命名通道'}</p>
|
||||
<small>{channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'}</small>
|
||||
<StatusTag status={status} />
|
||||
<footer>
|
||||
<button onClick={onEdit} type="button"><Pencil size={15} />编辑</button>
|
||||
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={15} />删除</button>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteConfigModal({
|
||||
channels,
|
||||
carrier,
|
||||
@@ -222,40 +253,6 @@ export function AdminChannelGroupFormPage() {
|
||||
loadData();
|
||||
}, [groupId]);
|
||||
|
||||
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
|
||||
{ key: 'province', title: '省份', width: '120px', render: (record) => <strong>{record.province}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'province', mode: 'edit', route: record })} type="button">编辑</button>
|
||||
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== record.id))} type="button">删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [channelById]);
|
||||
|
||||
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
|
||||
{ key: 'priority', title: '优先级', width: '120px', render: (record) => <strong>{record.priority}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="channel-group-row-actions">
|
||||
<button onClick={() => setModal({ type: 'national', mode: 'edit', route: record })} type="button">编辑</button>
|
||||
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== record.id))} type="button">删除</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [channelById]);
|
||||
|
||||
function saveRoute(route: ProvinceRoute | NationalRoute) {
|
||||
if (modal?.type === 'province') {
|
||||
const nextRoute = route as ProvinceRoute;
|
||||
@@ -359,7 +356,20 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>省网分流配置</h2>
|
||||
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" rowKey="id" />
|
||||
<div className="channel-route-card-grid">
|
||||
{provinceRoutes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
channel={channelById.get(route.channelId)}
|
||||
onDelete={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))}
|
||||
onEdit={() => setModal({ type: 'province', mode: 'edit', route })}
|
||||
status={route.status}
|
||||
subtitle="省网优先路由"
|
||||
title={route.province}
|
||||
/>
|
||||
))}
|
||||
{provinceRoutes.length === 0 ? <p className="channel-route-empty">暂无省网通道</p> : null}
|
||||
</div>
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
@@ -367,7 +377,20 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>全国通道配置</h2>
|
||||
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" rowKey="id" />
|
||||
<div className="channel-route-card-grid">
|
||||
{nationalRoutes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
channel={channelById.get(route.channelId)}
|
||||
onDelete={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))}
|
||||
onEdit={() => setModal({ type: 'national', mode: 'edit', route })}
|
||||
status={route.status}
|
||||
subtitle="全国补发路由"
|
||||
title={`优先级 ${route.priority}`}
|
||||
/>
|
||||
))}
|
||||
{nationalRoutes.length === 0 ? <p className="channel-route-empty">暂无全国通道</p> : null}
|
||||
</div>
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Layers3, Plus, Search, UsersRound } from 'lucide-react';
|
||||
import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
|
||||
import { adminApi, type ChannelGroup } from '@/api/adminApi';
|
||||
|
||||
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
];
|
||||
|
||||
const carrierLabels: Record<GroupCarrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
@@ -18,12 +13,10 @@ const carrierLabels: Record<GroupCarrier, string> = {
|
||||
};
|
||||
|
||||
export function AdminChannelGroupsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groups, setGroups] = useState<ChannelGroup[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [carrier, setCarrier] = useState<GroupCarrier>('mobile');
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -41,16 +34,14 @@ export function AdminChannelGroupsPage() {
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
|
||||
|
||||
function createGroup() {
|
||||
adminApi.createChannelGroup({ code, name, carrier, status: 'active' })
|
||||
function deleteGroup() {
|
||||
if (!deleteTarget) return;
|
||||
adminApi.deleteChannelGroup(deleteTarget.id)
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setName('');
|
||||
setCode('');
|
||||
setCarrier('mobile');
|
||||
setDeleteTarget(null);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通道组创建失败'));
|
||||
.catch((failure: Error) => setError(failure.message || '通道组删除失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -59,7 +50,7 @@ export function AdminChannelGroupsPage() {
|
||||
<div>
|
||||
<Breadcrumb items={['短信通道组管理']} />
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/channel-groups/new')}>
|
||||
添加通道组
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,6 +83,14 @@ export function AdminChannelGroupsPage() {
|
||||
})}
|
||||
{(group.items?.length ?? 0) === 0 ? <p className="muted">暂无绑定通道</p> : null}
|
||||
</div>
|
||||
<footer>
|
||||
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
|
||||
<Pencil size={15} />编辑
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
|
||||
<Trash2 size={15} />删除
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -101,26 +100,17 @@ export function AdminChannelGroupsPage() {
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!name || !code} onClick={createGroup}>保存</Button>
|
||||
<Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={deleteGroup} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加通道组"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
open={Boolean(deleteTarget)}
|
||||
title="删除通道组"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} />
|
||||
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||
<div className="channel-group-radio-row">
|
||||
<span>运营商</span>
|
||||
{carrierOptions.map((item) => (
|
||||
<label key={item.value}>
|
||||
<input checked={carrier === item.value} onChange={() => setCarrier(item.value)} type="radio" />
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="channel-confirm">
|
||||
<strong>{deleteTarget?.name}</strong>
|
||||
<p>删除前会校验真实路由绑定;已被企业应用使用的通道组不会被删除。</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
@@ -41,7 +41,7 @@ type ChannelConfirmAction = {
|
||||
|
||||
type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelLinkLogResponse;
|
||||
data?: ChannelConnectionLogResponse;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
@@ -54,10 +54,10 @@ const carrierOptions = [
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '链接正常', value: 'normal' },
|
||||
{ label: '连接正常', value: 'normal' },
|
||||
{ label: '已停用', value: 'stopped' },
|
||||
{ label: '链接中', value: 'connecting' },
|
||||
{ label: '链接失败', value: 'failed' },
|
||||
{ label: '连接中', value: 'connecting' },
|
||||
{ label: '连接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const protocolOptions = [
|
||||
@@ -93,10 +93,10 @@ const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'>
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
normal: '连接正常',
|
||||
stopped: '已停用',
|
||||
connecting: '链接中',
|
||||
failed: '链接失败',
|
||||
connecting: '连接中',
|
||||
failed: '连接失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||||
@@ -106,21 +106,31 @@ const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'dan
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
function mapApiChannel(channel: AdminChannel): SmsChannel {
|
||||
const statusMap: Record<string, ChannelStatus> = {
|
||||
active: 'normal',
|
||||
disabled: 'stopped',
|
||||
deleted: 'stopped',
|
||||
connecting: 'connecting',
|
||||
failed: 'failed',
|
||||
};
|
||||
function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
|
||||
if (channel.status !== 'active') {
|
||||
return 'stopped';
|
||||
}
|
||||
if (connections.some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.currentConnections > 0
|
||||
&& connection.desiredConnections > 0,
|
||||
)) {
|
||||
return 'normal';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
|
||||
sendRegion: channel.sendRegion ?? '全国',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: statusMap[channel.status] ?? 'normal',
|
||||
status: resolveChannelStatus(channel, connections),
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
@@ -353,13 +363,21 @@ export function AdminChannelsPage() {
|
||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function loadChannels() {
|
||||
adminApi.listChannels()
|
||||
.then((items) => {
|
||||
setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel));
|
||||
.then(async (items) => {
|
||||
const visibleChannels = items.filter((item) => item.status !== 'deleted');
|
||||
const connections = await Promise.all(visibleChannels.map((channel) =>
|
||||
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
|
||||
));
|
||||
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index])));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
@@ -375,16 +393,15 @@ export function AdminChannelsPage() {
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
try {
|
||||
if (modal?.mode === 'edit' && modal.channel) {
|
||||
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
|
||||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
} else {
|
||||
const created = await adminApi.createChannel({
|
||||
await adminApi.createChannel({
|
||||
code: `CH-${Date.now()}`,
|
||||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||||
status: 'active',
|
||||
});
|
||||
setChannels((items) => [mapApiChannel(created), ...items]);
|
||||
}
|
||||
loadChannels();
|
||||
setModal(null);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
@@ -393,8 +410,8 @@ export function AdminChannelsPage() {
|
||||
}
|
||||
|
||||
async function toggleChannel(channel: SmsChannel) {
|
||||
const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item)));
|
||||
await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function deleteChannel(id: string) {
|
||||
@@ -403,13 +420,13 @@ export function AdminChannelsPage() {
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
const copied = await adminApi.copyChannel(channel.id);
|
||||
setChannels((items) => [mapApiChannel(copied), ...items]);
|
||||
await adminApi.copyChannel(channel.id);
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function openLinkLogs(channel: SmsChannel) {
|
||||
setLogState({ channel });
|
||||
const data = await adminApi.listChannelLinkLogs(channel.id);
|
||||
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
||||
setLogState({ channel, data });
|
||||
}
|
||||
|
||||
@@ -446,7 +463,7 @@ export function AdminChannelsPage() {
|
||||
: confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入链接中状态,后续可继续观察网关连接。'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
@@ -491,7 +508,7 @@ export function AdminChannelsPage() {
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => void openLinkLogs(channel)} type="button">
|
||||
<FileText size={14} />链接日志
|
||||
<FileText size={14} />连接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
@@ -564,7 +581,7 @@ export function AdminChannelsPage() {
|
||||
onClose={() => setLogState(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>链接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
title={<div className="template-modal-title"><h2>连接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
>
|
||||
<div className="channel-log-list">
|
||||
{(logState.data?.logs ?? []).map((log) => (
|
||||
@@ -579,8 +596,8 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无链接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载链接日志...</p> : null}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无连接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { adminApi, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type EnterpriseForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
creditCode: string;
|
||||
province: string;
|
||||
city: string;
|
||||
@@ -18,6 +16,7 @@ type EnterpriseForm = {
|
||||
contactEmail: string;
|
||||
photoFileObjectId: string;
|
||||
photoFileName: string;
|
||||
photoContentType: string;
|
||||
};
|
||||
|
||||
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
|
||||
@@ -44,8 +43,6 @@ const cityOptionsByProvince: Record<string, Array<{ label: string; value: string
|
||||
|
||||
const emptyForm: EnterpriseForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
status: 'active',
|
||||
creditCode: '',
|
||||
province: '',
|
||||
city: '',
|
||||
@@ -56,14 +53,13 @@ const emptyForm: EnterpriseForm = {
|
||||
contactEmail: '',
|
||||
photoFileObjectId: '',
|
||||
photoFileName: '',
|
||||
photoContentType: '',
|
||||
};
|
||||
|
||||
function formFromTenant(tenant: TenantOption): EnterpriseForm {
|
||||
const profile = tenant.enterpriseProfile;
|
||||
return {
|
||||
name: tenant.name,
|
||||
code: tenant.code,
|
||||
status: tenant.status,
|
||||
creditCode: profile?.creditCode ?? '',
|
||||
province: profile?.province ?? '',
|
||||
city: profile?.city ?? '',
|
||||
@@ -74,6 +70,7 @@ function formFromTenant(tenant: TenantOption): EnterpriseForm {
|
||||
contactEmail: profile?.contactEmail ?? '',
|
||||
photoFileObjectId: profile?.photoFileObjectId ?? '',
|
||||
photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '',
|
||||
photoContentType: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +114,6 @@ export function AdminCustomerFormPage() {
|
||||
function validateForm() {
|
||||
const nextErrors: EnterpriseFormErrors = {};
|
||||
if (!form.name.trim()) nextErrors.name = '请填写企业名称';
|
||||
if (!form.code.trim()) nextErrors.code = '请填写企业编码';
|
||||
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
|
||||
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
|
||||
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
|
||||
@@ -128,7 +124,7 @@ export function AdminCustomerFormPage() {
|
||||
function submitForm() {
|
||||
if (!validateForm()) return;
|
||||
setSaving(true);
|
||||
const { photoFileName, ...payload } = form;
|
||||
const { photoContentType, photoFileName, ...payload } = form;
|
||||
const request = isEdit && enterpriseId
|
||||
? adminApi.updateTenant(enterpriseId, payload)
|
||||
: adminApi.createTenant(payload);
|
||||
@@ -143,13 +139,22 @@ export function AdminCustomerFormPage() {
|
||||
setUploadingPhoto(true);
|
||||
adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' })
|
||||
.then((fileObject) => {
|
||||
setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName }));
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
photoContentType: fileObject.contentType,
|
||||
photoFileObjectId: fileObject.id,
|
||||
photoFileName: fileObject.fileName,
|
||||
}));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业照片上传失败'))
|
||||
.finally(() => setUploadingPhoto(false));
|
||||
}
|
||||
|
||||
const photoFile: FileRef | null = form.photoFileObjectId
|
||||
? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照片', fileObjectId: form.photoFileObjectId }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="page-stack enterprise-form-page">
|
||||
<div className="page-heading">
|
||||
@@ -182,22 +187,22 @@ export function AdminCustomerFormPage() {
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<FileActions file={photoFile} />
|
||||
<p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input error={errors.name} label="企业名称" onChange={(event) => updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} />
|
||||
<Input error={errors.code} label="企业编码" onChange={(event) => updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} />
|
||||
<Input
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业档案。"
|
||||
label="统一社会信用代码"
|
||||
onChange={(event) => updateForm('creditCode', event.target.value)}
|
||||
placeholder="请填写统一社会信用代码或纳税识别号"
|
||||
required
|
||||
value={form.creditCode}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业档案。"
|
||||
label="统一社会信用代码"
|
||||
onChange={(event) => updateForm('creditCode', event.target.value)}
|
||||
placeholder="请填写统一社会信用代码或纳税识别号"
|
||||
required
|
||||
value={form.creditCode}
|
||||
/>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} />
|
||||
@@ -235,13 +240,6 @@ export function AdminCustomerFormPage() {
|
||||
<Input error={errors.contactPhone} label="手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" required value={form.contactPhone} />
|
||||
<Input label="电子邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} />
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="企业状态"
|
||||
onChange={(event) => updateForm('status', event.target.value)}
|
||||
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={form.status}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type AdminCustomersPageProps = {
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
type CustomerRow = TenantOption & {
|
||||
account?: TenantAccount;
|
||||
type CustomerRow = TenantManagementRow;
|
||||
|
||||
type RechargeForm = {
|
||||
amount: string;
|
||||
operator: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function formatCurrency(cents: number) {
|
||||
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
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="操作确认">
|
||||
@@ -20,6 +28,14 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
);
|
||||
}
|
||||
|
||||
function emptyRechargeForm(): RechargeForm {
|
||||
return {
|
||||
amount: '',
|
||||
operator: '运营',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<CustomerRow[]>([]);
|
||||
@@ -28,15 +44,16 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const [queryStatus, setQueryStatus] = useState('all');
|
||||
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
||||
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
|
||||
const [rechargeError, setRechargeError] = useState('');
|
||||
const [recharging, setRecharging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listAccounts()])
|
||||
.then(([tenants, accounts]) => {
|
||||
setRecords(tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
|
||||
...tenant,
|
||||
account: accounts.find((account) => account.tenantId === tenant.id),
|
||||
})));
|
||||
adminApi.listTenantManagementRows()
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
|
||||
@@ -47,7 +64,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
}, []);
|
||||
|
||||
const filteredRecords = useMemo(() => records.filter((record) => {
|
||||
const matchId = filters.id ? record.id.includes(filters.id) || record.code.includes(filters.id) : true;
|
||||
const matchId = filters.id ? record.id.includes(filters.id) : true;
|
||||
const matchName = filters.name ? record.name.includes(filters.name) : true;
|
||||
const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
|
||||
return matchId && matchName && matchStatus;
|
||||
@@ -58,23 +75,34 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
|
||||
|
||||
const columns: Array<TableColumn<CustomerRow>> = [
|
||||
{ key: 'id', title: '企业ID', width: '240px', render: (record) => <span className="table-mono-id">{record.id}</span> },
|
||||
{ key: 'id', title: '企业ID', width: '160px', render: (record) => <span className="table-mono-id">{record.id}</span> },
|
||||
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
|
||||
{ key: 'code', title: '企业编码', width: '180px', render: (record) => <span className="table-mono-id">{record.code}</span> },
|
||||
{ key: 'creditCode', title: '统一社会信用代码', width: '220px', render: (record) => record.enterpriseProfile?.creditCode || '-' },
|
||||
{ key: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' },
|
||||
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' },
|
||||
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
|
||||
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')} 条` },
|
||||
{
|
||||
key: 'balance',
|
||||
title: '当前余额',
|
||||
width: '150px',
|
||||
align: 'right',
|
||||
render: (record) => {
|
||||
const balance = record.account?.balanceCents ?? 0;
|
||||
return (
|
||||
<span className={balance < 0 ? 'status-danger' : ''}>
|
||||
¥{formatCurrency(balance)}
|
||||
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag">欠费</Tag> : null}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'overdraftLimit', title: '透支限额', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(record.account?.creditCents ?? 0)}` },
|
||||
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpendCents)}` },
|
||||
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '280px',
|
||||
width: '300px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost">详情</Button>
|
||||
<Button icon={<DollarSign size={15} />} onClick={() => openRechargeModal(record)} size="sm" variant="ghost">充值</Button>
|
||||
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
@@ -85,6 +113,42 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
},
|
||||
];
|
||||
|
||||
function openRechargeModal(record: CustomerRow) {
|
||||
setRechargeTarget(record);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
|
||||
setRechargeForm((current) => ({ ...current, [key]: value }));
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setRechargeError('请填写大于 0 的充值金额');
|
||||
return;
|
||||
}
|
||||
setRecharging(true);
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: rechargeTarget.id,
|
||||
amountCents: Math.round(amount * 100),
|
||||
smsUnits: 0,
|
||||
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
|
||||
});
|
||||
setRechargeTarget(null);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
|
||||
} finally {
|
||||
setRecharging(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
if (!confirmAction) return;
|
||||
const action = confirmAction.type === 'delete'
|
||||
@@ -108,13 +172,13 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
<div className="surface mini-status-card"><Building2 size={22} /><div><span>企业总数</span><strong>{records.length}</strong><small>真实租户数量。</small></div></div>
|
||||
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span>正常运营</span><strong>{activeCount}</strong><small>可正常提交发送任务。</small></div></div>
|
||||
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span>已禁用</span><strong>{disabledCount}</strong><small>已暂停发送能力。</small></div></div>
|
||||
<div className="surface mini-status-card"><DollarSign size={22} /><div><span>账户余额</span><strong>¥{(totalBalance / 100).toLocaleString('zh-CN')}</strong><small>企业账户余额汇总。</small></div></div>
|
||||
<div className="surface mini-status-card"><DollarSign size={22} /><div><span>账户余额</span><strong>¥{formatCurrency(totalBalance)}</strong><small>企业账户余额汇总。</small></div></div>
|
||||
</div>
|
||||
|
||||
<div className="surface ui-query-panel">
|
||||
<h2>查询条件</h2>
|
||||
<div className="ui-query-panel__grid enterprise-query-grid">
|
||||
<Input label="企业ID/编码" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID或编码" value={queryId} />
|
||||
<Input label="企业ID" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} />
|
||||
<Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} />
|
||||
<Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} />
|
||||
<div className="enterprise-query-actions">
|
||||
@@ -136,6 +200,29 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
onConfirm={submitConfirmAction}
|
||||
/>
|
||||
) : null}
|
||||
{rechargeTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost">取消</Button>
|
||||
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setRechargeTarget(null)}
|
||||
open
|
||||
size="md"
|
||||
title="企业人工充值"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
||||
<Input disabled label="当前余额" prefix="¥" value={formatCurrency(rechargeTarget.account?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
|
||||
<Input label="操作人" onChange={(event) => updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
||||
</div>
|
||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||
}
|
||||
|
||||
function mapConnection(connection: CmppConnectionState): CmppConnection {
|
||||
const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0;
|
||||
const isOpen = connection.status === 'connected' && connection.currentConnections > 0;
|
||||
return {
|
||||
id: connection.connectionId,
|
||||
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
@@ -9,6 +9,16 @@ type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
field1File?: UploadedFileRef | null;
|
||||
field2?: string;
|
||||
field3?: string;
|
||||
field4?: string;
|
||||
field5?: string;
|
||||
field6?: string;
|
||||
field7File?: UploadedFileRef | null;
|
||||
field8?: string;
|
||||
field9?: string;
|
||||
field10?: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
@@ -16,11 +26,30 @@ type DrainageInfo = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type UploadedFileRef = FileRef;
|
||||
|
||||
type SignatureProfile = {
|
||||
basis: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
legalPersonName: string;
|
||||
legalPersonIdCard: string;
|
||||
responsibleName: string;
|
||||
responsiblePhone: string;
|
||||
responsibleIdCard: string;
|
||||
credentialFile?: UploadedFileRef | null;
|
||||
legalFrontFile?: UploadedFileRef | null;
|
||||
legalBackFile?: UploadedFileRef | null;
|
||||
responsibleFrontFile?: UploadedFileRef | null;
|
||||
responsibleBackFile?: UploadedFileRef | null;
|
||||
};
|
||||
|
||||
type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
profile: SignatureProfile;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
@@ -54,6 +83,7 @@ function StatusTag({ status }: { status: CarrierStatus }) {
|
||||
function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
|
||||
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
|
||||
const profile = typeof payload.signatureProfile === 'object' && payload.signatureProfile ? payload.signatureProfile as Record<string, unknown> : {};
|
||||
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
const fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
|
||||
return {
|
||||
@@ -62,10 +92,21 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
|
||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||
},
|
||||
signatureProfile: normalizeSignatureProfile(profile, signature),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
field1File: normalizeUploadedFile(item.field1File),
|
||||
field2: String(item.field2 ?? ''),
|
||||
field3: String(item.field3 ?? ''),
|
||||
field4: String(item.field4 ?? ''),
|
||||
field5: String(item.field5 ?? ''),
|
||||
field6: String(item.field6 ?? ''),
|
||||
field7File: normalizeUploadedFile(item.field7File),
|
||||
field8: String(item.field8 ?? ''),
|
||||
field9: String(item.field9 ?? ''),
|
||||
field10: String(item.field10 ?? ''),
|
||||
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
|
||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||
@@ -75,8 +116,54 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[]) {
|
||||
return { carrierStatus, links };
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile) {
|
||||
return { carrierStatus, links, signatureProfile };
|
||||
}
|
||||
|
||||
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
const fileObjectId = String(item.fileObjectId ?? '');
|
||||
const fileName = String(item.fileName ?? '');
|
||||
const contentType = typeof item.contentType === 'string' ? item.contentType : undefined;
|
||||
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
|
||||
}
|
||||
|
||||
function emptySignatureProfile(signature?: ClientSmsSignature): SignatureProfile {
|
||||
return {
|
||||
basis: '',
|
||||
companyName: signature?.tenant?.name ?? '',
|
||||
creditCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonIdCard: '',
|
||||
responsibleName: '',
|
||||
responsiblePhone: '',
|
||||
responsibleIdCard: '',
|
||||
credentialFile: null,
|
||||
legalFrontFile: null,
|
||||
legalBackFile: null,
|
||||
responsibleFrontFile: null,
|
||||
responsibleBackFile: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSignatureProfile(value: Record<string, unknown>, signature?: ClientSmsSignature): SignatureProfile {
|
||||
return {
|
||||
...emptySignatureProfile(signature),
|
||||
basis: String(value.basis ?? ''),
|
||||
companyName: String(value.companyName ?? signature?.tenant?.name ?? ''),
|
||||
creditCode: String(value.creditCode ?? ''),
|
||||
legalPersonName: String(value.legalPersonName ?? ''),
|
||||
legalPersonIdCard: String(value.legalPersonIdCard ?? ''),
|
||||
responsibleName: String(value.responsibleName ?? ''),
|
||||
responsiblePhone: String(value.responsiblePhone ?? ''),
|
||||
responsibleIdCard: String(value.responsibleIdCard ?? ''),
|
||||
credentialFile: normalizeUploadedFile(value.credentialFile),
|
||||
legalFrontFile: normalizeUploadedFile(value.legalFrontFile),
|
||||
legalBackFile: normalizeUploadedFile(value.legalBackFile),
|
||||
responsibleFrontFile: normalizeUploadedFile(value.responsibleFrontFile),
|
||||
responsibleBackFile: normalizeUploadedFile(value.responsibleBackFile),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
@@ -91,6 +178,53 @@ function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function SignatureUploadBox({
|
||||
compact = false,
|
||||
file,
|
||||
label,
|
||||
onUploaded,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
file?: UploadedFileRef | null;
|
||||
label: string;
|
||||
onUploaded: (file: UploadedFileRef) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function uploadFile(fileInput: File | undefined) {
|
||||
if (!fileInput) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fileObject = await adminApi.uploadFileObject(fileInput, { purpose: 'signature_report_material', prefix: 'signature-report-materials' });
|
||||
onUploaded({ contentType: fileObject.contentType, fileObjectId: fileObject.id, fileName: fileObject.fileName });
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '文件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
<span>{label}</span>
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{uploading ? '上传中...' : file?.fileName || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
||||
<FileActions file={file} />
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
||||
{error ? <small className="form-error">{error}</small> : null}
|
||||
<input
|
||||
accept="image/png,image/jpeg,application/pdf"
|
||||
disabled={uploading}
|
||||
onChange={(event) => { void uploadFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureFormModal({
|
||||
applications,
|
||||
item,
|
||||
@@ -110,6 +244,7 @@ function SignatureFormModal({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
name: item?.name ?? '',
|
||||
purpose: item?.purpose ?? '',
|
||||
profile: payload?.signatureProfile ?? emptySignatureProfile(item),
|
||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||
@@ -120,6 +255,10 @@ function SignatureFormModal({
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateProfile<Key extends keyof SignatureProfile>(key: Key, value: SignatureProfile[Key]) {
|
||||
setForm((current) => ({ ...current, profile: { ...current.profile, [key]: value } }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
@@ -131,11 +270,20 @@ function SignatureFormModal({
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑短信签名' : '添加短信签名'}
|
||||
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 或 PDF 格式上传真实材料。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
@@ -149,7 +297,7 @@ function SignatureFormModal({
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="所属应用"
|
||||
label="* 应用名称"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定应用', value: '' },
|
||||
@@ -157,8 +305,46 @@ function SignatureFormModal({
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input label="短信签名" onChange={(event) => update('name', event.target.value)} placeholder="例如【某某科技】" required value={form.name} />
|
||||
<Input label="签名用途" onChange={(event) => update('purpose', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.purpose} />
|
||||
<Select
|
||||
label="* 签名依据"
|
||||
onChange={(event) => updateProfile('basis', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择签名依据', value: '' },
|
||||
{ label: '企事业单位证明', value: 'company' },
|
||||
{ label: '商标注册证', value: 'trademark' },
|
||||
{ label: '授权委托书', value: 'authorization' },
|
||||
]}
|
||||
value={form.profile.basis}
|
||||
/>
|
||||
<Input label="* 短信签名" onChange={(event) => update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" required value={form.name} />
|
||||
</div>
|
||||
<SignatureUploadBox
|
||||
file={form.profile.credentialFile}
|
||||
label="* 资质凭证"
|
||||
onUploaded={(file) => updateProfile('credentialFile', file)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>公司信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 公司名称" onChange={(event) => updateProfile('companyName', event.target.value)} placeholder="请输入公司名称" value={form.profile.companyName} />
|
||||
<Input label="* 统一社会信用代码" onChange={(event) => updateProfile('creditCode', event.target.value)} placeholder="请输入统一社会信用代码" value={form.profile.creditCode} />
|
||||
<Input label="* 法人姓名" onChange={(event) => updateProfile('legalPersonName', event.target.value)} placeholder="请输入法人姓名" value={form.profile.legalPersonName} />
|
||||
<Input label="法人身份证号" onChange={(event) => updateProfile('legalPersonIdCard', event.target.value)} placeholder="请输入法人身份证号" value={form.profile.legalPersonIdCard} />
|
||||
<SignatureUploadBox compact file={form.profile.legalFrontFile} label="法人身份证照片-人像面" onUploaded={(file) => updateProfile('legalFrontFile', file)} />
|
||||
<SignatureUploadBox compact file={form.profile.legalBackFile} label="法人身份证照片-国徽面" onUploaded={(file) => updateProfile('legalBackFile', file)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>责任人信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 责任人姓名" onChange={(event) => updateProfile('responsibleName', event.target.value)} placeholder="请输入责任人姓名" value={form.profile.responsibleName} />
|
||||
<Input label="* 责任人手机号" onChange={(event) => updateProfile('responsiblePhone', event.target.value)} placeholder="请输入责任人手机号" value={form.profile.responsiblePhone} />
|
||||
<Input className="signature-form-grid__wide" label="* 责任人身份证号" onChange={(event) => updateProfile('responsibleIdCard', event.target.value)} placeholder="请输入责任人身份证号" value={form.profile.responsibleIdCard} />
|
||||
<SignatureUploadBox compact file={form.profile.responsibleFrontFile} label="责任人身份证照片-人像面" onUploaded={(file) => updateProfile('responsibleFrontFile', file)} />
|
||||
<SignatureUploadBox compact file={form.profile.responsibleBackFile} label="责任人身份证照片-国徽面" onUploaded={(file) => updateProfile('responsibleBackFile', file)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -180,6 +366,16 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
field1File: null,
|
||||
field2: '',
|
||||
field3: '',
|
||||
field4: '',
|
||||
field5: '',
|
||||
field6: '',
|
||||
field7File: null,
|
||||
field8: '',
|
||||
field9: '',
|
||||
field10: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
@@ -196,20 +392,43 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.siteName || !form.url} onClick={() => onSubmit(form)}>保存</Button>
|
||||
<Button disabled={!form.url} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流链接' : '添加引流链接'}
|
||||
title={item ? '编辑引流信息' : '添加引流信息'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<section>
|
||||
<h3>引流信息</h3>
|
||||
<h3>基本信息</h3>
|
||||
<Input
|
||||
label="* 引流信息"
|
||||
onChange={(event) => update('url', event.target.value)}
|
||||
placeholder="请输入引流网址"
|
||||
required
|
||||
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">
|
||||
<Input label="站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站点名称" required value={form.siteName} />
|
||||
<Input label="网站链接" onChange={(event) => update('url', event.target.value)} placeholder="https://example.com" required value={form.url} />
|
||||
<SignatureUploadBox compact file={form.field1File} label="* 字段名称1" onUploaded={(file) => update('field1File', file)} />
|
||||
<Input label="* 字段名称2" onChange={(event) => update('field2', event.target.value)} placeholder="请输入字段2内容" value={form.field2 ?? ''} />
|
||||
<Input label="* 字段名称3" onChange={(event) => { update('field3', event.target.value); update('siteName', event.target.value); }} placeholder="请输入公司名称" value={form.field3 ?? form.siteName} />
|
||||
<Input label="字段名称4" onChange={(event) => update('field4', event.target.value)} placeholder="请输入统一社会信用代码" value={form.field4 ?? ''} />
|
||||
<Input label="* 字段名称5" onChange={(event) => update('field5', event.target.value)} placeholder="请输入法人姓名" value={form.field5 ?? ''} />
|
||||
<Input label="字段名称6" onChange={(event) => update('field6', event.target.value)} placeholder="请输入法人身份证号" value={form.field6 ?? ''} />
|
||||
<SignatureUploadBox compact file={form.field7File} label="字段名称7" onUploaded={(file) => update('field7File', file)} />
|
||||
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
|
||||
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
|
||||
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
|
||||
<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} />
|
||||
@@ -326,7 +545,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
mobile: state.mobile,
|
||||
unicom: state.unicom,
|
||||
telecom: state.telecom,
|
||||
}, existingPayload.links);
|
||||
}, existingPayload.links, state.profile);
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||
@@ -362,7 +581,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
? payload.links.map((current) => current.id === item.id ? item : current)
|
||||
: [item, ...payload.links];
|
||||
await adminApi.updateEnterpriseSignature(signatureId, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links),
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile),
|
||||
});
|
||||
setDrainageModal(null);
|
||||
setExpandedSignatureId(signatureId);
|
||||
@@ -380,7 +599,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (signature) {
|
||||
const payload = readDrainagePayload(signature);
|
||||
await adminApi.updateEnterpriseSignature(signature.id, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id)),
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
@@ -53,6 +53,11 @@ export function AdminPhoneSegmentsPage() {
|
||||
[keyword, segments],
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
|
||||
[keyword, rules],
|
||||
);
|
||||
|
||||
function createSegment() {
|
||||
adminApi.createPhoneSegment({ prefix, carrier, province, city })
|
||||
.then(() => {
|
||||
@@ -101,12 +106,44 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="segmented-control">
|
||||
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">手机号段</button>
|
||||
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">运营商区分规则</button>
|
||||
</div>
|
||||
<div className="phone-segment-overview">
|
||||
<section>
|
||||
<span><Smartphone size={20} /></span>
|
||||
<div>
|
||||
<strong>{segments.length}</strong>
|
||||
<p>手机号段记录</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<span><RadioTower size={20} /></span>
|
||||
<div>
|
||||
<strong>{rules.length}</strong>
|
||||
<p>运营商区分规则</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="surface phone-segment-tabs">
|
||||
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">
|
||||
<Smartphone size={18} />
|
||||
<span>
|
||||
<strong>手机号段</strong>
|
||||
<small>按号码前 7 位维护省份与城市</small>
|
||||
</span>
|
||||
<em>{segments.length}</em>
|
||||
</button>
|
||||
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">
|
||||
<RadioTower size={18} />
|
||||
<span>
|
||||
<strong>运营商区分规则</strong>
|
||||
<small>按前缀正则识别移动、联通、电信</small>
|
||||
</span>
|
||||
<em>{rules.length}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar phone-segment-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
|
||||
{activeTab === 'segments' ? '新增号段' : '新增规则'}
|
||||
</Button>
|
||||
@@ -115,7 +152,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
<div className="surface admin-system-table-card">
|
||||
{activeTab === 'segments'
|
||||
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
|
||||
: <Table columns={ruleColumns} data={rules} emptyText="暂无运营商区分规则" rowKey="id" />}
|
||||
: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" />}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, Eye, FileUp, Search } from 'lucide-react';
|
||||
import { adminApi, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||
pending: { label: '待处理', tone: 'neutral' },
|
||||
@@ -13,12 +13,59 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
failed: { label: '有失败', tone: 'danger' },
|
||||
};
|
||||
|
||||
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (file: File, remark: string) => void }) {
|
||||
type ReceiptImportPayload = {
|
||||
delimiter: ',' | '\t';
|
||||
fileContent: string;
|
||||
fileName: string;
|
||||
fileObjectId: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileObject, setFileObject] = useState<FileObject | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [remark, setRemark] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const fileRef: FileRef | null = fileObject
|
||||
? { contentType: fileObject.contentType, fileName: fileObject.fileName, fileObjectId: fileObject.id }
|
||||
: null;
|
||||
|
||||
async function uploadReceiptFile(nextFile: File | undefined) {
|
||||
setFile(nextFile ?? null);
|
||||
setFileObject(null);
|
||||
setError('');
|
||||
if (!nextFile) {
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await adminApi.uploadFileObject(nextFile, { purpose: 'report_receipt', prefix: `report-receipts/${task.id}` });
|
||||
setFileObject(uploaded);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '报备回执上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!file || !fileObject) {
|
||||
return;
|
||||
}
|
||||
const delimiter: ',' | '\t' = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
|
||||
onSubmit({
|
||||
delimiter,
|
||||
fileContent: await file.text(),
|
||||
fileName: file.name,
|
||||
fileObjectId: fileObject.id,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!file} onClick={() => file && onSubmit(file, remark)}>确认导入</Button></>}
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!fileObject || uploading} onClick={() => { void submitImport(); }}>{uploading ? '上传中...' : '确认导入'}</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
@@ -29,13 +76,15 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
|
||||
<FileUp size={38} />
|
||||
<strong>{file?.name || '选择回执文件'}</strong>
|
||||
<span>支持 CSV、TSV、TXT 文本回执,需包含状态/结果列。</span>
|
||||
<FileActions file={fileRef} />
|
||||
<input
|
||||
accept=".csv,.tsv,.txt,text/csv,text/plain"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
onChange={(event) => { void uploadReceiptFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -92,20 +141,15 @@ export function AdminReportTasksPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
|
||||
}
|
||||
|
||||
function importReceipt(file: File, remark: string) {
|
||||
function importReceipt(payload: ReceiptImportPayload) {
|
||||
if (!receiptTask) return;
|
||||
const delimiter = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
|
||||
Promise.all([
|
||||
adminApi.uploadFileObject(file, { purpose: 'report_receipt', prefix: `report-receipts/${receiptTask.id}` }),
|
||||
file.text(),
|
||||
])
|
||||
.then(([fileObject, fileContent]) => adminApi.importReportReceipt(receiptTask.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
fileName: file.name,
|
||||
fileContent,
|
||||
delimiter,
|
||||
reason: remark,
|
||||
}))
|
||||
adminApi.importReportReceipt(receiptTask.id, {
|
||||
delimiter: payload.delimiter,
|
||||
fileContent: payload.fileContent,
|
||||
fileName: payload.fileName,
|
||||
fileObjectId: payload.fileObjectId,
|
||||
reason: payload.remark,
|
||||
})
|
||||
.then(() => {
|
||||
setReceiptTask(null);
|
||||
loadData();
|
||||
@@ -156,7 +200,7 @@ export function AdminReportTasksPage() {
|
||||
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} /> : null}
|
||||
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null}
|
||||
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user