fix: track live downstream cmpp connections

This commit is contained in:
hectorzhao
2026-07-11 19:16:52 +08:00
parent 76e0417ccf
commit 2fea15ef25
21 changed files with 477 additions and 141 deletions
+9 -3
View File
@@ -24,7 +24,9 @@ const typeOptions = [
export function AdminDrainageFieldsPage() {
const [fields, setFields] = useState<DrainageField[]>([]);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [type, setType] = useState('all');
const [appliedType, setAppliedType] = useState('all');
const [creating, setCreating] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
@@ -47,11 +49,11 @@ export function AdminDrainageFieldsPage() {
const filteredFields = useMemo(
() => fields.filter((field) => {
const matchesKeyword = !keyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(keyword));
const matchesType = type === 'all' || field.fieldType === type;
const matchesKeyword = !appliedKeyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(appliedKeyword));
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
return matchesKeyword && matchesType;
}),
[fields, keyword, type],
[appliedKeyword, appliedType, fields],
);
function createField() {
@@ -88,6 +90,10 @@ export function AdminDrainageFieldsPage() {
<div className="surface admin-drainage-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}></Button>
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}></Button>
</div>
@@ -2,7 +2,7 @@ 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, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type SmsApp = {
@@ -197,11 +197,9 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
function CmppConnectionModal({
app,
onClose,
onDeleteConnection,
}: {
app: SmsApp;
onClose: () => void;
onDeleteConnection: (connectionId: string) => void;
}) {
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
@@ -216,7 +214,7 @@ function CmppConnectionModal({
<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></span><strong>{app.cmppParams.maxConnections}</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>
@@ -231,15 +229,6 @@ function CmppConnectionModal({
{ 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连接"
@@ -254,6 +243,7 @@ export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
@@ -268,9 +258,9 @@ export function AdminEnterpriseApplicationsPage() {
| null
>(null);
async function loadSmsApps() {
async function loadSmsApps(keyword = appliedEnterpriseKeyword) {
try {
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
const applications = await adminApi.listEnterpriseApplications({ keyword });
setSmsApps(applications.map(mapApplication));
setError('');
} catch (err) {
@@ -281,7 +271,7 @@ export function AdminEnterpriseApplicationsPage() {
useEffect(() => {
void loadSmsApps();
}, [enterpriseKeyword]);
}, [appliedEnterpriseKeyword]);
async function openAddModal() {
setAddModalOpen(true);
@@ -328,22 +318,14 @@ export function AdminEnterpriseApplicationsPage() {
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],
() => smsApps.filter((item) => !appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)),
[appliedEnterpriseKeyword, smsApps],
);
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
@@ -408,7 +390,10 @@ export function AdminEnterpriseApplicationsPage() {
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost"></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedEnterpriseKeyword(enterpriseKeyword.trim())}></Button>
<Button onClick={() => { setEnterpriseKeyword(''); setAppliedEnterpriseKeyword(''); }} variant="ghost"></Button>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
@@ -446,7 +431,6 @@ export function AdminEnterpriseApplicationsPage() {
<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}
@@ -467,22 +451,22 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
deliveryRate: application.deliveryRate ?? 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100,
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP2.0' },
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: application.cmppMaxConnections ?? 1, heartbeatSeconds: 30, windowSize: application.cmppWindowSize ?? 16, protocolVersion: 'CMPP2.0' },
cmppConnections: connections,
};
}
function mapConnection(connection: CmppConnectionState): CmppConnection {
const isOpen = connection.status === 'connected' && connection.currentConnections > 0;
function mapConnection(connection: CmppDownstreamConnection): CmppConnection {
const isOpen = connection.status === 'connected';
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: formatDateTime(connection.lastConnectedAt),
clientIp: String(connection.remoteIp ?? ''),
sourceAddr: connection.enterpriseCode,
establishedAt: formatDateTime(connection.connectedAt),
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
lastSubmitAt: formatDateTime(connection.updatedAt),
pendingWindow: connection.currentConnections,
lastSubmitAt: formatDateTime(connection.lastSubmitAt),
pendingWindow: 0,
};
}
@@ -491,19 +491,21 @@ export function AdminEnterpriseSignaturesPage() {
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData() {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, signatureKeyword: appliedSignatureKeyword }) {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures({ keyword: [enterpriseKeyword, signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listEnterpriseSignatures({ keyword: [filters.enterpriseKeyword, filters.signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
]);
@@ -523,9 +525,9 @@ export function AdminEnterpriseSignaturesPage() {
const filteredSignatures = useMemo(() => signatures.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
}), [enterpriseKeyword, signatureKeyword, signatures]);
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || application.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword));
}), [appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -533,7 +535,7 @@ export function AdminEnterpriseSignaturesPage() {
useEffect(() => {
setPage(1);
}, [enterpriseKeyword, filteredSignatures.length, signatureKeyword]);
}, [appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -695,8 +697,22 @@ export function AdminEnterpriseSignaturesPage() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="签名/应用" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), signatureKeyword: signatureKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', signatureKeyword: '' };
setEnterpriseKeyword('');
setSignatureKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedSignatureKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -250,18 +250,20 @@ export function AdminEnterpriseTemplatesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [error, setError] = useState('');
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [templateKeyword, setTemplateKeyword] = useState('');
const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
async function loadData() {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, templateKeyword: appliedTemplateKeyword }) {
try {
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplates({ keyword: [enterpriseKeyword, templateKeyword].filter(Boolean).join(' ') }),
adminApi.listEnterpriseTemplates({ keyword: [filters.enterpriseKeyword, filters.templateKeyword].filter(Boolean).join(' ') }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
adminApi.listEnterpriseSignatures(),
@@ -283,9 +285,9 @@ export function AdminEnterpriseTemplatesPage() {
const filteredTemplates = useMemo(() => templates.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!templateKeyword || item.name.includes(templateKeyword) || item.content.includes(templateKeyword) || application.includes(templateKeyword));
}), [enterpriseKeyword, templateKeyword, templates]);
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedTemplateKeyword || item.name.includes(appliedTemplateKeyword) || item.content.includes(appliedTemplateKeyword) || application.includes(appliedTemplateKeyword));
}), [appliedEnterpriseKeyword, appliedTemplateKeyword, templates]);
async function saveTemplate(state: TemplateFormState) {
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
@@ -363,8 +365,22 @@ export function AdminEnterpriseTemplatesPage() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); void loadData(); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}></Button>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), templateKeyword: templateKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedTemplateKeyword(filters.templateKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', templateKeyword: '' };
setEnterpriseKeyword('');
setTemplateKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedTemplateKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
</div>
<div className="surface section-stack">
<Tabs
+40 -20
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Database, ListFilter, Plus, RotateCcw, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type PhoneSegment = DictionaryItem & {
prefix?: string;
@@ -42,20 +43,12 @@ export function AdminPhoneSegmentsPage() {
const [rulePage, setRulePage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => {
setSegmentQuery(keyword.trim());
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }),
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
adminApi.listPhoneCarrierRules({ keyword: segmentQuery || undefined, page: rulePage, pageSize }),
])
.then(([segmentPage, ruleResponse]) => {
if (cancelled) return;
@@ -74,7 +67,7 @@ export function AdminPhoneSegmentsPage() {
return () => {
cancelled = true;
};
}, [activeTab, page, reloadKey, rulePage, segmentQuery]);
}, [page, reloadKey, rulePage, segmentQuery]);
const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize));
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
@@ -103,12 +96,25 @@ export function AdminPhoneSegmentsPage() {
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
}
function query() {
setSegmentQuery(keyword.trim());
setPage(1);
setRulePage(1);
}
function reset() {
setKeyword('');
setSegmentQuery('');
setPage(1);
setRulePage(1);
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
{ key: 'province', title: '省份', width: '140px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '140px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
@@ -119,28 +125,42 @@ export function AdminPhoneSegmentsPage() {
], []);
return (
<section className="page-stack admin-system-page">
<section className="page-stack admin-system-page phone-segment-workbench">
<div className="page-heading">
<div>
<Breadcrumb items={['系统管理', '手机号段库']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<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>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="phone-segment-overview" aria-label="号段数据概览">
<section>
<span><Database size={20} /></span>
<div><strong>{segmentTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
<section>
<span><ListFilter size={20} /></span>
<div><strong>{ruleTotal.toLocaleString('zh-CN')}</strong><p></p></div>
</section>
</div>
<div className="surface phone-segment-query">
<Input label="关键词" onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '手机号段、运营商、省份或城市' : '运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-system-table-card">
<Tabs
className="phone-segment-tabs"
className="phone-segment-workbench__tabs"
onChange={(value) => {
setActiveTab(value as 'segments' | 'rules');
setRulePage(1);
}}
value={activeTab}
items={[
+24 -5
View File
@@ -25,6 +25,7 @@ export function AdminSystemLogsPage() {
const [level, setLevel] = useState('all');
const [module, setModule] = useState('all');
const [range, setRange] = useState('today');
const [filters, setFilters] = useState({ keyword: '', level: 'all', module: 'all', range: 'today' });
const [page, setPage] = useState(1);
const pageSize = 5;
const [logs, setLogs] = useState<OperationLogItem[]>([]);
@@ -33,7 +34,7 @@ export function AdminSystemLogsPage() {
const [error, setError] = useState('');
useEffect(() => {
adminApi.listSystemLogs({ keyword, level, module, range, page, pageSize })
adminApi.listSystemLogs({ ...filters, page, pageSize })
.then((data) => {
setLogs(data.items);
setModules(data.modules);
@@ -45,7 +46,7 @@ export function AdminSystemLogsPage() {
setTotal(0);
setError(err instanceof Error ? err.message : '系统日志加载失败');
});
}, [keyword, level, module, range, page]);
}, [filters, page]);
const moduleOptions = useMemo(() => {
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
@@ -54,6 +55,20 @@ export function AdminSystemLogsPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
function query() {
setPage(1);
setFilters({ keyword: keyword.trim(), level, module, range });
}
function reset() {
setKeyword('');
setLevel('all');
setModule('all');
setRange('today');
setPage(1);
setFilters({ keyword: '', level: 'all', module: 'all', range: 'today' });
}
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
@@ -89,13 +104,13 @@ export function AdminSystemLogsPage() {
<div className="system-log-filters">
<Input
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、操作人、动作、资源ID或详情"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
onChange={(event) => { setLevel(event.target.value); setPage(1); }}
onChange={(event) => setLevel(event.target.value)}
options={[
{ label: '全部级别', value: 'all' },
{ label: '信息', value: 'info' },
@@ -105,7 +120,11 @@ export function AdminSystemLogsPage() {
]}
value={level}
/>
<Select onChange={(event) => { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} />
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
<div className="system-log-filters__actions">
<Button icon={<Search size={16} />} onClick={query}></Button>
<Button onClick={reset} variant="ghost"></Button>
</div>
</div>
<div className="system-log-range">
+7 -2
View File
@@ -63,6 +63,7 @@ export function AdminUsersPage() {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
@@ -84,12 +85,12 @@ export function AdminUsersPage() {
}, []);
const filteredUsers = useMemo(() => {
const value = keyword.trim().toLowerCase();
const value = appliedKeyword.trim().toLowerCase();
return users.filter((user) => {
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
return !value || target.includes(value);
});
}, [keyword, users]);
}, [appliedKeyword, users]);
function openCreate() {
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
@@ -199,6 +200,10 @@ export function AdminUsersPage() {
<div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
<div className="admin-system-toolbar__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}></Button>
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={16} />} onClick={openCreate}></Button>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}