feat: optimize UI queries and seed number library
This commit is contained in:
+16
-2
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
|
||||
import { Icon, PageLoadingDialog } from './components/layout.jsx';
|
||||
import { api, explainApiError } from './api.js';
|
||||
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
|
||||
import {
|
||||
formatCurrency,
|
||||
normalizeAuditLog,
|
||||
@@ -298,7 +299,8 @@ export default function App() {
|
||||
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
|
||||
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
|
||||
} else if (page === 'operationLogs') {
|
||||
add('audit.view', () => api.auditLogs(signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
|
||||
const range = lastSevenDays();
|
||||
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
|
||||
} else if (page === 'cdr') {
|
||||
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
@@ -318,6 +320,18 @@ export default function App() {
|
||||
setApiLoading(false);
|
||||
};
|
||||
const refreshApi = () => loadPageData(active, authUser, { force: true });
|
||||
const refreshAuditLogs = async (params = {}) => {
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
try {
|
||||
const value = await api.auditLogs(params);
|
||||
setLogRows((value.items || []).map(normalizeAuditLog));
|
||||
} catch (error) {
|
||||
setApiError(explainApiError(error));
|
||||
} finally {
|
||||
setApiLoading(false);
|
||||
}
|
||||
};
|
||||
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
|
||||
setActiveCallsLoading(true);
|
||||
if (!silent) setActiveCallsBlockingLoading(true);
|
||||
@@ -528,7 +542,7 @@ export default function App() {
|
||||
: active === 'roles'
|
||||
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
|
||||
: active === 'operationLogs'
|
||||
? { logRows, apiLoading, apiError, refreshApi }
|
||||
? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
|
||||
: {};
|
||||
|
||||
if (!authUser) {
|
||||
|
||||
+6
-6
@@ -153,7 +153,7 @@ export const api = {
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
|
||||
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...params })}`),
|
||||
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
||||
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
|
||||
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
|
||||
@@ -202,20 +202,20 @@ export const api = {
|
||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
roles: (options) => request('/roles', options),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: (options) => request('/audit-logs?take=100', options),
|
||||
auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options),
|
||||
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
|
||||
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
||||
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button } from './ui.jsx';
|
||||
import { Alert, Badge, Button, Field, Input } from './ui.jsx';
|
||||
|
||||
const selectedBlue = '#2563EB';
|
||||
|
||||
@@ -42,6 +42,36 @@ export function Toolbar({ children }) {
|
||||
return <div className="toolbar">{children}</div>;
|
||||
}
|
||||
|
||||
export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) {
|
||||
return (
|
||||
<>
|
||||
<Field label="开始日期"><Input type="date" value={startDate} max={endDate || undefined} onChange={(event) => onStartChange(event.target.value)} /></Field>
|
||||
<Field label="结束日期"><Input type="date" value={endDate} min={startDate || undefined} onChange={(event) => onEndChange(event.target.value)} /></Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) {
|
||||
const start = total ? skip + 1 : 0;
|
||||
const end = Math.min(total, skip + count);
|
||||
return (
|
||||
<div className="pagination-bar" aria-label="分页">
|
||||
<span>显示 {start}-{end} 条,共 {total} 条</span>
|
||||
<div className="pagination-actions">
|
||||
<label>每页
|
||||
<select value={take} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" disabled={loading || skip === 0} onClick={() => onPageChange(Math.max(0, skip - take))}>上一页</Button>
|
||||
<Button size="sm" variant="outline" disabled={loading || skip + count >= total} onClick={() => onPageChange(skip + take)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ title, aside, children, className = '' }) {
|
||||
return (
|
||||
<section className={`prototype-panel ${className}`}>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
||||
const [detailCdr, setDetailCdr] = useState(null);
|
||||
@@ -24,8 +27,8 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
vendorGatewayId: 'all',
|
||||
cityCode: '',
|
||||
carrier: 'all',
|
||||
startedFrom: '',
|
||||
startedTo: '',
|
||||
startedFrom: defaultRange.startDate,
|
||||
startedTo: defaultRange.endDate,
|
||||
take: '25',
|
||||
skip: 0,
|
||||
});
|
||||
@@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
vendorGatewayId: nextFilters.vendorGatewayId,
|
||||
cityCode: nextFilters.cityCode.trim(),
|
||||
carrier: nextFilters.carrier,
|
||||
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '',
|
||||
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
|
||||
...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo),
|
||||
take: nextFilters.take,
|
||||
skip: String(nextFilters.skip),
|
||||
});
|
||||
@@ -133,7 +135,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
|
||||
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: defaultRange.startDate, startedTo: defaultRange.endDate, take: filters.take, skip: 0 };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
@@ -230,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field>
|
||||
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
|
||||
<DateRangeFields startDate={filters.startedFrom} endDate={filters.startedTo} onStartChange={(value) => updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} />
|
||||
<Field label="每页">
|
||||
<Select value={filters.take} onChange={(event) => {
|
||||
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
import { formatCurrency } from '../utils/formatters.js';
|
||||
|
||||
const pendingMetrics = [
|
||||
@@ -32,7 +32,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
||||
const trendBuckets = dashboardTrends?.buckets || [];
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
|
||||
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -57,18 +56,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
||||
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
|
||||
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
|
||||
</Panel>
|
||||
<Panel title="客户消费 TOP 10">
|
||||
<div className="rank-list">
|
||||
<EmptyState title="消费排名待接入">当前 Dashboard 接口暂未提供客户消费排名,避免额外预加载客户列表。</EmptyState>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="失败响应码分布">
|
||||
<div className="code-grid">
|
||||
{failureCodes.length ? failureCodes.map((code) => (
|
||||
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
|
||||
)) : <EmptyState title="暂无失败码">今日没有失败 CDR 或 API 尚未返回数据。</EmptyState>}
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable, Pagination } from '../components/layout.jsx';
|
||||
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
@@ -53,10 +53,10 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const [rows, setRows] = useState(emptyNumberLibraryRows);
|
||||
const [totals, setTotals] = useState(emptyNumberLibraryTotals);
|
||||
const [filters, setFilters] = useState({
|
||||
cities: { keyword: '' },
|
||||
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' },
|
||||
areaCodes: { areaCode: '', cityCode: '' },
|
||||
carrierPrefixRules: { prefix: '', carrier: 'all' },
|
||||
cities: { keyword: '', take: 25, skip: 0 },
|
||||
phoneSegments: { segment7: '', cityCode: '', carrier: 'all', take: 25, skip: 0 },
|
||||
areaCodes: { areaCode: '', cityCode: '', take: 25, skip: 0 },
|
||||
carrierPrefixRules: { prefix: '', carrier: 'all', take: 25, skip: 0 },
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -66,8 +66,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const canManage = can('number_library.manage');
|
||||
|
||||
const readTab = async (tab) => {
|
||||
const params = filters[tab] || {};
|
||||
const readTab = async (tab, params = filters[tab] || {}) => {
|
||||
if (tab === 'cities') {
|
||||
const payload = await api.numberLibraryCities(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
@@ -120,11 +119,11 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}));
|
||||
};
|
||||
|
||||
const loadTab = async (tab = activeTab) => {
|
||||
const loadTab = async (tab = activeTab, params = filters[tab]) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await readTab(tab);
|
||||
const result = await readTab(tab, params);
|
||||
setRows((current) => ({ ...current, [tab]: result.rows }));
|
||||
setTotals((current) => ({ ...current, [tab]: result.total }));
|
||||
} catch (loadError) {
|
||||
@@ -141,10 +140,22 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const updateFilter = (key, value) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[activeTab]: { ...current[activeTab], [key]: value },
|
||||
[activeTab]: { ...current[activeTab], [key]: value, skip: 0 },
|
||||
}));
|
||||
};
|
||||
|
||||
const searchActiveTab = () => {
|
||||
const next = { ...filters[activeTab], skip: 0 };
|
||||
setFilters((current) => ({ ...current, [activeTab]: next }));
|
||||
void loadTab(activeTab, next);
|
||||
};
|
||||
|
||||
const changePage = (skip, take = filters[activeTab].take) => {
|
||||
const next = { ...filters[activeTab], skip, take };
|
||||
setFilters((current) => ({ ...current, [activeTab]: next }));
|
||||
void loadTab(activeTab, next);
|
||||
};
|
||||
|
||||
const openImport = (tab) => {
|
||||
setImportTarget(tab);
|
||||
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
|
||||
@@ -187,7 +198,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
return (
|
||||
<>
|
||||
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<>
|
||||
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
|
||||
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -327,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
</Toolbar>
|
||||
) : null}
|
||||
{renderTable(tab.value)}
|
||||
{tab.value === activeTab ? (
|
||||
<Pagination
|
||||
total={totals[tab.value]}
|
||||
take={filters[tab.value].take}
|
||||
skip={filters[tab.value].skip}
|
||||
count={rows[tab.value].length}
|
||||
loading={loading}
|
||||
onPageChange={(skip) => changePage(skip)}
|
||||
onPageSizeChange={(take) => changePage(0, take)}
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
),
|
||||
}))}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { operationLogRows } from '../fixtures/devFixtures.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
function toneForStatus(status) {
|
||||
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
|
||||
@@ -11,8 +14,8 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('all');
|
||||
const [resultFilter, setResultFilter] = useState('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [startDate, setStartDate] = useState(defaultRange.startDate);
|
||||
const [endDate, setEndDate] = useState(defaultRange.endDate);
|
||||
const [detailLog, setDetailLog] = useState(null);
|
||||
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
|
||||
const visibleLogs = useMemo(() => {
|
||||
@@ -23,18 +26,34 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
||||
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
|
||||
});
|
||||
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
|
||||
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); };
|
||||
const queryLogs = (overrides = {}) => {
|
||||
const next = { moduleFilter, resultFilter, startDate, endDate, ...overrides };
|
||||
return refreshApi({
|
||||
module: next.moduleFilter,
|
||||
result: next.resultFilter === '成功' ? 'SUCCESS' : next.resultFilter === '失败' ? 'FAILURE' : 'all',
|
||||
...dateRangeParams(next.startDate, next.endDate, 'createdFrom', 'createdTo'),
|
||||
});
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setModuleFilter('all');
|
||||
setResultFilter('all');
|
||||
setStartDate(defaultRange.startDate);
|
||||
setEndDate(defaultRange.endDate);
|
||||
void queryLogs({ moduleFilter: 'all', resultFilter: 'all', ...defaultRange });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={() => void queryLogs()} />
|
||||
<PageLoadingDialog loading={apiLoading} />
|
||||
<Toolbar>
|
||||
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
|
||||
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
|
||||
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
|
||||
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field>
|
||||
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field>
|
||||
<DateRangeFields startDate={startDate} endDate={endDate} onStartChange={setStartDate} onEndChange={setEndDate} />
|
||||
<Button icon={<Icon type="search" />} disabled={apiLoading} onClick={() => void queryLogs()}>查询</Button>
|
||||
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="日志列表" aside={<Badge tone="neutral">共 {visibleLogs.length} 条</Badge>} className="wide-panel">
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
|
||||
const [ruleRows, setRuleRows] = useState([]);
|
||||
const [recordingRows, setRecordingRows] = useState([]);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const [qualityError, setQualityError] = useState('');
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50' });
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50', ...defaultRange });
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(undefined);
|
||||
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
||||
@@ -51,6 +53,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
const params = {
|
||||
limit: nextFilter.limit,
|
||||
reviewStatus: nextFilter.reviewStatus,
|
||||
...dateRangeParams(nextFilter.startDate, nextFilter.endDate),
|
||||
};
|
||||
const recordingList = await api.recordings(params);
|
||||
setRecordingRows((recordingList || []).map(normalizeRecording));
|
||||
@@ -293,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
const changeRecordingFilter = (key, value) => {
|
||||
const nextFilter = { ...recordingFilter, [key]: value };
|
||||
setRecordingFilter(nextFilter);
|
||||
};
|
||||
const resetRecordingFilter = () => {
|
||||
const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange };
|
||||
setRecordingFilter(nextFilter);
|
||||
void refreshQuality(nextFilter);
|
||||
};
|
||||
|
||||
@@ -313,6 +320,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<option value="REVIEWED">已完成</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<DateRangeFields startDate={recordingFilter.startDate} endDate={recordingFilter.endDate} onStartChange={(value) => changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} />
|
||||
<Field label="读取条数">
|
||||
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
||||
<option value="25">25</option>
|
||||
@@ -320,6 +328,8 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<option value="100">100</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>查询</Button>
|
||||
<Button variant="outline" disabled={qualityLoading} onClick={resetRecordingFilter}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="录音列表" aside={<Badge tone="neutral">共 {recordingRows.length} 条录音</Badge>} className="wide-panel">
|
||||
<SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
|
||||
|
||||
@@ -2196,11 +2196,39 @@ a {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
min-height: 0;
|
||||
padding: 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination-actions,
|
||||
.pagination-actions label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-actions select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
padding: 0 28px 0 10px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
|
||||
export function formatDateInput(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function lastSevenDays(reference = new Date()) {
|
||||
const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
|
||||
}
|
||||
|
||||
export function dateRangeParams(startDate, endDate, fromKey = 'startedFrom', toKey = 'startedTo') {
|
||||
const params = {};
|
||||
if (startDate) params[fromKey] = new Date(`${startDate}T00:00:00`).toISOString();
|
||||
if (endDate) params[toKey] = new Date(`${endDate}T23:59:59.999`).toISOString();
|
||||
return params;
|
||||
}
|
||||
Reference in New Issue
Block a user