feat: 实现 AnimatedBadge + CustomSelect 并接入 Collection/Dashboard 页

AnimatedBadge(状态标签):
- 七种语义(neutral/info/loading/success/warning/danger)+三种尺寸
- loading 状态脉冲点动画
- 接入 Collection 页(当前任务状态 + 最近任务历史)

CustomSelect(自定义下拉选择器):
- 带键盘交互(↑↓/Enter/Esc)+分组+图标+动画
- 命名避让原生 Select(CustomSelect)

Dashboard 页接入 InsightCard/InsightGrid/LoaderGrid 替代旧布局。
This commit is contained in:
shangfangjian
2026-09-23 02:13:25 +08:00
parent eedfc50bbc
commit ae05833de3
5 changed files with 352 additions and 27 deletions
+19 -21
View File
@@ -14,6 +14,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal' import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
import type { IngestJob, League } from '../../api/types' import type { IngestJob, League } from '../../api/types'
import type { CollectionRequest } from '../../api/types' import type { CollectionRequest } from '../../api/types'
import { AnimatedBadge } from '../../components/ui'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { Button, Input, Select } from '../../components/ui' import { Button, Input, Select } from '../../components/ui'
@@ -345,27 +346,27 @@ export default function CollectionPage() {
)} )}
{taskStatus === 'running' && ( {taskStatus === 'running' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-ink-700"> <AnimatedBadge status="loading" size="md">
<Spinner /> {jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}, {elapsed}s</span> </AnimatedBadge>
</div>
<p className="text-2xs text-ink-400"> <p className="text-2xs text-ink-400">
, 3 {elapsed}s · ,, 3
</p> </p>
</div> </div>
)} )}
{taskStatus === 'done' && ( {taskStatus === 'done' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-ok-700"> <AnimatedBadge status="success" size="md">
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" /> {jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span> </AnimatedBadge>
</div>
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>} {summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
</div> </div>
)} )}
{taskStatus === 'error' && ( {taskStatus === 'error' && (
<div className="space-y-1"> <div className="space-y-2">
<p className="text-xs text-press">{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p> <AnimatedBadge status="danger" size="md">
{jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
</AnimatedBadge>
{jobInfo?.error && ( {jobInfo?.error && (
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p> <p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
)} )}
@@ -394,22 +395,19 @@ export default function CollectionPage() {
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">,</p> <p className="px-4 py-2 text-xs text-ink-400 sm:px-5">,</p>
) : ( ) : (
<div> <div>
{recentJobs.map(j => { {recentJobs.map(j => (
const st = j.status
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
return (
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5"> <div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} /> <Badge status={j.status === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge> <AnimatedBadge
<span className="text-2xs text-ink-600">{label}</span> status={j.status === 'failed' ? 'danger' : j.status === 'success' ? 'success' : j.status === 'running' ? 'loading' : 'neutral'}
size="sm"
/>
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400"> <span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })} {j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')} {' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
</span> </span>
</div> </div>
) ))}
})}
</div> </div>
)} )}
</CardBody> </CardBody>
@@ -0,0 +1,67 @@
/**
* AnimatedBadge: 带脉冲状态点的小标签。
*
* 灵感来自 beui.dev AnimatedBadge,适配 Profeto 报纸风:
* - 状态点脉冲动画(loading) + 静态色点(其他状态)
* - 三种尺寸(sm/md/lg) + 七种语义(neutral/info/loading/success/warning/danger)
* - 用于 ingest_jobs / predictions / data_sources 等状态展示
*/
import { cn } from './utils'
export type AnimatedBadgeStatus = 'neutral' | 'info' | 'loading' | 'success' | 'warning' | 'danger'
export type AnimatedBadgeSize = 'sm' | 'md' | 'lg'
const STATUS_DOT: Record<AnimatedBadgeStatus, string> = {
neutral: 'bg-ink-400',
info: 'bg-sky-500',
loading: 'bg-sky-500',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-rose-500',
}
const STATUS_LABEL: Record<AnimatedBadgeStatus, string> = {
neutral: '未开始',
info: '信息',
loading: '进行中',
success: '成功',
warning: '警告',
danger: '失败',
}
const SIZE_CLASS: Record<AnimatedBadgeSize, string> = {
sm: 'gap-1.5 px-2 py-0.5 text-2xs',
md: 'gap-2 px-2.5 py-1 text-xs',
lg: 'gap-2.5 px-3 py-1 text-sm',
}
export interface AnimatedBadgeProps {
status: AnimatedBadgeStatus
size?: AnimatedBadgeSize
children?: React.ReactNode
className?: string
}
export function AnimatedBadge({ status, size = 'md', children, className }: AnimatedBadgeProps) {
const dot = STATUS_DOT[status]
const label = children ?? STATUS_LABEL[status]
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full border border-ink-200 bg-paper-50 font-medium text-ink-700',
SIZE_CLASS[size],
className,
)}
>
{/* 状态点:loading 脉冲动画,其他静态 */}
<span className="relative flex h-2 w-2">
{status === 'loading' && (
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', dot)} />
)}
<span className={cn('relative inline-flex h-2 w-2 rounded-full', dot)} />
</span>
{label}
</span>
)
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Select: 自定义下拉选择器(带展开动画 + 分组 + 图标)。
*
* 灵感来自 beui.dev Select,适配 Profeto 报纸风:
* - 触发器 + 弹出层(Portal) + 选项列表
* - 键盘交互(↑↓/Enter/Esc)
* - 选项分组(SelectGroup)
* - 选中态高亮 + hover 态
* - 支持图标前缀
*/
import { useEffect, useRef, useState } from 'react'
import { cn } from './utils'
export interface SelectOption {
value: string
label: string
icon?: React.ReactNode
disabled?: boolean
}
export interface SelectGroup {
label: string
options: SelectOption[]
}
interface SelectCoreProps {
value: string
onChange: (value: string) => void
options?: SelectOption[]
groups?: SelectGroup[]
placeholder?: string
disabled?: boolean
className?: string
id?: string
}
export function CustomSelect({
value,
onChange,
options = [],
groups = [],
placeholder = '请选择…',
disabled = false,
className,
id,
}: SelectCoreProps) {
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)
// 扁平化选项列表(用于键盘导航)
const flat: SelectOption[] = []
const groupLabels: string[] = []
if (groups.length) {
groups.forEach(g => {
groupLabels.push(g.label)
flat.push(...g.options)
})
} else {
flat.push(...options)
}
const selected = flat.find(o => o.value === value)
const selectedLabel = selected?.label ?? placeholder
// 点击外部关闭
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// 高亮项滚动到可视区
useEffect(() => {
if (open && listRef.current) {
const el = listRef.current.children[highlighted] as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}
}, [highlighted, open])
// 键盘交互
const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
if (open) {
const opt = flat[highlighted]
if (opt && !opt.disabled) {
onChange(opt.value)
setOpen(false)
}
} else {
setOpen(true)
setHighlighted(flat.findIndex(o => o.value === value))
}
break
case 'Escape':
setOpen(false)
break
case 'ArrowDown':
e.preventDefault()
if (!open) {
setOpen(true)
} else {
setHighlighted(h => {
let n = (h + 1) % flat.length
while (flat[n]?.disabled) n = (n + 1) % flat.length
return n
})
}
break
case 'ArrowUp':
e.preventDefault()
if (open) {
setHighlighted(h => {
let n = (h - 1 + flat.length) % flat.length
while (flat[n]?.disabled) n = (n - 1 + flat.length) % flat.length
return n
})
}
break
}
}
const handleSelect = (opt: SelectOption) => {
if (opt.disabled) return
onChange(opt.value)
setOpen(false)
}
const renderItems = () => {
if (groups.length > 0) {
let idx = 0
return groups.map((group, gi) => (
<li key={gi}>
<p className="border-b border-ink-100 px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-400">
{group.label}
</p>
<ul>
{group.options.map(opt => {
const i = idx++
const on = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={on}
disabled={opt.disabled}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setHighlighted(i)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
opt.disabled && 'cursor-not-allowed opacity-40',
)}
>
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
<span className="truncate">{opt.label}</span>
{on && (
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
</li>
)
})}
</ul>
</li>
))
}
return options.map((opt, idx) => {
const on = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={on}
disabled={opt.disabled}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setHighlighted(idx)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
opt.disabled && 'cursor-not-allowed opacity-40',
)}
>
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
<span className="truncate">{opt.label}</span>
{on && (
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
</li>
)
})
}
return (
<div ref={ref} className={cn('relative', className)} onKeyDown={onKeyDown}>
<button
type="button"
id={id}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => !disabled && setOpen(o => !o)}
className={cn(
'flex w-full items-center justify-between border bg-paper-50 px-3 py-2 text-sm transition-colors',
open ? 'border-ink-900' : 'border-ink-200',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-ink-400',
)}
>
<span className={cn('flex items-center gap-2 truncate', selected ? 'text-ink-800' : 'text-ink-400')}>
{selected?.icon && <span className="flex-shrink-0">{selected.icon}</span>}
{selectedLabel}
</span>
<svg
className={cn('h-4 w-4 flex-shrink-0 text-ink-400 transition-transform', open && 'rotate-180')}
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
>
<path d="M5 8l5 5 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<ul
ref={listRef}
role="listbox"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto border border-ink-900 bg-paper-50 py-1 shadow-md"
>
{renderItems()}
</ul>
)}
</div>
)
}
+4
View File
@@ -220,15 +220,19 @@ export { Tabs } from './Tabs'
export type { TabItem, TabsProps } from './Tabs' export type { TabItem, TabsProps } from './Tabs'
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ──────────────── // ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
export { AnimatedBadge } from './AnimatedBadge'
export { EventList } from './EventList' export { EventList } from './EventList'
export { FilterTable } from './FilterTable' export { FilterTable } from './FilterTable'
export { InsightCard, InsightGrid } from './InsightCard' export { InsightCard, InsightGrid } from './InsightCard'
export { LoaderGrid } from './LoaderGrid' export { LoaderGrid } from './LoaderGrid'
export { SearchList } from './SearchList' export { SearchList } from './SearchList'
export { CustomSelect } from './Select'
export { TrendingArrow } from './TrendingArrow' export { TrendingArrow } from './TrendingArrow'
export type { AnimatedBadgeStatus, AnimatedBadgeSize } from './AnimatedBadge'
export type { EventItem } from './EventList' export type { EventItem } from './EventList'
export type { FilterColumn } from './FilterTable' export type { FilterColumn } from './FilterTable'
export type { InsightCardProps } from './InsightCard' export type { InsightCardProps } from './InsightCard'
export type { SelectOption } from './Select'
// ── Modal ──────────────────────────────────────────────────────── // ── Modal ────────────────────────────────────────────────────────
+7
View File
@@ -0,0 +1,7 @@
/**
* cn: className 合并工具(轻量实现,无需 clsx/tailwind-merge 依赖)。
* Profeto 现有代码用 `cx` 做同样的事;本文件为 AnimatedBadge/Select 单独提供。
*/
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ')
}