Files
lislgosms/src/apps/admin/AdminEnterpriseApplicationsPage.tsx
T

478 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
type SmsApp = {
id: string;
tenantId: string;
name: string;
enterprise: string;
appId: string;
enabled: boolean;
sentToday: number;
deliveryRate: number;
unitPrice: number;
cmppStatus: 'connected' | 'disconnected' | 'inactive';
cmppConnections: CmppConnection[];
cmppParams: CmppParams;
};
type CmppParams = {
host: string;
port: number;
enterpriseCode: string;
account: string;
password: string;
accessNumber: string;
maxConnections: number;
heartbeatSeconds: number;
windowSize: number;
protocolVersion: string;
};
type CmppConnection = {
id: string;
state: 'open' | 'closed' | 'reconnecting';
bindType: 'transceiver' | 'submitter' | 'receiver';
clientIp: string;
sourceAddr: string;
establishedAt: string;
lastHeartbeatAt: string;
lastSubmitAt: string;
pendingWindow: number;
};
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>
);
}
function AddApplicationModal({
tenants,
loading,
selectedTenantId,
onChange,
onCancel,
onConfirm,
}: {
tenants: TenantOption[];
loading: boolean;
selectedTenantId: string;
onChange: (tenantId: string) => void;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost">取消</Button>
<Button disabled={!selectedTenantId || loading} onClick={onConfirm}>下一步</Button>
</>
)}
onClose={onCancel}
open
title={<div className="template-modal-title"><h2>新建企业应用</h2><p>先选择真实企业,再配置短信应用和三网通道组。</p></div>}
>
<div className="form-grid app-create-modal">
<label className="field">
<span>所属企业</span>
<select disabled={loading} onChange={(event) => onChange(event.target.value)} value={selectedTenantId}>
<option value="">{loading ? '企业加载中...' : '请选择真实企业'}</option>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>{tenant.name}{tenant.code}</option>
))}
</select>
</label>
<div className="app-create-modal__hint">
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
</div>
</div>
</Modal>
);
}
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
open: { label: '已连接', tone: 'success' },
closed: { label: '已断开', tone: 'neutral' },
reconnecting: { label: '重连中', tone: 'warning' },
};
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
const cmppParams = params ?? app.cmppParams;
return [
`应用名称: ${app.name}`,
`企业名称: ${app.enterprise}`,
`AppID: ${app.appId}`,
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
`企业代码: ${cmppParams.enterpriseCode}`,
`接口账号: ${cmppParams.account}`,
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
`最大连接数: ${cmppParams.maxConnections}`,
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
`提交窗口: ${cmppParams.windowSize}`,
`协议版本: ${cmppParams.protocolVersion}`,
].join('\n');
}
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const paramsText = formatCmppParams(app, params);
const host = params?.gatewayHost ?? app.cmppParams.host;
const port = params?.gatewayPort ?? app.cmppParams.port;
const password = params?.passwordCipher ?? app.cmppParams.password;
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
async function copyParams() {
await navigator.clipboard.writeText(paramsText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost">关闭</Button>
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
>
<div className="cmpp-param-detail">
<div className="cmpp-param-grid">
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
<div><span>接口账号</span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
<div><span>接口密码</span><strong>{password}</strong></div>
<div><span>接入号</span><strong>{srcId}</strong></div>
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} </strong></div>
<div><span>提交窗口</span><strong>{params?.windowSize ?? app.cmppParams.windowSize}</strong></div>
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
</div>
<pre className="cmpp-param-copy">{paramsText}</pre>
</div>
</Modal>
);
}
function CmppConnectionModal({
app,
onClose,
onDeleteConnection,
}: {
app: SmsApp;
onClose: () => void;
onDeleteConnection: (connectionId: string) => void;
}) {
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
return (
<Modal
footer={<Button onClick={onClose}>关闭</Button>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
>
<div className="cmpp-connection-detail">
<div className="cmpp-connection-summary">
<div><span>当前连接数</span><strong>{activeConnections}</strong></div>
<div><span>配置连接数</span><strong>{Math.max(activeConnections, app.cmppConnections.length)}</strong></div>
<div><span>AppID</span><strong>{app.appId}</strong></div>
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
</div>
<Table
columns={[
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
{
key: 'actions',
title: '操作',
align: 'right',
width: '120px',
render: (record: CmppConnection) => (
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger">删除</Button>
),
},
]}
data={app.cmppConnections}
emptyText="暂无CMPP连接"
rowKey="id"
/>
</div>
</Modal>
);
}
export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
const [error, setError] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [confirmAction, setConfirmAction] = useState<
| { action: 'toggle'; id: string; name: string; enabled: boolean }
| { action: 'delete'; id: string; name: string }
| null
>(null);
async function loadSmsApps() {
try {
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
setSmsApps(applications.map(mapApplication));
setError('');
} catch (err) {
setSmsApps([]);
setError(err instanceof Error ? err.message : '企业应用加载失败');
}
}
useEffect(() => {
void loadSmsApps();
}, [enterpriseKeyword]);
async function openAddModal() {
setAddModalOpen(true);
if (tenants.length === 0) {
setTenantsLoading(true);
try {
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
} catch (err) {
setError(err instanceof Error ? err.message : '企业列表加载失败');
} finally {
setTenantsLoading(false);
}
}
}
function confirmAddApplication() {
if (selectedTenantId) {
navigate(`/admin/customers/${selectedTenantId}/sms-apps/new`);
}
}
async function confirmToggle(id: string) {
const app = smsApps.find((item) => item.id === id);
if (app) {
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
await loadSmsApps();
}
}
async function confirmDelete(id: string) {
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
await loadSmsApps();
}
async function runConfirmedAction() {
if (!confirmAction) {
return;
}
if (confirmAction.action === 'toggle') {
await confirmToggle(confirmAction.id);
} else {
await confirmDelete(confirmAction.id);
}
setConfirmAction(null);
}
async function deleteConnection(appId: string, connectionId: string) {
await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接');
const data = await adminApi.listApplicationConnections(appId);
const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] });
setConnectionApp(nextApp);
await loadSmsApps();
}
async function openParams(app: SmsApp) {
setParamsApp(app);
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
}
const filteredSmsApps = useMemo(
() => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
[enterpriseKeyword, smsApps],
);
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: '130px', render: (record) => `${record.deliveryRate}%` },
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
{
key: 'cmppStatus',
title: 'CMPP状态',
width: '230px',
render: (record) => (
<div className="cmpp-status-cell">
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
</Tag>
<button onClick={() => setConnectionApp(record)} type="button">
{record.cmppConnections.filter((item) => item.state === 'open').length}
</button>
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
<Settings2 size={13} />
参数
</button>
</div>
),
},
{ key: 'enabled', title: '状态', width: '130px', 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/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', 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', 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>
<Breadcrumb items={['客户管理', '企业应用管理']} />
<h1>企业应用管理</h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
</div>
<div className="surface admin-split-filter">
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost">重置</Button>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
<div className="surface section-stack">
<Tabs
items={[
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div> },
]}
/>
</div>
{confirmAction ? (
<ConfirmModal
danger={confirmAction.action === 'delete'}
message={confirmAction.action === 'delete'
? `确认删除应用“${confirmAction.name}”吗?`
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
onCancel={() => setConfirmAction(null)}
onConfirm={() => { void runConfirmedAction(); }}
/>
) : null}
{addModalOpen ? (
<AddApplicationModal
loading={tenantsLoading}
onCancel={() => setAddModalOpen(false)}
onChange={setSelectedTenantId}
onConfirm={confirmAddApplication}
selectedTenantId={selectedTenantId}
tenants={tenants}
/>
) : null}
{connectionApp ? (
<CmppConnectionModal
app={connectionApp}
onClose={() => setConnectionApp(null)}
onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }}
/>
) : null}
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
</section>
);
}
function mapApplication(application: EnterpriseApplication): SmsApp {
const connections = (application.cmppConnections ?? []).map(mapConnection);
return {
id: application.id,
tenantId: application.tenantId,
name: application.name,
enterprise: application.tenant?.name ?? application.tenantId,
appId: application.id,
enabled: application.status === 'active',
sentToday: application.sentToday ?? 0,
deliveryRate: application.deliveryRate ?? 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100,
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
cmppConnections: connections,
};
}
function mapConnection(connection: CmppConnectionState): CmppConnection {
const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0;
return {
id: connection.connectionId,
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
bindType: 'transceiver',
clientIp: String(connection.channel?.gatewayHost ?? ''),
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '',
lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '',
lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '',
pendingWindow: connection.currentConnections,
};
}