Files
lisglosips/apps/web/src/components/layout.jsx
T

271 lines
9.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}