fix web ui smoke and brand assets

This commit is contained in:
hectorzhao
2026-06-30 11:13:30 +08:00
parent 0dfb2988b2
commit 9920575bba
103 changed files with 6650 additions and 135 deletions
+4 -1
View File
@@ -11,7 +11,10 @@ async function bootstrap(): Promise<void> {
AppModule,
new FastifyAdapter({
trustProxy: true,
logger: false
logger: false,
routerOptions: {
maxParamLength: 256
}
}),
{
bufferLogs: true
@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
import type { CurrentUser } from '../security/security.metadata.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
class MemoryIdentityRepository implements IdentityRepository {
users = new Map<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
async write(_input: AuditEntryInput): Promise<void> {}
}
describe('active calls API', () => {
let app: NestFastifyApplication;
const tokenFor = (userId: string) =>
signAccessToken(
{
sub: userId,
username: userId,
roles: ['test'],
typ: 'access'
},
{
secret: 'test-only-access-token-secret-min-32-bytes',
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 900
}
);
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
const identities = new MemoryIdentityRepository();
identities.users.set('usr_active', {
id: 'usr_active',
username: 'active',
roles: ['话务'],
permissions: ['active_calls.view', 'active_calls.manage'] as PermissionKey[]
});
const { AppModule } = await import('../app.module.js');
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(IDENTITY_REPOSITORY)
.useValue(identities)
.overrideProvider(AUDIT_REPOSITORY)
.useValue(new MemoryAuditRepository())
.overrideProvider(OpenSipsMiClient)
.useValue({ endDialog: vi.fn(), listDialogs: vi.fn() })
.compile();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false, routerOptions: { maxParamLength: 256 } }));
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app?.close();
});
it('returns ACTIVE_CALL_ID_INVALID for long invalid dialog ids before MI calls', async () => {
await request(app.getHttpServer())
.post(`/api/v2/active-calls/${'x'.repeat(129)}/hangup`)
.set('Authorization', `Bearer ${tokenFor('usr_active')}`)
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('ACTIVE_CALL_ID_INVALID');
});
});
});
@@ -120,5 +120,7 @@ describe('active calls service', () => {
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
await expect(service.hangup('x'.repeat(129))).rejects.toBeInstanceOf(BadRequestException);
await expect(service.hangup('x'.repeat(20))).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -22,7 +22,7 @@ export interface ActiveCallSummary {
raw: Record<string, unknown>;
}
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
const SAFE_DIALOG_ID = /^(?=.{1,128}$)(?=.*[@.:])[A-Za-z0-9@._:%+\-=]+$/;
@Injectable()
export class ActiveCallsService {
@@ -198,6 +198,32 @@ describe('S12 recharges API', () => {
expect(audit.entries.some((entry) => entry.module === 'recharges' && entry.action === 'customer_recharge' && entry.result === 'SUCCESS')).toBe(true);
});
it('creates negative customer recharge as a balance deduction', async () => {
await request(app.getHttpServer())
.post('/api/v2/customers/cus_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-3.25', idempotencyKey: 'customer-deduct-001', remark: 'manual deduction' })
.expect(201)
.expect((response) => {
expect(response.body).toMatchObject({
accountType: 'CUSTOMER',
accountId: 'cus_seed',
amount: '-3.250000',
beforeBalance: '25.250000',
afterBalance: '22.000000'
});
});
await request(app.getHttpServer())
.post('/api/v2/customers/cus_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-0.000000', idempotencyKey: 'customer-deduct-zero' })
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('MONEY_INVALID');
});
});
it('creates vendor recharge, lists ledgers, and rejects idempotency conflicts', async () => {
await request(app.getHttpServer())
.post('/api/v2/vendors/ven_seed/recharges')
@@ -219,9 +245,18 @@ describe('S12 recharges API', () => {
.send({ amount: '4.5', idempotencyKey: 'vendor-rch-001' })
.expect(409);
await request(app.getHttpServer())
.post('/api/v2/vendors/ven_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-1', idempotencyKey: 'vendor-negative-001' })
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('MONEY_INVALID');
});
const list = await request(app.getHttpServer()).get('/api/v2/recharges?take=10').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200);
expect(list.body.total).toBe(2);
expect(list.body.total).toBe(3);
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'CUSTOMER')).toBe(true);
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'VENDOR')).toBe(true);
});
@@ -35,7 +35,7 @@ export class RechargesService {
}
rechargeCustomer(customerId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
const amount = this.money(body.amount, 'amount');
const amount = this.signedMoney(body.amount, 'amount');
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
const remark = this.optionalString(body.remark, 'remark', 500);
@@ -50,7 +50,7 @@ export class RechargesService {
}
rechargeVendor(vendorId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
const amount = this.money(body.amount, 'amount');
const amount = this.positiveMoney(body.amount, 'amount');
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
const remark = this.optionalString(body.remark, 'remark', 500);
@@ -105,7 +105,7 @@ export class RechargesService {
return trimmed;
}
private money(value: unknown, field: string): string {
private positiveMoney(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a positive decimal with up to 6 places.` });
@@ -120,6 +120,23 @@ export class RechargesService {
return normalized;
}
private signedMoney(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^-?(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-zero signed decimal with up to 6 places.` });
}
const negative = raw.startsWith('-');
const unsigned = negative ? raw.slice(1) : raw;
const [integerPart, fractionPart = ''] = unsigned.split('.');
const normalized = `${negative ? '-' : ''}${integerPart}.${fractionPart.padEnd(6, '0')}`;
if (normalized === '0.000000' || normalized === '-0.000000') {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must not be zero.` });
}
return normalized;
}
private pageNumber(value: unknown, defaultValue: number, max: number): number {
if (value === undefined) {
return defaultValue;
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>LisgloSIPS - 聆界SIP管理平台</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+13 -1
View File
@@ -495,7 +495,19 @@ export default function App() {
<div className={`prototype-app ${collapsed ? 'sidebar-collapsed' : ''}`}>
<aside className="sidebar">
<div className="brand-block">
<div className="brand-mark"></div>
<div className="brand-expanded">
<img
className="brand-logo brand-logo-expanded"
src="/brand/logo1.png"
alt="聆界SIP管理平台"
/>
<span className="brand-sip-text">SIP</span>
</div>
<img
className="brand-logo brand-logo-collapsed"
src="/brand/logo2.png"
alt="聆界SIP管理平台"
/>
<div className="brand-copy">
<strong>聆界SIP管理平台</strong>
<span>LisgloSIPS</span>
+16 -12
View File
@@ -30,7 +30,7 @@ export function Field({ label, hint, error, children }) {
);
}
export function Input(props) {
export function Input({ children: _children, ...props }) {
return <input className="ui-input" {...props} />;
}
@@ -46,34 +46,37 @@ export function Select({ children, ...props }) {
);
}
export function Checkbox({ label, checked, ...props }) {
export function Checkbox({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-check">
<input type="checkbox" checked={checked} {...props} />
<input type="checkbox" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-check-box" aria-hidden="true" />
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
export function Radio({ label, checked, ...props }) {
export function Radio({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-check">
<input type="radio" checked={checked} {...props} />
<input type="radio" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-radio-dot" aria-hidden="true" />
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
export function Switch({ label, checked, ...props }) {
export function Switch({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-switch">
<input type="checkbox" checked={checked} {...props} />
<input type="checkbox" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-switch-track" aria-hidden="true">
<span className="ui-switch-thumb" />
</span>
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
@@ -138,10 +141,11 @@ export function Progress({ value }) {
);
}
export function Slider({ label, value, ...props }) {
export function Slider({ label, value, children, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-slider">
<span>{label}</span>
<span>{labelContent}</span>
<input type="range" value={value} {...props} />
<output>{value}</output>
</label>
-1
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
import { metrics } from '../fixtures/devFixtures.js';
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
const [busyId, setBusyId] = useState('');
+14 -45
View File
@@ -1,11 +1,24 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { enStatus } from '../utils/formatters.js';
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
function normalizeBusinessPrefix(item) {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description || '-',
priority: item.priority ?? 100,
status: zhStatus(item.status),
gatewayCount: item.gatewayCount ?? 0,
createdAt: formatDate(item.createdAt),
};
}
export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
@@ -227,47 +240,3 @@ export function BusinessPrefixesPage({ can = () => true }) {
</>
);
}
const numberLibraryTabs = [
{ value: 'cities', label: '地级市字典' },
{ value: 'phoneSegments', label: '手机号码库' },
{ value: 'areaCodes', label: '城市区号' },
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
];
const numberLibraryImportExamples = {
cities: [
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
],
phoneSegments: [
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
],
areaCodes: [
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
],
carrierPrefixRules: [
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
],
};
const emptyNumberLibraryRows = {
cities: [],
phoneSegments: [],
areaCodes: [],
carrierPrefixRules: [],
};
const emptyNumberLibraryTotals = {
cities: 0,
phoneSegments: 0,
areaCodes: 0,
carrierPrefixRules: 0,
};
function normalizeNumberLibraryList(payload, mapItem) {
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: items.map(mapItem),
total: payload?.total ?? items.length,
};
}
+42 -25
View File
@@ -31,6 +31,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
const strategyPolicies = strategyGateway
? policyRows.filter((item) => item.gateway === strategyGateway).sort((first, second) => first.priority - second.priority)
: [];
const selectedBusinessPrefixCount = gatewayForm.businessPrefixIds.length;
useEffect(() => {
api.businessPrefixes({ status: 'ENABLED' })
.then((items) => setBusinessPrefixOptions(Array.isArray(items) ? items : []))
@@ -170,12 +171,22 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id));
setSubmitting(false);
};
const openPolicyDrawer = (gatewayId) => {
setStrategyGateway(gatewayId);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
const toggleBusinessPrefix = (prefixId, checked) => {
setGatewayForm((current) => {
const nextIds = checked
? Array.from(new Set([...current.businessPrefixIds, prefixId]))
: current.businessPrefixIds.filter((id) => id !== prefixId);
return { ...current, businessPrefixIds: nextIds };
});
};
const selectAllBusinessPrefixes = () => {
setGatewayForm((current) => ({
...current,
businessPrefixIds: businessPrefixOptions.map((prefix) => prefix.id),
}));
};
const clearBusinessPrefixes = () => {
setGatewayForm((current) => ({ ...current, businessPrefixIds: [] }));
};
const closePolicyDrawer = () => {
setStrategyGateway(null);
@@ -287,10 +298,8 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
<Panel title="客户网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '148px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP地址 / 账号名称', render: (row) => (row.authMode === 'IP' || row.authMode === '混合认证' ? (row.sourceIps || []).join(', ') || row.ipAddress : row.sipAccount) },
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
{ key: 'lineGroupName', label: '落地线路组' },
{ key: 'rate', label: '客户费率', render: (row) => `${row.billingCycleSec || 60}s / ¥${Number(row.cycleRate || 0).toFixed(6)}` },
{ key: 'callerRule', label: '主叫规则', render: (row) => row.callerMatchMode === 'PREFIXES' ? (row.callerPrefixes || []).join(', ') : '任意号码' },
@@ -384,21 +393,29 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
</Select>
</Field>
{gatewayForm.calleeMatchMode === 'BUSINESS_PREFIXES' ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => setGatewayForm((current) => ({
...current,
businessPrefixIds: checked
? [...current.businessPrefixIds, prefix.id]
: current.businessPrefixIds.filter((id) => id !== prefix.id),
}))}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
<div className="prefix-picker">
<div className="prefix-picker-head">
<span>已选择 {selectedBusinessPrefixCount} / {businessPrefixOptions.length}</span>
<div className="prefix-picker-actions">
<Button type="button" size="sm" variant="outline" disabled={!businessPrefixOptions.length || selectedBusinessPrefixCount === businessPrefixOptions.length} onClick={selectAllBusinessPrefixes}>全选</Button>
<Button type="button" size="sm" variant="ghost" disabled={!selectedBusinessPrefixCount} onClick={clearBusinessPrefixes}>清空</Button>
</div>
</div>
{businessPrefixOptions.length ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => toggleBusinessPrefix(prefix.id, checked)}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
</div>
) : (
<div className="empty-inline">暂无可用业务前缀</div>
)}
</div>
) : null}
<div className="modal-actions">
-1
View File
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onToggleCustomerStatus, onDeleteCustomer, onRechargeCustomer }) {
+44
View File
@@ -4,6 +4,50 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
const numberLibraryTabs = [
{ value: 'cities', label: '地级市字典' },
{ value: 'phoneSegments', label: '手机号码库' },
{ value: 'areaCodes', label: '城市区号' },
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
];
const numberLibraryImportExamples = {
cities: [
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
],
phoneSegments: [
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
],
areaCodes: [
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
],
carrierPrefixRules: [
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
],
};
const emptyNumberLibraryRows = {
cities: [],
phoneSegments: [],
areaCodes: [],
carrierPrefixRules: [],
};
const emptyNumberLibraryTotals = {
cities: 0,
phoneSegments: 0,
areaCodes: 0,
carrierPrefixRules: 0,
};
function normalizeNumberLibraryList(payload, mapItem) {
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: items.map(mapItem),
total: payload?.total ?? items.length,
};
}
export function NumberLibraryPage({ can = () => true }) {
const [activeTab, setActiveTab] = useState('cities');
const [rows, setRows] = useState(emptyNumberLibraryRows);
+4 -1
View File
@@ -3,6 +3,10 @@ import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js';
function toneForStatus(status) {
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
}
export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiError, refreshApi }) {
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all');
@@ -64,4 +68,3 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
</>
);
}
+1 -2
View File
@@ -1,6 +1,6 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
import { customers, gateways, customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
import { customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
export function RoutesPage() {
return (
@@ -57,4 +57,3 @@ export function RoutesPage() {
</>
);
}
-3
View File
@@ -34,6 +34,3 @@ export function SettingsPage() {
);
}
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
const emptyRoleForm = { name: '', description: '', status: '启用' };
+1 -2
View File
@@ -1,4 +1,4 @@
import { Badge, Button, Progress } from '../components/ui.jsx';
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, StatusBadge, SimpleTable } from '../components/layout.jsx';
import { sipAccounts, opsItems } from '../fixtures/devFixtures.js';
@@ -22,4 +22,3 @@ export function SipOpsPage() {
</>
);
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { formatDate, zhStatus } from '../utils/formatters.js';
import { gateways, vendorLineGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
@@ -183,18 +182,3 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
</>
);
}
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
function normalizeBusinessPrefix(item) {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description || '-',
priority: item.priority ?? 100,
status: zhStatus(item.status),
gatewayCount: item.gatewayCount ?? 0,
createdAt: formatDate(item.createdAt),
};
}
-1
View File
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Button, Field, Input, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) {
+117 -5
View File
@@ -156,13 +156,48 @@ button:disabled {
.brand-block {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 34px;
grid-template-columns: minmax(0, 1fr) 34px;
align-items: center;
gap: 6px;
gap: 10px;
padding: 16px 12px;
border-bottom: 1px solid var(--line);
}
.brand-logo {
display: block;
object-fit: contain;
min-width: 0;
}
.brand-expanded {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.brand-logo-expanded {
width: min(100%, 152px);
flex: 0 1 auto;
height: 38px;
object-position: left center;
}
.brand-sip-text {
flex: 0 0 auto;
color: var(--brand);
font-size: 19px;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
}
.brand-logo-collapsed {
display: none;
width: 38px;
height: 38px;
}
.brand-mark {
display: grid;
place-items: center;
@@ -175,7 +210,7 @@ button:disabled {
}
.brand-copy {
display: grid;
display: none;
min-width: 0;
gap: 2px;
}
@@ -296,6 +331,18 @@ button:disabled {
padding: 14px 10px;
}
.sidebar-collapsed .brand-logo-expanded {
display: none;
}
.sidebar-collapsed .brand-expanded {
display: none;
}
.sidebar-collapsed .brand-logo-collapsed {
display: block;
}
.sidebar-collapsed .brand-copy,
.sidebar-collapsed .nav-group p,
.sidebar-collapsed .nav-label,
@@ -862,6 +909,60 @@ button:disabled {
gap: 8px;
}
.prefix-picker {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-muted);
}
.prefix-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 13px;
font-weight: 750;
}
.prefix-picker-actions {
display: inline-flex;
gap: 8px;
}
.checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px 14px;
}
.checkbox-grid .ui-check {
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
}
.checkbox-grid .ui-check > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-inline {
padding: 12px;
color: var(--muted);
border: 1px dashed var(--line);
border-radius: 6px;
background: var(--surface);
}
.mini-chart {
display: grid;
grid-template-columns: repeat(12, 1fr);
@@ -1680,12 +1781,23 @@ button:disabled {
}
.sidebar-collapsed .brand-block {
grid-template-columns: 38px minmax(0, 1fr) 34px;
grid-template-columns: minmax(0, 1fr) 34px;
justify-items: stretch;
padding: 12px;
}
.sidebar-collapsed .brand-copy,
.sidebar-collapsed .brand-logo-expanded {
display: block;
}
.sidebar-collapsed .brand-expanded {
display: inline-flex;
}
.sidebar-collapsed .brand-logo-collapsed {
display: none;
}
.sidebar-collapsed .nav-label {
display: grid;
}
-2
View File
@@ -1,6 +1,4 @@
const selectedBlue = '#2563EB';
function formatCurrency(value, digits = 2) {
const numeric = Number(value || 0);
return `¥${numeric.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;