339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
|
*
|
|
* 与前台共用同一套设计语言:
|
|
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
|
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
|
*/
|
|
|
|
import { ReactNode } from 'react'
|
|
|
|
// ── 卡片 ────────────────────────────────────────────────────────
|
|
|
|
export function Card({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
|
)
|
|
}
|
|
|
|
export function CardHeader({
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
action?: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
|
{action}
|
|
</div>
|
|
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CardBody({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
|
}
|
|
|
|
// ── 统计卡片 ────────────────────────────────────────────────────
|
|
|
|
export function StatCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
hint?: string
|
|
}) {
|
|
return (
|
|
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
|
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
|
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
|
{value}
|
|
</div>
|
|
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 状态标记 ────────────────────────────────────────────────────
|
|
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
|
|
|
|
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
|
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
|
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
|
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
|
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
|
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
|
warning: { text: 'text-press', mark: 'border border-press' },
|
|
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
}
|
|
|
|
export function Badge({
|
|
status,
|
|
children,
|
|
}: {
|
|
status: string
|
|
children: ReactNode
|
|
}) {
|
|
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
|
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ── 数据表格 ────────────────────────────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function DataTable<T = any>({
|
|
columns,
|
|
data,
|
|
rowKey,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
|
data: T[]
|
|
rowKey: (row: T) => string | number
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
|
{columns.map(col => (
|
|
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
|
{col.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.map(row => (
|
|
<tr
|
|
key={rowKey(row)}
|
|
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
|
>
|
|
{columns.map(col => (
|
|
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
|
{col.render
|
|
? col.render(row)
|
|
: row != null && typeof row === 'object' && col.key in row
|
|
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
|
: '—'}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
|
|
|
export function ProgressBar({ value }: { value: number }) {
|
|
const clamped = Math.max(0, Math.min(100, value))
|
|
return (
|
|
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
|
|
<div
|
|
className="h-px bg-press transition-[width] duration-500"
|
|
style={{ width: `${clamped}%` }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
|
|
|
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
|
return (
|
|
<div className="border-y border-ink-200 py-12 text-center">
|
|
<p className="font-serif text-sm text-ink-600">{text}</p>
|
|
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function MobileCardList<T = any>({
|
|
data,
|
|
renderCard,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
data: T[]
|
|
renderCard: (row: T, index: number) => ReactNode
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3 lg:hidden">
|
|
{data.map((row, idx) => (
|
|
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
|
{renderCard(row, idx)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 响应式表格容器 ──────────────────────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function ResponsiveTable<T = any>({
|
|
columns,
|
|
data,
|
|
rowKey,
|
|
cardRender,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
|
data: T[]
|
|
rowKey: (row: T) => string | number
|
|
cardRender: (row: T, index: number) => ReactNode
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* 桌面端表格 */}
|
|
<div className="hidden overflow-x-auto lg:block">
|
|
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
|
</div>
|
|
{/* 移动端卡片 */}
|
|
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── 小节标题:同前台 section-head ───────────────────────────────
|
|
|
|
export function SectionHeader({
|
|
title,
|
|
description,
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
}) {
|
|
return (
|
|
<div className="mb-5">
|
|
<h2 className="section-head text-base">{title}</h2>
|
|
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
|
|
|
export function Alert({
|
|
kind,
|
|
title,
|
|
message,
|
|
onClose,
|
|
}: {
|
|
kind: 'error' | 'ok' | 'info'
|
|
title: string
|
|
message?: string
|
|
onClose?: () => void
|
|
}) {
|
|
const style =
|
|
kind === 'error'
|
|
? 'border-press bg-press-wash'
|
|
: kind === 'ok'
|
|
? 'border-ink-900 bg-paper-100'
|
|
: 'border-ink-300 bg-paper-50'
|
|
const titleCls = kind === 'error' ? 'text-press' : 'text-ink-900'
|
|
|
|
return (
|
|
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
|
|
<div>
|
|
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
|
<span
|
|
className={`inline-block h-1.5 w-1.5 ${kind === 'error' ? 'bg-press' : 'bg-ink-900'}`}
|
|
aria-hidden="true"
|
|
/>
|
|
{title}
|
|
</p>
|
|
{message && (
|
|
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
|
{message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="text-ink-400 transition-colors hover:text-ink-900"
|
|
aria-label="关闭"
|
|
>
|
|
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
|
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 加载指示:同前台 Spinner ────────────────────────────────────
|
|
|
|
export function Spinner({ className = '' }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 20 20"
|
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// ── 骨架占位 ────────────────────────────────────────────────────
|
|
|
|
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
|
return <div className={`skeleton ${className}`} />
|
|
}
|