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:
@@ -14,6 +14,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
|
||||
import type { IngestJob, League } 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 { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
@@ -345,27 +346,27 @@ export default function CollectionPage() {
|
||||
)}
|
||||
{taskStatus === 'running' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-ink-700">
|
||||
<Spinner />
|
||||
<span>任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…</span>
|
||||
</div>
|
||||
<AnimatedBadge status="loading" size="md">
|
||||
执行中{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
<p className="text-2xs text-ink-400">
|
||||
后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。
|
||||
已运行 {elapsed}s · 后台异步执行,关闭页面不影响结果,每 3 秒自动轮询进度。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'done' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-ok-700">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
|
||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
||||
</div>
|
||||
<AnimatedBadge status="success" size="md">
|
||||
采集完成{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'error' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-press">采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p>
|
||||
<div className="space-y-2">
|
||||
<AnimatedBadge status="danger" size="md">
|
||||
采集失败{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
{jobInfo?.error && (
|
||||
<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>
|
||||
) : (
|
||||
<div>
|
||||
{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">
|
||||
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
|
||||
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
||||
<span className="text-2xs text-ink-600">{label}</span>
|
||||
<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 })}
|
||||
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{recentJobs.map(j => (
|
||||
<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">
|
||||
<Badge status={j.status === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
||||
<AnimatedBadge
|
||||
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">
|
||||
{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) : '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -220,15 +220,19 @@ export { Tabs } from './Tabs'
|
||||
export type { TabItem, TabsProps } from './Tabs'
|
||||
|
||||
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
|
||||
export { AnimatedBadge } from './AnimatedBadge'
|
||||
export { EventList } from './EventList'
|
||||
export { FilterTable } from './FilterTable'
|
||||
export { InsightCard, InsightGrid } from './InsightCard'
|
||||
export { LoaderGrid } from './LoaderGrid'
|
||||
export { SearchList } from './SearchList'
|
||||
export { CustomSelect } from './Select'
|
||||
export { TrendingArrow } from './TrendingArrow'
|
||||
export type { AnimatedBadgeStatus, AnimatedBadgeSize } from './AnimatedBadge'
|
||||
export type { EventItem } from './EventList'
|
||||
export type { FilterColumn } from './FilterTable'
|
||||
export type { InsightCardProps } from './InsightCard'
|
||||
export type { SelectOption } from './Select'
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
Reference in New Issue
Block a user