From f33a1d91ff1955b4a3368c8b9217567ddcee296d Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Wed, 23 Sep 2026 01:30:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(P1):=20=E6=96=B0=E5=A2=9E=205=20=E4=B8=AA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=E7=BB=84=E4=BB=B6(?= =?UTF-8?q?=E7=81=B5=E6=84=9F=E6=9D=A5=E8=87=AA=20beautifului.dev)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FilterTable 带列筛选+排序的增强表格 - InsightCard 指标洞察卡片(趋势+对比+状态色) - SearchList 即时搜索列表(过滤+选中态) - EventList 事件流(时间线+类型图标+展开详情) - LoaderGrid 网格骨架屏(卡片/表格加载态) 均适配 Profeto 报纸风(衬线/纸色/细线),统一收口 components/ui。 --- frontend/src/components/ui/EventList.tsx | 111 +++++++++++ frontend/src/components/ui/FilterTable.tsx | 183 +++++++++++++++++++ frontend/src/components/ui/InsightCard.tsx | 84 +++++++++ frontend/src/components/ui/LoaderGrid.tsx | 93 ++++++++++ frontend/src/components/ui/SearchList.tsx | 118 ++++++++++++ frontend/src/components/ui/TrendingArrow.tsx | 27 +++ frontend/src/components/ui/index.tsx | 11 ++ 7 files changed, 627 insertions(+) create mode 100644 frontend/src/components/ui/EventList.tsx create mode 100644 frontend/src/components/ui/FilterTable.tsx create mode 100644 frontend/src/components/ui/InsightCard.tsx create mode 100644 frontend/src/components/ui/LoaderGrid.tsx create mode 100644 frontend/src/components/ui/SearchList.tsx create mode 100644 frontend/src/components/ui/TrendingArrow.tsx diff --git a/frontend/src/components/ui/EventList.tsx b/frontend/src/components/ui/EventList.tsx new file mode 100644 index 0000000..d2dfe7e --- /dev/null +++ b/frontend/src/components/ui/EventList.tsx @@ -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 = { + success: 'bg-emerald-500', + error: 'bg-rose-500', + warning: 'bg-amber-500', + info: 'bg-sky-500', + pending: 'bg-ink-300', +} + +const TYPE_LABEL: Record = { + success: '成功', + error: '失败', + warning: '警告', + info: '信息', + pending: '进行中', +} + +export function EventList({ events, maxInitial = 10 }: { events: EventItem[]; maxInitial?: number }) { + const [expanded, setExpanded] = useState>({}) + 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
暂无事件
+ } + + return ( +
+ {/* 时间轴细线 */} + + ) +} diff --git a/frontend/src/components/ui/FilterTable.tsx b/frontend/src/components/ui/FilterTable.tsx new file mode 100644 index 0000000..f298cd9 --- /dev/null +++ b/frontend/src/components/ui/FilterTable.tsx @@ -0,0 +1,183 @@ +/** + * FilterTable: 带列筛选 + 排序的增强表格。 + * + * 灵感来自 beautifului FilterTable,适配 Profeto 报纸风: + * - 列头点击排序(升/降/无) + * - 文本列支持输入筛选 + * - 状态列支持下拉筛选 + * - 与现有 DataTable 风格统一(细线/留白/衬线) + */ +import { useMemo, useState } from 'react' +import { EmptyState } from './Feedback' + +export interface FilterColumn { + 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>({ + columns, + data, + rowKey, + emptyText = '暂无数据', + initialSort, +}: { + columns: FilterColumn[] + data: T[] + rowKey: (row: T) => string | number + emptyText?: string + initialSort?: { key: string; dir: 'asc' | 'desc' } +}) { + const [sortKey, setSortKey] = useState(initialSort?.key ?? null) + const [sortDir, setSortDir] = useState(initialSort?.dir ?? null) + const [filters, setFilters] = useState>({}) + + 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 + } + + return ( +
+ + + + {columns.map(col => ( + + ))} + + {/* 筛选行 */} + {columns.some(c => c.filterable) && ( + + {columns.map(col => ( + + ))} + + )} + + + {sorted.map(row => ( + + {columns.map(col => ( + + ))} + + ))} + +
+
+ {col.sortable && ( + + )} + {!col.sortable && col.label} +
+
+ {col.filterable && col.filterType === 'select' && col.filterOptions ? ( + + ) : col.filterable ? ( + 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} +
+ {col.render + ? col.render(row) + : row[col.key] != null + ? String(row[col.key]) + : '—'} +
+ {sorted.length === 0 && filtered.length > 0 && ( +
没有匹配的数据
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/InsightCard.tsx b/frontend/src/components/ui/InsightCard.tsx new file mode 100644 index 0000000..42834a3 --- /dev/null +++ b/frontend/src/components/ui/InsightCard.tsx @@ -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 = { + 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 = { + 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 ( +
+
+
+

{label}

+

+ {value} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+ + {(trend || trendLabel) && ( +
+ {trend && } + {trendLabel && ( + {trendLabel} + )} + {compareHint && {compareHint}} +
+ )} +
+ ) +} + +/** InsightCard 网格容器 */ +export function InsightGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/ui/LoaderGrid.tsx b/frontend/src/components/ui/LoaderGrid.tsx new file mode 100644 index 0000000..ca38e96 --- /dev/null +++ b/frontend/src/components/ui/LoaderGrid.tsx @@ -0,0 +1,93 @@ +/** + * LoaderGrid: 网格骨架占位 —— 卡片/面板加载态。 + * + * 灵感来自 beautifului LoaderGrid,适配 Profeto 报纸风: + * - 网格布局(响应式) + * - 脉冲动画占位块 + * - 可配置列数/行数 + */ + +function shimmer() { + return 'animate-pulse bg-paper-100' +} + +/** 单张骨架卡片 */ +function SkeletonCard({ lines = 3 }: { lines?: number }) { + return ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: lines }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ) +} + +/** 单行骨架(表格行) */ +function SkeletonRow({ cols = 4 }: { cols?: number }) { + return ( + + {Array.from({ length: cols }).map((_, i) => ( + +
+ + ))} + + ) +} + +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 ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) + } + + return ( +
+ + + + {Array.from({ length: cols }).map((_, i) => ( + + ))} + + + + {Array.from({ length: count }).map((_, i) => ( + + ))} + +
+
+
+
+ ) +} diff --git a/frontend/src/components/ui/SearchList.tsx b/frontend/src/components/ui/SearchList.tsx new file mode 100644 index 0000000..3592449 --- /dev/null +++ b/frontend/src/components/ui/SearchList.tsx @@ -0,0 +1,118 @@ +/** + * SearchList: 即时搜索列表 —— 输入框 + 过滤结果 + 选中态。 + * + * 灵感来自 beautifului SearchList,适配 Profeto 报纸风: + * - 顶部搜索输入(放大镜图标) + * - 即时过滤(前端本地匹配) + * - 选中高亮 + hover 态 + * - 空态提示 + */ +import { useMemo, useState } from 'react' + +export interface SearchListProps { + 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({ + items, + searchText, + itemKey, + renderItem, + onSelect, + activeKey, + placeholder = '搜索…', + emptyText = '无匹配项', + maxHeight = '360px', +}: SearchListProps) { + 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 ( +
+ {/* 搜索框 */} +
+
+ + 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 && ( + + )} +
+
+ + {/* 结果列表 */} +
+ {filtered.length === 0 ? ( +
{emptyText}
+ ) : ( +
    + {filtered.map(item => { + const key = itemKey(item) + const active = key === activeKey + return ( +
  • + +
  • + ) + })} +
+ )} +
+ + {/* 结果计数 */} + {filtered.length > 0 && ( +
+ {filtered.length} / {items.length} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/TrendingArrow.tsx b/frontend/src/components/ui/TrendingArrow.tsx new file mode 100644 index 0000000..33e1bf1 --- /dev/null +++ b/frontend/src/components/ui/TrendingArrow.tsx @@ -0,0 +1,27 @@ +/** + * TrendingArrow: 趋势箭头(↑↓→)。 + * InsightCard 的依赖子组件。 + */ +export function TrendingArrow({ dir, className = '' }: { dir: 'up' | 'down' | 'flat'; className?: string }) { + if (dir === 'flat') { + return ( + + ) + } + const isUp = dir === 'up' + return ( + + ) +} diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx index aa31cdf..e7e265a 100644 --- a/frontend/src/components/ui/index.tsx +++ b/frontend/src/components/ui/index.tsx @@ -219,6 +219,17 @@ export { Switch } from './Switch' export { Tabs } 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 ──────────────────────────────────────────────────────── export interface ModalProps {