feat(P1): 新增 5 个数据展示组件(灵感来自 beautifului.dev)
- FilterTable 带列筛选+排序的增强表格 - InsightCard 指标洞察卡片(趋势+对比+状态色) - SearchList 即时搜索列表(过滤+选中态) - EventList 事件流(时间线+类型图标+展开详情) - LoaderGrid 网格骨架屏(卡片/表格加载态) 均适配 Profeto 报纸风(衬线/纸色/细线),统一收口 components/ui。
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user