diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx
index e4fc8d9..d53329c 100644
--- a/frontend/src/admin/pages/Collection.tsx
+++ b/frontend/src/admin/pages/Collection.tsx
@@ -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' && (
-
-
- 任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…
-
+
+ 执行中{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
+
- 后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。
+ 已运行 {elapsed}s · 后台异步执行,关闭页面不影响结果,每 3 秒自动轮询进度。
)}
{taskStatus === 'done' && (
-
-
- 采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}
-
+
+ 采集完成{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
+
{summary &&
{summary.detail}
}
)}
{taskStatus === 'error' && (
-
-
采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}
+
+
+ 采集失败{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
+
{jobInfo?.error && (
{jobInfo.error.slice(0, 200)}
)}
@@ -394,22 +395,19 @@ export default function CollectionPage() {
还没有任务记录,触发一次采集后这里会出现历史。
) : (
- {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 (
-
-
- {j.task}
- {label}
-
- {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) : '')}
-
-
- )
- })}
+ {recentJobs.map(j => (
+
+
{j.task}
+
+
+ {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) : '')}
+
+
+ ))}
)}
diff --git a/frontend/src/components/ui/AnimatedBadge.tsx b/frontend/src/components/ui/AnimatedBadge.tsx
new file mode 100644
index 0000000..2ce1bc0
--- /dev/null
+++ b/frontend/src/components/ui/AnimatedBadge.tsx
@@ -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
= {
+ 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 = {
+ neutral: '未开始',
+ info: '信息',
+ loading: '进行中',
+ success: '成功',
+ warning: '警告',
+ danger: '失败',
+}
+
+const SIZE_CLASS: Record = {
+ 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 (
+
+ {/* 状态点:loading 脉冲动画,其他静态 */}
+
+ {status === 'loading' && (
+
+ )}
+
+
+ {label}
+
+ )
+}
diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx
new file mode 100644
index 0000000..5a9ed0a
--- /dev/null
+++ b/frontend/src/components/ui/Select.tsx
@@ -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(null)
+ const listRef = useRef(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) => (
+
+
+ {group.label}
+
+
+ {group.options.map(opt => {
+ const i = idx++
+ const on = opt.value === value
+ return (
+ -
+
+
+ )
+ })}
+
+
+ ))
+ }
+ return options.map((opt, idx) => {
+ const on = opt.value === value
+ return (
+
+
+
+ )
+ })
+ }
+
+ return (
+
+
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx
index e7e265a..3679f7a 100644
--- a/frontend/src/components/ui/index.tsx
+++ b/frontend/src/components/ui/index.tsx
@@ -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 ────────────────────────────────────────────────────────
diff --git a/frontend/src/components/ui/utils.ts b/frontend/src/components/ui/utils.ts
new file mode 100644
index 0000000..a60e01d
--- /dev/null
+++ b/frontend/src/components/ui/utils.ts
@@ -0,0 +1,7 @@
+/**
+ * cn: className 合并工具(轻量实现,无需 clsx/tailwind-merge 依赖)。
+ * Profeto 现有代码用 `cx` 做同样的事;本文件为 AnimatedBadge/Select 单独提供。
+ */
+export function cn(...parts: Array): string {
+ return parts.filter(Boolean).join(' ')
+}