f0a1552
ZhenYi
feat(chat): add chat module with room management, message handling, and session revocation
f0a1552
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
export function Button({
children,
onClick,
variant = 'primary',
size = 'md',
disabled,
type = 'button',
title,
}: {
children: ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md'
disabled?: boolean
type?: 'button' | 'submit'
title?: string
}) {
const base =
'inline-flex items-center justify-center gap-1 rounded-md font-medium transition disabled:cursor-not-allowed disabled:opacity-50'
const sizes = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'
const variants: Record<string, string> = {
primary: 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white',
secondary:
'border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
danger: 'bg-red-600 text-white hover:bg-red-500',
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
}
return (
<button
type={type}
title={title}
disabled={disabled}
onClick={onClick}
className={`${base} ${sizes} ${variants[variant]}`}
>
{children}
</button>
)
}
export function Card({
title,
action,
children,
className,
}: {
title?: ReactNode
action?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section
className={`rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900 ${className ?? ''}`}
>
{(title || action) && (
<header className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">{title}</h2>
{action}
</header>
)}
{children}
</section>
)
}
type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
export function Badge({ tone = 'neutral', children }: { tone?: Tone; children: ReactNode }) {
const tones: Record<Tone, string> = {
neutral: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
danger: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
info: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
}
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${tones[tone]}`}>
{children}
</span>
)
}
export function statusTone(state: string): Tone {
switch (state) {
case 'success':
case 'succeeded':
case 'done':
case 'completed':
case 'active':
return 'success'
case 'failed':
case 'failure':
case 'cancelled':
case 'canceled':
case 'removed':
return 'danger'
case 'running':
case 'processing':
case 'delivering':
case 'scheduled':
case 'cancel_requested':
return 'info'
case 'pending':
case 'queued':
return 'warning'
default:
return 'neutral'
}
}
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: ReactNode
}) {
return (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-slate-700 dark:text-slate-200">{label}</span>
{children}
{hint && <span className="text-xs text-slate-500 dark:text-slate-400">{hint}</span>}
</label>
)
}
const inputClass =
'w-full rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-900 outline-none focus:border-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100'
export function TextInput({
value,
onChange,
placeholder,
type = 'text',
disabled,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
type?: string
disabled?: boolean
}) {
return (
<input
className={inputClass}
value={value}
type={type}
disabled={disabled}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export function Select({
value,
onChange,
options,
}: {
value: string
onChange: (value: string) => void
options: { value: string; label: string }[]
}) {
return (
<select className={inputClass} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
export function Checkbox({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (value: boolean) => void
label: string
}) {
return (
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
)
}
export interface Column<T> {
key: string
title: ReactNode
render: (row: T) => ReactNode
className?: string
}
export function DataTable<T>({
columns,
rows,
rowKey,
onRowClick,
empty = '暂无数据',
}: {
columns: Column<T>[]
rows: T[]
rowKey: (row: T) => string
onRowClick?: (row: T) => void
empty?: string
}) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:text-slate-400">
{columns.map((column) => (
<th key={column.key} className={`px-2 py-2 font-medium ${column.className ?? ''}`}>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-2 py-6 text-center text-slate-400">
{empty}
</td>
</tr>
)}
{rows.map((row) => (
<tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={`border-b border-slate-100 last:border-0 dark:border-slate-800 ${
onRowClick ? 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/60' : ''
}`}
>
{columns.map((column) => (
<td key={column.key} className={`px-2 py-2 align-middle ${column.className ?? ''}`}>
{column.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function Pagination({
total,
limit,
offset,
onChange,
}: {
total: number
limit: number
offset: number
onChange: (offset: number) => void
}) {
const page = Math.floor(offset / limit) + 1
const pages = Math.max(1, Math.ceil(total / limit))
return (
<div className="mt-3 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span>
共 {total} 条 · 第 {page}/{pages} 页
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
disabled={offset <= 0}
onClick={() => onChange(Math.max(0, offset - limit))}
>
上一页
</Button>
<Button
size="sm"
variant="secondary"
disabled={offset + limit >= total}
onClick={() => onChange(offset + limit)}
>
下一页
</Button>
</div>
</div>
)
}
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean
title: ReactNode
onClose: () => void
children: ReactNode
footer?: ReactNode
}) {
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4">
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg bg-white p-4 shadow-xl dark:bg-slate-900">
<header className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
关闭
</Button>
</header>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">{children}</div>
{footer && <footer className="mt-4 flex justify-end gap-2">{footer}</footer>}
</div>
</div>
)
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel = '确认执行',
requireText,
busy,
onConfirm,
onClose,
}: {
open: boolean
title: string
description: ReactNode
confirmLabel?: string
requireText?: string
busy?: boolean
onConfirm: () => void
onClose: () => void
}) {
const [text, setText] = useState('')
const canConfirm = !requireText || text === requireText
return (
<Modal
open={open}
title={title}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button variant="danger" disabled={!canConfirm || busy} onClick={onConfirm}>
{busy ? '执行中…' : confirmLabel}
</Button>
</>
}
>
<div className="space-y-3">
<div>{description}</div>
{requireText && (
<Field label={`输入 ${requireText} 以确认`}>
<TextInput value={text} onChange={setText} placeholder={requireText} />
</Field>
)}
</div>
</Modal>
)
}
export function ErrorBanner({ message }: { message: string | null }) {
if (!message) {
return null
}
return (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{message}
</div>
)
}
export function Loading({ label = '加载中…' }: { label?: string }) {
return <div className="py-6 text-center text-sm text-slate-400">{label}</div>
}
export function EmptyState({ label }: { label: string }) {
return <div className="py-6 text-center text-sm text-slate-400">{label}</div>
}
interface ToastItem {
id: number
message: string
tone: 'success' | 'danger'
}
const ToastContext = createContext<(message: string, tone?: 'success' | 'danger') => void>(() => {})
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([])
const push = useCallback((message: string, tone: 'success' | 'danger' = 'success') => {
const id = Date.now() + Math.random()
setItems((current) => [...current, { id, message, tone }])
window.setTimeout(() => {
setItems((current) => current.filter((item) => item.id !== id))
}, 4000)
}, [])
const value = useMemo(() => push, [push])
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex flex-col gap-2">
{items.map((item) => (
<div
key={item.id}
className={`pointer-events-auto rounded-md px-3 py-2 text-sm text-white shadow-lg ${
item.tone === 'success' ? 'bg-emerald-600' : 'bg-red-600'
}`}
>
{item.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
return useContext(ToastContext)
}