feat(P1): 新增 5 个数据展示组件(灵感来自 beautifului.dev)

- FilterTable  带列筛选+排序的增强表格
- InsightCard  指标洞察卡片(趋势+对比+状态色)
- SearchList   即时搜索列表(过滤+选中态)
- EventList    事件流(时间线+类型图标+展开详情)
- LoaderGrid   网格骨架屏(卡片/表格加载态)

均适配 Profeto 报纸风(衬线/纸色/细线),统一收口 components/ui。
This commit is contained in:
shangfangjian
2026-09-23 01:30:32 +08:00
parent e8e626e3e4
commit f33a1d91ff
7 changed files with 627 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
/**
* EventList: 事件/活动流 —— 时间线样式的事件列表。
*
* 灵感来自 beautifului EventList,适配 Profeto 报纸风:
* - 左侧时间轴细线
* - 事件类型图标圆点(彩色)
* - 时间戳 + 摘要 + 详情
* - 支持展开/折叠详情
*/
import { useState } from 'react'
export interface EventItem {
id: string
type: 'success' | 'error' | 'warning' | 'info' | 'pending'
title: string
timestamp: string
detail?: string
actor?: string
}
const TYPE_DOT: Record<string, string> = {
success: 'bg-emerald-500',
error: 'bg-rose-500',
warning: 'bg-amber-500',
info: 'bg-sky-500',
pending: 'bg-ink-300',
}
const TYPE_LABEL: Record<string, string> = {
success: '成功',
error: '失败',
warning: '警告',
info: '信息',
pending: '进行中',
}
export function EventList({ events, maxInitial = 10 }: { events: EventItem[]; maxInitial?: number }) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [showAll, setShowAll] = useState(false)
const visible = showAll ? events : events.slice(0, maxInitial)
const hasMore = events.length > maxInitial && !showAll
const toggle = (id: string) => setExpanded(s => ({ ...s, [id]: !s[id] }))
if (events.length === 0) {
return <div className="py-8 text-center text-xs text-ink-400"></div>
}
return (
<div className="relative">
{/* 时间轴细线 */}
<div className="absolute bottom-2 left-3 top-2 w-px bg-ink-200" aria-hidden="true" />
<ul className="space-y-0">
{visible.map((ev) => {
const isOpen = expanded[ev.id]
return (
<li key={ev.id} className="relative pl-8 pr-3">
{/* 圆点 */}
<span
className={`absolute left-1.5 top-3 inline-block h-3 w-3 rounded-full border-2 border-paper-50 ${TYPE_DOT[ev.type]}`}
aria-hidden="true"
/>
<button
type="button"
onClick={() => ev.detail && toggle(ev.id)}
className={`w-full border-b border-ink-100 py-2.5 text-left transition-colors hover:bg-paper-100 ${
ev.detail ? 'cursor-pointer' : 'cursor-default'
}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-ink-800">{ev.title}</span>
<span className="flex-shrink-0 text-2xs tabular-nums text-ink-400">{ev.timestamp}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-ink-500">
<span className={`rounded px-1 py-0.5 text-2xs font-medium ${
ev.type === 'success' ? 'bg-emerald-50 text-emerald-700' :
ev.type === 'error' ? 'bg-rose-50 text-rose-700' :
ev.type === 'warning' ? 'bg-amber-50 text-amber-700' :
ev.type === 'pending' ? 'bg-ink-100 text-ink-500' :
'bg-sky-50 text-sky-700'
}`}>
{TYPE_LABEL[ev.type]}
</span>
{ev.actor && <span>· {ev.actor}</span>}
</div>
{ev.detail && isOpen && (
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{ev.detail}
</p>
)}
</button>
</li>
)
})}
</ul>
{hasMore && (
<div className="mt-2 pl-8">
<button
onClick={() => setShowAll(true)}
className="text-xs text-ink-500 underline-offset-2 hover:text-ink-900 hover:underline"
>
{events.length}
</button>
</div>
)}
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
/**
* FilterTable: 带列筛选 + 排序的增强表格。
*
* 灵感来自 beautifului FilterTable,适配 Profeto 报纸风:
* - 列头点击排序(升/降/无)
* - 文本列支持输入筛选
* - 状态列支持下拉筛选
* - 与现有 DataTable 风格统一(细线/留白/衬线)
*/
import { useMemo, useState } from 'react'
import { EmptyState } from './Feedback'
export interface FilterColumn<T> {
key: string
label: string
width?: string
align?: 'left' | 'center' | 'right'
sortable?: boolean
filterable?: boolean
filterType?: 'text' | 'select'
filterOptions?: { value: string; label: string }[]
render?: (row: T) => React.ReactNode
}
type SortDir = 'asc' | 'desc' | null
export function FilterTable<T extends Record<string, unknown>>({
columns,
data,
rowKey,
emptyText = '暂无数据',
initialSort,
}: {
columns: FilterColumn<T>[]
data: T[]
rowKey: (row: T) => string | number
emptyText?: string
initialSort?: { key: string; dir: 'asc' | 'desc' }
}) {
const [sortKey, setSortKey] = useState<string | null>(initialSort?.key ?? null)
const [sortDir, setSortDir] = useState<SortDir>(initialSort?.dir ?? null)
const [filters, setFilters] = useState<Record<string, string>>({})
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc')
if (sortDir === 'desc') setSortKey(null)
} else {
setSortKey(key)
setSortDir('asc')
}
}
const handleFilter = (key: string, value: string) => {
setFilters(f => (value ? { ...f, [key]: value } : { ...f, [key]: '' }))
// 筛选变化时重置排序,避免混淆
setSortKey(null)
setSortDir(null)
}
const filtered = useMemo(() => {
let rows = data
for (const col of columns) {
const fv = filters[col.key]
if (!fv) continue
rows = rows.filter(r => {
const v = r[col.key]
if (v == null) return false
return String(v).toLowerCase().includes(fv.toLowerCase())
})
}
return rows
}, [data, columns, filters])
const sorted = useMemo(() => {
if (!sortKey || !sortDir) return filtered
const sorted = [...filtered].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
if (av == null && bv == null) return 0
if (av == null) return 1
if (bv == null) return -1
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return sorted
}, [filtered, sortKey, sortDir, columns])
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="select-none px-3 py-2 font-medium"
style={{ width: col.width, textAlign: col.align ?? 'left' }}
>
<div className="flex items-center gap-1">
{col.sortable && (
<button
onClick={() => handleSort(col.key)}
className={`flex items-center gap-0.5 hover:text-ink-900 ${
sortKey === col.key ? 'text-ink-900' : ''
}`}
title="排序"
>
{col.label}
<span className="text-[10px]">
{sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕'}
</span>
</button>
)}
{!col.sortable && col.label}
</div>
</th>
))}
</tr>
{/* 筛选行 */}
{columns.some(c => c.filterable) && (
<tr className="border-b border-ink-200 bg-paper-100/50">
{columns.map(col => (
<th key={col.key} className="px-2 py-1.5">
{col.filterable && col.filterType === 'select' && col.filterOptions ? (
<select
value={filters[col.key] ?? ''}
onChange={e => handleFilter(col.key, e.target.value)}
className="w-full border border-ink-200 bg-paper-50 px-1 py-0.5 text-xs text-ink-700"
>
<option value=""></option>
{col.filterOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : col.filterable ? (
<input
type="text"
value={filters[col.key] ?? ''}
onChange={e => handleFilter(col.key, e.target.value)}
placeholder="筛选…"
className="w-full border border-ink-200 bg-paper-50 px-2 py-0.5 text-xs text-ink-700 placeholder:text-ink-300"
/>
) : null}
</th>
))}
</tr>
)}
</thead>
<tbody>
{sorted.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"
style={{ textAlign: col.align ?? 'left' }}
>
{col.render
? col.render(row)
: row[col.key] != null
? String(row[col.key])
: '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && filtered.length > 0 && (
<div className="py-6 text-center text-xs text-ink-400"></div>
)}
</div>
)
}
@@ -0,0 +1,84 @@
/**
* InsightCard: 指标洞察卡片 —— 数值 + 趋势 + 对比。
*
* 灵感来自 beautifului InsightCard / StatCard,适配 Profeto 报纸风:
* - 大字号主指标(衬线)
* - 趋势箭头(↑↓) + 变化率
* - 可选对比基线(如"较上周 +12%")
* - 状态色(info/warning/positive/negative)
*/
import { TrendingArrow } from './TrendingArrow'
export interface InsightCardProps {
label: string
value: string | number
/** 趋势方向: up/down/flat */
trend?: 'up' | 'down' | 'flat'
/** 变化率文案,如 "+12%" */
trendLabel?: string
/** 对比说明,如 "较昨日" */
compareHint?: string
/** 状态色 */
status?: 'info' | 'warning' | 'positive' | 'negative'
/** 副标题 / 补充说明 */
subtitle?: string
/** 图标(ReactNode) */
icon?: React.ReactNode
}
const STATUS_STYLE: Record<string, { card: string; accent: string }> = {
info: { card: 'border-ink-200', accent: 'text-ink-600' },
warning: { card: 'border-amber-300', accent: 'text-amber-700' },
positive: { card: 'border-emerald-300', accent: 'text-emerald-700' },
negative: { card: 'border-rose-300', accent: 'text-rose-700' },
}
const TREND_COLOR: Record<string, string> = {
up: 'text-emerald-600',
down: 'text-rose-600',
flat: 'text-ink-400',
}
export function InsightCard({ label, value, trend, trendLabel, compareHint, status = 'info', subtitle, icon }: InsightCardProps) {
const style = STATUS_STYLE[status]
return (
<article className={`border bg-paper-50 px-4 py-4 shadow-sm transition-shadow hover:shadow-md ${style.card}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium uppercase tracking-wider text-ink-500">{label}</p>
<p className={`mt-1 font-serif text-2xl font-bold leading-none sm:text-3xl ${style.accent}`}>
{value}
</p>
{subtitle && (
<p className="mt-1 truncate text-xs text-ink-400">{subtitle}</p>
)}
</div>
{icon && (
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded ${style.accent} bg-paper-100`}>
{icon}
</div>
)}
</div>
{(trend || trendLabel) && (
<div className="mt-3 flex items-center gap-1.5 text-xs">
{trend && <TrendingArrow dir={trend} className="h-3 w-3" />}
{trendLabel && (
<span className={`font-medium ${TREND_COLOR[trend ?? 'flat']}`}>{trendLabel}</span>
)}
{compareHint && <span className="text-ink-400">{compareHint}</span>}
</div>
)}
</article>
)
}
/** InsightCard 网格容器 */
export function InsightGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{children}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
/**
* LoaderGrid: 网格骨架占位 —— 卡片/面板加载态。
*
* 灵感来自 beautifului LoaderGrid,适配 Profeto 报纸风:
* - 网格布局(响应式)
* - 脉冲动画占位块
* - 可配置列数/行数
*/
function shimmer() {
return 'animate-pulse bg-paper-100'
}
/** 单张骨架卡片 */
function SkeletonCard({ lines = 3 }: { lines?: number }) {
return (
<div className="border border-ink-200 bg-paper-50 p-4">
<div className="flex items-center gap-3">
<div className={`h-8 w-8 rounded ${shimmer()}`} />
<div className="flex-1 space-y-1.5">
<div className={`h-3 w-2/3 rounded ${shimmer()}`} />
<div className={`h-2.5 w-1/3 rounded ${shimmer()}`} />
</div>
</div>
<div className="mt-4 space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div key={i} className={`h-3 rounded ${shimmer()}`} style={{ width: `${70 + Math.random() * 25}%` }} />
))}
</div>
<div className="mt-4 flex items-center justify-between">
<div className={`h-2.5 w-16 rounded ${shimmer()}`} />
<div className={`h-2.5 w-12 rounded ${shimmer()}`} />
</div>
</div>
)
}
/** 单行骨架(表格行) */
function SkeletonRow({ cols = 4 }: { cols?: number }) {
return (
<tr className="border-b border-ink-200">
{Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-3 py-3">
<div className={`h-3 rounded ${shimmer()}`} style={{ width: i === 0 ? '60%' : '80%' }} />
</td>
))}
</tr>
)
}
export interface LoaderGridProps {
/** 模式: card=卡片网格 / row=表格行 */
mode?: 'card' | 'row'
/** 显示数量 */
count?: number
/** 列数(card 模式)或列数(row 模式) */
cols?: number
/** 卡片模式行数 */
cardLines?: number
}
export function LoaderGrid({ mode = 'card', count = 4, cols = 4, cardLines = 3 }: LoaderGridProps) {
if (mode === 'card') {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} lines={cardLines} />
))}
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-900">
{Array.from({ length: cols }).map((_, i) => (
<th key={i} className="px-3 py-2">
<div className={`h-3 w-12 rounded ${shimmer()}`} />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: count }).map((_, i) => (
<SkeletonRow key={i} cols={cols} />
))}
</tbody>
</table>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
/**
* SearchList: 即时搜索列表 —— 输入框 + 过滤结果 + 选中态。
*
* 灵感来自 beautifului SearchList,适配 Profeto 报纸风:
* - 顶部搜索输入(放大镜图标)
* - 即时过滤(前端本地匹配)
* - 选中高亮 + hover 态
* - 空态提示
*/
import { useMemo, useState } from 'react'
export interface SearchListProps<T> {
items: T[]
/** 抽取用于搜索的文本 */
searchText: (item: T) => string
/** 抽取唯一 key */
itemKey: (item: T) => string
/** 渲染每行 */
renderItem: (item: T, active: boolean) => React.ReactNode
/** 点击回调 */
onSelect?: (item: T) => void
/** 当前选中 key */
activeKey?: string
placeholder?: string
emptyText?: string
maxHeight?: string
}
export function SearchList<T>({
items,
searchText,
itemKey,
renderItem,
onSelect,
activeKey,
placeholder = '搜索…',
emptyText = '无匹配项',
maxHeight = '360px',
}: SearchListProps<T>) {
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
if (!query.trim()) return items
const q = query.trim().toLowerCase()
return items.filter(it => searchText(it).toLowerCase().includes(q))
}, [items, query, searchText])
return (
<div className="flex flex-col">
{/* 搜索框 */}
<div className="sticky top-0 border-b border-ink-200 bg-paper-50 px-3 py-2">
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
>
<circle cx="8.5" cy="8.5" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M12.5 12.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full border border-ink-200 bg-paper-50 py-1.5 pl-8 pr-3 text-sm text-ink-800 placeholder:text-ink-300 focus:border-ink-400 focus:outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-ink-400 hover:text-ink-700"
aria-label="清除"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 12 12" fill="none">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</div>
</div>
{/* 结果列表 */}
<div className="overflow-y-auto" style={{ maxHeight }}>
{filtered.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-ink-400">{emptyText}</div>
) : (
<ul>
{filtered.map(item => {
const key = itemKey(item)
const active = key === activeKey
return (
<li key={key}>
<button
type="button"
onClick={() => onSelect?.(item)}
className={`w-full border-b border-ink-100 px-3 py-2 text-left transition-colors ${
active ? 'bg-press-wash/40' : 'hover:bg-paper-100'
}`}
>
{renderItem(item, active)}
</button>
</li>
)
})}
</ul>
)}
</div>
{/* 结果计数 */}
{filtered.length > 0 && (
<div className="border-t border-ink-200 px-3 py-1.5 text-right text-2xs text-ink-400">
{filtered.length} / {items.length}
</div>
)}
</div>
)
}
@@ -0,0 +1,27 @@
/**
* TrendingArrow: 趋势箭头(↑↓→)。
* InsightCard 的依赖子组件。
*/
export function TrendingArrow({ dir, className = '' }: { dir: 'up' | 'down' | 'flat'; className?: string }) {
if (dir === 'flat') {
return (
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
const isUp = dir === 'up'
return (
<svg
className={`${className} ${isUp ? '' : 'rotate-180'}`}
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
>
<path
d="M6 2L10 7H2L6 2Z"
fill="currentColor"
/>
</svg>
)
}
+11
View File
@@ -219,6 +219,17 @@ export { Switch } from './Switch'
export { Tabs } from './Tabs' export { Tabs } from './Tabs'
export type { TabItem, TabsProps } from './Tabs' export type { TabItem, TabsProps } from './Tabs'
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
export { EventList } from './EventList'
export { FilterTable } from './FilterTable'
export { InsightCard, InsightGrid } from './InsightCard'
export { LoaderGrid } from './LoaderGrid'
export { SearchList } from './SearchList'
export { TrendingArrow } from './TrendingArrow'
export type { EventItem } from './EventList'
export type { FilterColumn } from './FilterTable'
export type { InsightCardProps } from './InsightCard'
// ── Modal ──────────────────────────────────────────────────────── // ── Modal ────────────────────────────────────────────────────────
export interface ModalProps { export interface ModalProps {