271 lines
9.7 KiB
React
271 lines
9.7 KiB
React
import { useEffect, useState } from 'react';
|
||
import { Alert, Badge, Button, Field, Input } from './ui.jsx';
|
||
|
||
const selectedBlue = '#2563EB';
|
||
|
||
export function toneForStatus(status) {
|
||
if (['启用', '在线', '正常', '生效', '已计费', '完成', '已完成', '开启', '可试听', '成功'].includes(status)) return 'success';
|
||
if (['观察', '抖动', '待质检', '处理中', '失败不计费'].includes(status)) return 'warning';
|
||
if (['停用', '禁用', '暂停', '告警', '关闭', '失败'].includes(status)) return 'danger';
|
||
return 'neutral';
|
||
}
|
||
|
||
export function Icon({ type }) {
|
||
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||
const paths = {
|
||
plus: <path d="M12 5v14M5 12h14" />,
|
||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-3.4-3.4" /></>,
|
||
export: <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>,
|
||
reload: <><path d="M21 12a9 9 0 0 1-15 6.7" /><path d="M3 12a9 9 0 0 1 15-6.7" /><path d="M18 3v5h-5" /><path d="M6 21v-5h5" /></>,
|
||
arrow: <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
|
||
phoneOff: <><path d="M10.7 13.3a10.8 10.8 0 0 0 3.9 2.9" /><path d="M17 13.5l3 3v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 2.5 6.2 2 2 0 0 1 4.5 4h3l3 3-2 2a13 13 0 0 0 1.2 2.3" /><path d="M22 2 2 22" /></>,
|
||
collapse: <><path d="M15 18l-6-6 6-6" /><path d="M20 4v16" /></>,
|
||
expand: <><path d="M9 18l6-6-6-6" /><path d="M4 4v16" /></>,
|
||
logout: <><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /><path d="M21 19V5a2 2 0 0 0-2-2h-5" /></>,
|
||
};
|
||
return <svg {...common}>{paths[type]}</svg>;
|
||
}
|
||
|
||
export function PageTitle({ title, desc, actions }) {
|
||
return (
|
||
<div className="page-title">
|
||
<div>
|
||
<h1>{title}</h1>
|
||
<p>{desc}</p>
|
||
</div>
|
||
<div className="page-actions">{actions}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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}`}>
|
||
<div className="panel-head">
|
||
<h2>{title}</h2>
|
||
{aside}
|
||
</div>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function ApiNotice({ loading, error, onRetry }) {
|
||
if (loading) {
|
||
return null;
|
||
}
|
||
if (error) {
|
||
return (
|
||
<Alert title="API 数据不可用" tone="warning">
|
||
{error}
|
||
{onRetry ? <Button size="sm" variant="outline" onClick={onRetry}>重试</Button> : null}
|
||
</Alert>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function PageLoadingDialog({ loading, message = '页面加载中,请稍候…', delay = 2000 }) {
|
||
const [visible, setVisible] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!loading) {
|
||
setVisible(false);
|
||
return undefined;
|
||
}
|
||
const timer = window.setTimeout(() => setVisible(true), delay);
|
||
return () => window.clearTimeout(timer);
|
||
}, [delay, loading]);
|
||
|
||
if (!visible) return null;
|
||
return (
|
||
<div className="page-loading-backdrop" role="status" aria-live="polite" aria-label={message}>
|
||
<div className="page-loading-dialog">
|
||
<span className="page-loading-spinner" aria-hidden="true" />
|
||
<div>
|
||
<strong>{message}</strong>
|
||
<span>正在读取真实业务数据,请不要重复刷新。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
|
||
return (
|
||
<div className="empty-state">
|
||
<strong>{title}</strong>
|
||
<span>{children}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Modal({ title, aside, children, onClose, size = 'lg' }) {
|
||
return (
|
||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||
<section className={`modal-dialog modal-${size}`} role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h2>{title}</h2>
|
||
{aside}
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
|
||
</div>
|
||
<div className="modal-body">{children}</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ConfirmDialog({
|
||
title,
|
||
children,
|
||
confirmLabel = '确认',
|
||
confirmVariant = 'primary',
|
||
busy = false,
|
||
onCancel,
|
||
onConfirm,
|
||
}) {
|
||
return (
|
||
<Modal title={title} onClose={busy ? () => {} : onCancel} size="sm">
|
||
<div className="confirm-copy">
|
||
{typeof children === 'string' ? <p>{children}</p> : children}
|
||
</div>
|
||
<div className="modal-actions">
|
||
<Button type="button" variant="outline" disabled={busy} onClick={onCancel}>取消</Button>
|
||
<Button type="button" variant={confirmVariant} disabled={busy} onClick={onConfirm}>
|
||
{busy ? '处理中' : confirmLabel}
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function Drawer({ title, aside, children, onClose }) {
|
||
return (
|
||
<div className="drawer-backdrop" role="presentation" onMouseDown={onClose}>
|
||
<aside className="drawer-panel" role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
|
||
<div className="drawer-head">
|
||
<div>
|
||
<h2>{title}</h2>
|
||
{aside}
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
|
||
</div>
|
||
<div className="drawer-body">{children}</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StatusBadge({ children }) {
|
||
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
|
||
}
|
||
|
||
export function SimpleTable({ columns, rows, onRowClick, selectedKey, loading = false, emptyTitle = '暂无数据', emptyDescription }) {
|
||
return (
|
||
<div className="table-shell">
|
||
<table className="proto-table" aria-busy={loading}>
|
||
{columns.some((column) => column.width) ? (
|
||
<colgroup>
|
||
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
|
||
</colgroup>
|
||
) : null}
|
||
<thead>
|
||
<tr>
|
||
{columns.map((column) => <th key={column.key} className={column.className}>{column.label}</th>)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading && rows.length === 0 ? Array.from({ length: 4 }, (_, rowIndex) => (
|
||
<tr className="table-skeleton-row" key={`skeleton-${rowIndex}`} aria-hidden="true">
|
||
{columns.map((column) => <td key={column.key}><span className="table-skeleton-cell" /></td>)}
|
||
</tr>
|
||
)) : rows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={columns.length}>
|
||
<EmptyState title={emptyTitle}>{emptyDescription}</EmptyState>
|
||
</td>
|
||
</tr>
|
||
) : rows.map((row, index) => (
|
||
<tr
|
||
key={row.id || row.callId || row.name || index}
|
||
className={selectedKey && selectedKey === (row.id || row.callId) ? 'is-selected' : ''}
|
||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||
>
|
||
{columns.map((column) => (
|
||
<td key={column.key} className={column.className}>
|
||
{column.status ? <StatusBadge>{row[column.key]}</StatusBadge> : column.render ? column.render(row) : row[column.key]}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MiniBarChart({ data, color = selectedBlue }) {
|
||
const max = Math.max(1, ...data);
|
||
return (
|
||
<div className="mini-chart" aria-label="趋势图">
|
||
{data.map((value, index) => (
|
||
<span key={index} style={{ height: `${Math.max(14, (value / max) * 100)}%`, background: color }} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LineChart({ data }) {
|
||
const points = data.map((value, index) => `${(index / Math.max(1, data.length - 1)) * 100},${100 - value}`).join(' ');
|
||
return (
|
||
<svg className="line-chart" viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="接通率趋势">
|
||
<polyline points={points} fill="none" stroke={selectedBlue} strokeWidth="3" vectorEffect="non-scaling-stroke" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function KeyValue({ label, value }) {
|
||
return (
|
||
<div className="kv">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|