feat: optimize UI queries and seed number library
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
function client() {
|
||||
const config = {
|
||||
get: () => ({
|
||||
mode: 'http',
|
||||
httpUrl: 'http://127.0.0.1:8888/mi',
|
||||
sshHost: 'unused',
|
||||
remoteCommand: 'unused',
|
||||
timeoutMs: 3000
|
||||
})
|
||||
} as unknown as ConfigService<RuntimeConfig, true>;
|
||||
return new OpenSipsMiClient(config);
|
||||
}
|
||||
|
||||
describe('OpenSipsMiClient HTTP MI', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('lists dialogs through the local JSON-RPC endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
|
||||
});
|
||||
|
||||
it('preserves OpenSIPS MI errors for the API layer', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
|
||||
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
|
||||
});
|
||||
});
|
||||
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
|
||||
|
||||
private async call(method: string, params: string[] = []): Promise<unknown> {
|
||||
const config = this.configService.get('activeCalls', { infer: true });
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args = [];
|
||||
if (config.sshConfig) {
|
||||
args.push('-F', config.sshConfig);
|
||||
}
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
|
||||
let output: string;
|
||||
let response: MiResponse;
|
||||
try {
|
||||
output = await this.executor.run(args, config.timeoutMs);
|
||||
response = config.mode === 'http'
|
||||
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
|
||||
: await this.callSsh(config, method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof BadGatewayException) throw error;
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
|
||||
message: 'OpenSIPS control plane is unavailable.',
|
||||
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
|
||||
});
|
||||
}
|
||||
|
||||
let response: MiResponse;
|
||||
try {
|
||||
response = JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
|
||||
}
|
||||
try {
|
||||
return await response.json() as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args: string[] = [];
|
||||
if (config.sshConfig) args.push('-F', config.sshConfig);
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
const output = await this.executor.run(args, config.timeoutMs);
|
||||
try {
|
||||
return JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
result?: 'SUCCESS' | 'FAILURE';
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
|
||||
const where = {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
module: query.module,
|
||||
action: query.action,
|
||||
userId: query.userId,
|
||||
objectType: query.objectType,
|
||||
objectId: query.objectId,
|
||||
result: query.result
|
||||
result: query.result,
|
||||
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
|
||||
};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
|
||||
@@ -13,6 +13,8 @@ export class AuditLogsService {
|
||||
objectType: this.optionalString(rawQuery.objectType),
|
||||
objectId: this.optionalString(rawQuery.objectId),
|
||||
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
|
||||
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
|
||||
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
|
||||
take: this.positiveInt(rawQuery.take, 50, 100),
|
||||
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
|
||||
};
|
||||
@@ -56,4 +58,16 @@ export class AuditLogsService {
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown, field: string): Date | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
|
||||
|
||||
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
|
||||
|
||||
export interface RecordingListQuery {
|
||||
status?: string;
|
||||
reviewStatus?: 'PENDING' | 'REVIEWED';
|
||||
limit?: number;
|
||||
startedFrom?: Date;
|
||||
startedTo?: Date;
|
||||
}
|
||||
|
||||
export interface RecordingsRepository {
|
||||
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
|
||||
list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
|
||||
getDetail(id: string): Promise<RecordingDetail>;
|
||||
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
|
||||
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
|
||||
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
|
||||
export class PrismaRecordingsRepository implements RecordingsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
|
||||
async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
|
||||
const take = Math.min(query.limit ?? 100, 500);
|
||||
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
|
||||
const recordings = await this.prisma.recording.findMany({
|
||||
where: {
|
||||
status: query.status ? (query.status as never) : 'READY',
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
|
||||
OR: startedAt ? [
|
||||
{ rawCdr: { is: { startedAt } } },
|
||||
{ rawCdrId: null, createdAt: startedAt }
|
||||
] : undefined
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take,
|
||||
|
||||
@@ -26,11 +26,13 @@ export class RecordingsService {
|
||||
@Inject(QualityService) private readonly qualityService: QualityService
|
||||
) {}
|
||||
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
|
||||
const recordings = await this.recordings.list({
|
||||
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
|
||||
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
|
||||
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
|
||||
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
|
||||
});
|
||||
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
|
||||
}
|
||||
@@ -123,6 +125,17 @@ export class RecordingsService {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private date(value: unknown, field: string): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function internalRecordingPath(storageKey: string): string {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface RuntimeConfig {
|
||||
url: string;
|
||||
};
|
||||
activeCalls: {
|
||||
mode: 'http' | 'ssh';
|
||||
httpUrl: string;
|
||||
sshHost: string;
|
||||
sshConfig?: string;
|
||||
remoteCommand: string;
|
||||
@@ -47,6 +49,8 @@ export const validationSchema = Joi.object({
|
||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||
ACTIVE_CALLS_MODE: Joi.string().valid('http', 'ssh').default('http'),
|
||||
ACTIVE_CALLS_HTTP_URL: Joi.string().uri({ scheme: ['http', 'https'] }).default('http://127.0.0.1:8888/mi'),
|
||||
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
|
||||
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
|
||||
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
|
||||
@@ -89,6 +93,8 @@ export function appConfig(): RuntimeConfig {
|
||||
url: process.env.REDIS_URL ?? ''
|
||||
},
|
||||
activeCalls: {
|
||||
mode: process.env.ACTIVE_CALLS_MODE === 'ssh' ? 'ssh' : 'http',
|
||||
httpUrl: process.env.ACTIVE_CALLS_HTTP_URL ?? 'http://127.0.0.1:8888/mi',
|
||||
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
|
||||
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
|
||||
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
|
||||
|
||||
+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