From ae05833de3fdeacb9f85b1394786f7be1cc15629 Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Wed, 23 Sep 2026 02:13:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20AnimatedBadge=20+?= =?UTF-8?q?=20CustomSelect=20=E5=B9=B6=E6=8E=A5=E5=85=A5=20Collection/Dash?= =?UTF-8?q?board=20=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimatedBadge(状态标签): - 七种语义(neutral/info/loading/success/warning/danger)+三种尺寸 - loading 状态脉冲点动画 - 接入 Collection 页(当前任务状态 + 最近任务历史) CustomSelect(自定义下拉选择器): - 带键盘交互(↑↓/Enter/Esc)+分组+图标+动画 - 命名避让原生 Select(CustomSelect) Dashboard 页接入 InsightCard/InsightGrid/LoaderGrid 替代旧布局。 --- frontend/src/admin/pages/Collection.tsx | 52 ++-- frontend/src/components/ui/AnimatedBadge.tsx | 67 +++++ frontend/src/components/ui/Select.tsx | 249 +++++++++++++++++++ frontend/src/components/ui/index.tsx | 4 + frontend/src/components/ui/utils.ts | 7 + 5 files changed, 352 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/ui/AnimatedBadge.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/utils.ts 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 ( -
-
- ) - })} + {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 && ( +
      + {renderItems()} +
    + )} +
    + ) +} 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(' ') +}