Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LisgloSIPS - 聆界SIP管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@lisglosips/web",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"vite": "5.4.21"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
const API_BASE = (import.meta.env.VITE_LISGLOSIPS_API_BASE || '/api/v2').replace(/\/$/, '');
|
||||
const ACCESS_TOKEN_KEY = 'lisglosips.accessToken';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status, code } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('Accept', 'application/json');
|
||||
if (options.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(errorMessage(payload, response.status), {
|
||||
status: response.status,
|
||||
code: typeof payload === 'object' && payload ? payload.code : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function errorMessage(payload, status) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
return payload.message || payload.error || `API request failed with HTTP ${status}.`;
|
||||
}
|
||||
return payload || `API request failed with HTTP ${status}.`;
|
||||
}
|
||||
|
||||
function jsonBody(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function idempotencyKey(scope) {
|
||||
const random = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${scope}:${random}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
dashboardSummary: () => request('/dashboard/summary'),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
||||
customers: () => request('/customers'),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
rechargeCustomer: (id, body) =>
|
||||
request(`/customers/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
|
||||
}),
|
||||
vendors: () => request('/vendors'),
|
||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
rechargeVendor: (id, body) =>
|
||||
request(`/vendors/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
recharges: () => request('/recharges?take=100'),
|
||||
users: () => request('/users'),
|
||||
roles: () => request('/roles'),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
};
|
||||
|
||||
export function explainApiError(error) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return 'API 已启用鉴权,请先通过登录接口获取会话或在同源环境使用有效 Cookie。';
|
||||
}
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconOnly = false,
|
||||
className = '',
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`ui-button ui-button-${variant} ui-button-${size} ${iconOnly ? 'ui-button-icon-only' : ''} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <span className="ui-button-icon" aria-hidden="true">{icon}</span> : null}
|
||||
{iconOnly ? <span className="sr-only">{children}</span> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, hint, error, children }) {
|
||||
return (
|
||||
<label className="ui-field">
|
||||
<span className="ui-field-label">{label}</span>
|
||||
{children}
|
||||
{error ? <span className="ui-field-error">{error}</span> : hint ? <span className="ui-field-hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props) {
|
||||
return <input className="ui-input" {...props} />;
|
||||
}
|
||||
|
||||
export function Textarea(props) {
|
||||
return <textarea className="ui-input ui-textarea" {...props} />;
|
||||
}
|
||||
|
||||
export function Select({ children, ...props }) {
|
||||
return (
|
||||
<select className="ui-input ui-select" {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({ label, checked, ...props }) {
|
||||
return (
|
||||
<label className="ui-check">
|
||||
<input type="checkbox" checked={checked} {...props} />
|
||||
<span className="ui-check-box" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Radio({ label, checked, ...props }) {
|
||||
return (
|
||||
<label className="ui-check">
|
||||
<input type="radio" checked={checked} {...props} />
|
||||
<span className="ui-radio-dot" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({ label, checked, ...props }) {
|
||||
return (
|
||||
<label className="ui-switch">
|
||||
<input type="checkbox" checked={checked} {...props} />
|
||||
<span className="ui-switch-track" aria-hidden="true">
|
||||
<span className="ui-switch-thumb" />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControl({ value, options, onChange }) {
|
||||
return (
|
||||
<div className="ui-segmented" role="tablist" aria-label="分段选择">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={option.value === value ? 'is-active' : ''}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tabs({ value, tabs, onChange }) {
|
||||
return (
|
||||
<div className="ui-tabs">
|
||||
<div className="ui-tab-list" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.value === value}
|
||||
className={tab.value === value ? 'is-active' : ''}
|
||||
onClick={() => onChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ui-tab-panel">{tabs.find((tab) => tab.value === value)?.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({ children, tone = 'neutral' }) {
|
||||
return <span className={`ui-badge ui-badge-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export function Alert({ title, children, tone = 'info' }) {
|
||||
return (
|
||||
<div className={`ui-alert ui-alert-${tone}`} role="status">
|
||||
<strong>{title}</strong>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Progress({ value }) {
|
||||
return (
|
||||
<div className="ui-progress" aria-label={`进度 ${value}%`}>
|
||||
<span style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Slider({ label, value, ...props }) {
|
||||
return (
|
||||
<label className="ui-slider">
|
||||
<span>{label}</span>
|
||||
<input type="range" value={value} {...props} />
|
||||
<output>{value}</output>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({ rows }) {
|
||||
return (
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>线路</th>
|
||||
<th>状态</th>
|
||||
<th>接通率</th>
|
||||
<th>并发</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td><Badge tone={row.status === '正常' ? 'success' : 'warning'}>{row.status}</Badge></td>
|
||||
<td>{row.answerRate}</td>
|
||||
<td>{row.concurrent}</td>
|
||||
<td><Button variant="ghost" size="sm">查看</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
proxy: {
|
||||
'/api/v2': 'http://127.0.0.1:3000'
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user