Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
export function Badge({
children,
tone = 'default',
}: {
children: ReactNode
tone?: BadgeTone
}) {
const tones: Record<BadgeTone, string> = {
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
success:
'border-[color-mix(in_srgb,var(--success)_25%,transparent)] bg-[color-mix(in_srgb,var(--success)_12%,transparent)] text-[var(--success)]',
warn:
'border-[color-mix(in_srgb,var(--warning)_25%,transparent)] bg-[color-mix(in_srgb,var(--warning)_12%,transparent)] text-[var(--warning)]',
danger:
'border-[color-mix(in_srgb,var(--danger)_25%,transparent)] bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] text-[var(--danger)]',
info:
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
}
return (
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}>
{children}
</span>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
export type ButtonSize = 'sm' | 'md' | 'icon'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
}
export function Button({
variant = 'default',
size = 'md',
className,
...props
}: ButtonProps) {
return (
<button
className={cn('ui-btn', `ui-btn-${variant}`, `ui-btn-${size}`, className)}
{...props}
/>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils'
export function Card({
children,
className,
hoverable,
onClick,
...rest
}: {
children: ReactNode
className?: string
hoverable?: boolean
onClick?: HTMLAttributes<HTMLDivElement>['onClick']
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick'>) {
return (
<div
className={cn(
'rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-sm)] transition-[background,border-color] duration-150',
hoverable && 'hover:border-[var(--border-hover)] hover:bg-[var(--bg-tertiary)]',
onClick && 'cursor-pointer',
className,
)}
onClick={onClick}
{...rest}
>
{children}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
export function Checkbox({
checked,
onCheckedChange,
disabled,
className,
id,
}: {
checked: boolean
onCheckedChange: (v: boolean) => void
disabled?: boolean
className?: string
id?: string
}) {
return (
<button
id={id}
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
className={cn(
'inline-flex h-4 w-4 items-center justify-center rounded border transition',
checked
? 'border-transparent bg-[var(--bg-accent)] text-[var(--text-on-accent)]'
: 'border-[var(--border-hover)] bg-[var(--bg-secondary)]',
disabled && 'opacity-55 cursor-not-allowed',
className,
)}
onClick={() => !disabled && onCheckedChange(!checked)}
>
{checked ? <Check size={12} strokeWidth={2.5} /> : null}
</button>
)
}
+8
View File
@@ -0,0 +1,8 @@
export function Empty({ text }: { text: string }) {
return (
<div className="px-4 py-12 text-center">
<div className="mx-auto mb-3 h-10 w-10 rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] opacity-70" />
<p className="text-sm text-[var(--text-muted)]">{text}</p>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
export function Field({
label,
children,
hint,
}: {
label: string
children: ReactNode
hint?: string
}) {
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</span>
{children}
{hint ? <span className="block text-[11px] text-[var(--text-muted)]">{hint}</span> : null}
</label>
)
}
+20
View File
@@ -0,0 +1,20 @@
export { cn } from '@/lib/utils'
export { Button } from './button'
export type { ButtonProps, ButtonVariant, ButtonSize } from './button'
export { Input } from './input'
export { Textarea } from './textarea'
export { Select } from './select'
export type { SelectProps, SelectOption } from './select'
export { Card } from './card'
export { Badge } from './badge'
export type { BadgeTone } from './badge'
export { Modal } from './modal'
export { Table, Th, Td } from './table'
export { Field } from './field'
export { PageHeader } from './page-header'
export { Empty } from './empty'
export { Loading } from './loading'
export { SectionLabel } from './section-label'
export { Switch } from './switch'
export { Tabs } from './tabs'
export { Checkbox } from './checkbox'
+6
View File
@@ -0,0 +1,6 @@
import type { InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
}
+8
View File
@@ -0,0 +1,8 @@
export function Loading({ text = '加载中...' }: { text?: string }) {
return (
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-[var(--text-muted)]">
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-[var(--border-hover)] border-t-[var(--text-secondary)]" />
{text}
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
export function Modal({
open,
title,
children,
onClose,
footer,
className,
size = 'md',
}: {
open: boolean
title: string
children: ReactNode
onClose: () => void
footer?: ReactNode
className?: string
size?: 'md' | 'lg' | 'xl' | 'full'
}) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const sizes = {
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-[95vw]',
}
return (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
onClick={onClose}
>
<div
className={`max-h-[90vh] w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-lg)] ${sizes[size]} ${className || ''}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
<h3 className="text-[15px] font-semibold tracking-tight">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<div className="space-y-3 p-4">{children}</div>
{footer ? (
<div className="flex justify-end gap-2 border-t border-[var(--border)] px-4 py-3">{footer}</div>
) : null}
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
export function PageHeader({
title,
description,
subtitle,
actions,
}: {
title: string
description?: string
subtitle?: string
actions?: ReactNode
}) {
return (
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex flex-wrap items-baseline gap-3">
<h1 className="m-0 text-2xl font-bold leading-tight tracking-tight text-[var(--text-primary)]">
{title}
</h1>
{subtitle ? <span className="text-[13px] text-[var(--text-muted)]">{subtitle}</span> : null}
</div>
<div className="h-[3px] w-8 rounded-sm bg-[var(--bg-accent)]" />
{description ? <p className="mt-2.5 text-sm text-[var(--text-muted)]">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-1.5">{actions}</div> : null}
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<h2 className="mb-3 text-[12px] font-medium uppercase tracking-wide text-[var(--text-muted)]">
{children}
</h2>
)
}
+204
View File
@@ -0,0 +1,204 @@
import {
Children,
isValidElement,
useEffect,
useId,
useMemo,
useRef,
useState,
type ButtonHTMLAttributes,
type ReactNode,
} from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
export type SelectOption = {
value: string
label: ReactNode
disabled?: boolean
}
type ChangeHandler = (event: { target: { value: string } }) => void
export interface SelectProps {
value?: string
defaultValue?: string
onChange?: ChangeHandler
onValueChange?: (value: string) => void
options?: SelectOption[]
children?: ReactNode
placeholder?: string
className?: string
disabled?: boolean
name?: string
id?: string
}
function parseOptionsFromChildren(children: ReactNode): SelectOption[] {
const list: SelectOption[] = []
Children.forEach(children, (child) => {
if (!isValidElement(child)) return
if (child.type !== 'option') return
const props = child.props as {
value?: string | number
disabled?: boolean
children?: ReactNode
}
list.push({
value: props.value == null ? '' : String(props.value),
label: props.children,
disabled: Boolean(props.disabled),
})
})
return list
}
export function Select({
value,
defaultValue = '',
onChange,
onValueChange,
options,
children,
placeholder = '请选择',
className,
disabled,
name,
id,
}: SelectProps) {
const reactId = useId()
const listboxId = id || `select-${reactId}`
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [uncontrolled, setUncontrolled] = useState(defaultValue)
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
const parsed = useMemo(() => {
if (options?.length) return options
return parseOptionsFromChildren(children)
}, [options, children])
const current = value !== undefined ? value : uncontrolled
const selected = parsed.find((o) => o.value === current)
const display = selected?.label ?? (current ? current : null)
function commit(next: string) {
if (value === undefined) setUncontrolled(next)
onValueChange?.(next)
onChange?.({ target: { value: next } })
setOpen(false)
}
function updateMenuPosition() {
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const viewportH = window.innerHeight
const spaceBelow = viewportH - rect.bottom
const openUp = spaceBelow < 220 && rect.top > spaceBelow
setMenuStyle({
position: 'fixed',
left: rect.left,
width: Math.max(rect.width, 140),
zIndex: 80,
...(openUp
? { bottom: viewportH - rect.top + 6, top: 'auto' }
: { top: rect.bottom + 6, bottom: 'auto' }),
})
}
useEffect(() => {
if (!open) return
updateMenuPosition()
const onDoc = (e: MouseEvent) => {
const t = e.target as Node
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
const onReposition = () => updateMenuPosition()
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('resize', onReposition)
window.addEventListener('scroll', onReposition, true)
return () => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', onReposition)
window.removeEventListener('scroll', onReposition, true)
}
}, [open])
return (
<div ref={rootRef} className={cn('relative w-full', className)}>
{name ? <input type="hidden" name={name} value={current} /> : null}
<button
ref={triggerRef}
id={listboxId}
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')}
onClick={() => {
if (disabled) return
setOpen((v) => !v)
}}
>
<span className={cn('min-w-0 flex-1 truncate', !display && 'text-[var(--text-muted)]')}>
{display ?? placeholder}
</span>
<ChevronDown
size={14}
strokeWidth={1.5}
className={cn('shrink-0 text-[var(--text-muted)] transition-transform', open && 'rotate-180')}
/>
</button>
{open &&
createPortal(
<div
ref={menuRef}
role="listbox"
aria-labelledby={listboxId}
className="ui-select-menu"
style={menuStyle}
>
{parsed.length === 0 ? (
<div className="px-3 py-2 text-sm text-[var(--text-muted)]"></div>
) : (
parsed.map((opt) => {
const active = opt.value === current
return (
<button
key={`${opt.value}__${String(opt.label)}`}
type="button"
role="option"
aria-selected={active}
disabled={opt.disabled}
className={cn('ui-select-item', active && 'is-active')}
onClick={() => {
if (opt.disabled) return
commit(opt.value)
}}
>
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
{active ? <Check size={14} strokeWidth={1.75} className="shrink-0 opacity-90" /> : null}
</button>
)
})
)}
</div>,
document.body,
)}
</div>
)
}
/** 兼容旧的原生 option 写法,无需改调用方 */
export type NativeSelectProps = ButtonHTMLAttributes<HTMLButtonElement>
+36
View File
@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils'
export function Switch({
checked,
onCheckedChange,
disabled,
className,
}: {
checked: boolean
onCheckedChange: (v: boolean) => void
disabled?: boolean
className?: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition',
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
disabled && 'opacity-55 cursor-not-allowed',
className,
)}
onClick={() => !disabled && onCheckedChange(!checked)}
>
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
)}
/>
</button>
)
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export function Table({ children }: { children: ReactNode }) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-left text-sm">{children}</table>
</div>
)
}
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
return (
<th
className={cn(
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]',
className,
)}
>
{children}
</th>
)
}
export function Td({ children, className }: { children?: ReactNode; className?: string }) {
return (
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}>
{children}
</td>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils'
export function Tabs({
value,
onValueChange,
items,
className,
}: {
value: string
onValueChange: (v: string) => void
items: { value: string; label: string }[]
className?: string
}) {
return (
<div
className={cn(
'flex max-w-full items-center gap-1.5 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] p-1.5',
'[scrollbar-width:thin]',
className,
)}
role="tablist"
>
{items.map((item) => {
const active = value === item.value
return (
<button
key={item.value}
type="button"
role="tab"
aria-selected={active}
className={cn(
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
active
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
: 'text-[var(--text-muted)] hover:bg-[var(--bg-hover)]/60 hover:text-[var(--text-secondary)]',
)}
onClick={() => onValueChange(item.value)}
>
{item.label}
</button>
)
})}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
}