chore: import deployable mai-bot source tree

This commit is contained in:
2026-05-11 00:51:12 +00:00
parent 4813699b3e
commit 7a54015f94
1009 changed files with 312999 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,54 @@
import { lazy, Suspense } from 'react'
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
export interface CodeEditorProps {
value: string
onChange?: (value: string) => void
language?: Language
readOnly?: boolean
height?: string
minHeight?: string
maxHeight?: string
placeholder?: string
theme?: 'light' | 'dark'
className?: string
}
const CodeEditorImpl = lazy(() => import('./CodeEditorImpl'))
function CodeEditorFallback({
height,
minHeight,
maxHeight,
className = '',
}: Pick<CodeEditorProps, 'height' | 'minHeight' | 'maxHeight' | 'className'>) {
return (
<div
className={`bg-muted animate-pulse rounded-md border ${className}`}
style={{ height, minHeight, maxHeight }}
/>
)
}
export function CodeEditor(props: CodeEditorProps) {
const { height = '400px', minHeight, maxHeight, className = '' } = props
return (
<Suspense
fallback={
<CodeEditorFallback
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
className={className}
/>
}
>
<CodeEditorImpl {...props} />
</Suspense>
)
}
export default CodeEditor

View File

@@ -0,0 +1,105 @@
import { css } from '@codemirror/lang-css'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { StreamLanguage } from '@codemirror/language'
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
import { linter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import CodeMirror from '@uiw/react-codemirror'
import { useTheme } from '@/components/use-theme'
import type { CodeEditorProps, Language } from './CodeEditor'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), linter(jsonParseLinter())],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}
export default function CodeEditorImpl({
value,
onChange,
language = 'text',
readOnly = false,
height = '400px',
minHeight,
maxHeight,
placeholder,
theme,
className = '',
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const extensions = [
...(languageExtensions[language] || []),
EditorView.lineWrapping,
// 应用 JetBrains Mono 字体
EditorView.theme({
'&': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-content': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-gutters': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-scroller': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
}),
]
if (readOnly) {
extensions.push(EditorView.editable.of(false))
}
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
const effectiveTheme = theme ?? resolvedTheme
return (
<div className={`custom-scrollbar overflow-hidden rounded-md border ${className}`}>
<CodeMirror
value={value}
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
theme={effectiveTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={onChange}
placeholder={placeholder}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,525 @@
/**
* ListFieldEditor - 动态数组字段编辑器
*
* 支持功能:
* - 字符串数组 (string[])
* - 数字数组 (number[])
* - 对象数组 (object[]) - 根据 item_fields 定义渲染
* - 拖拽排序
* - 动态增删项
*/
import { useState, useCallback, useMemo } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
// ============ 类型定义 ============
export interface ItemFieldDefinition {
/** 字段类型: "string" | "number" | "boolean" | "select" */
type: string
label?: string
placeholder?: string
default?: unknown
/** select 类型的选项 */
choices?: unknown[]
/** slider 类型的最小值 */
min?: number
/** slider 类型的最大值 */
max?: number
/** slider 类型的步进 */
step?: number
}
export interface ListFieldEditorProps {
/** 当前值 */
value: unknown[] | unknown
/** 值变化回调 */
onChange: (value: unknown[]) => void
/** 数组元素类型: "string" | "number" | "object" */
itemType?: string
/** 当 itemType="object" 时的字段定义 */
itemFields?: Record<string, ItemFieldDefinition>
/** 最小元素数量 */
minItems?: number
/** 最大元素数量 */
maxItems?: number
/** 是否禁用 */
disabled?: boolean
/** 新项的占位符文字 */
placeholder?: string
}
// ============ 可排序项组件 ============
interface SortableItemProps {
id: string
index: number
itemType: string
itemFields?: Record<string, ItemFieldDefinition>
value: unknown
onChange: (value: unknown) => void
onRemove: () => void
disabled?: boolean
canRemove: boolean
placeholder?: string
}
function SortableItem({
id,
index,
itemType,
itemFields,
value,
onChange,
onRemove,
disabled,
canRemove,
placeholder,
}: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-start gap-2 group',
isDragging && 'opacity-50 z-50'
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
'flex-shrink-0 p-2 cursor-grab active:cursor-grabbing',
'text-muted-foreground hover:text-foreground transition-colors',
'opacity-0 group-hover:opacity-100 focus:opacity-100',
disabled && 'cursor-not-allowed opacity-30'
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
{itemType === 'object' && itemFields ? (
<ObjectItemEditor
value={value as Record<string, unknown>}
onChange={onChange}
fields={itemFields}
disabled={disabled}
/>
) : itemType === 'number' ? (
<Input
type="number"
value={value as number ?? ''}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
className="font-mono"
/>
) : (
<Input
type="text"
value={value as string ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
/>
)}
</div>
{/* 删除按钮 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
disabled={disabled || !canRemove}
className={cn(
'flex-shrink-0 text-muted-foreground hover:text-destructive',
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity'
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
}
// ============ 对象项编辑器 ============
interface ObjectItemEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
fields: Record<string, ItemFieldDefinition>
disabled?: boolean
}
function ObjectItemEditor({
value,
onChange,
fields,
disabled,
}: ObjectItemEditorProps) {
const handleFieldChange = useCallback(
(fieldName: string, fieldValue: unknown) => {
onChange({
...value,
[fieldName]: fieldValue,
})
},
[value, onChange]
)
const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => {
const fieldValue = value?.[fieldName]
// boolean / switch
if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') {
return (
<div className="flex items-center justify-between py-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Switch
checked={Boolean(fieldValue ?? fieldDef.default)}
onCheckedChange={(checked) => handleFieldChange(fieldName, checked)}
disabled={disabled}
/>
</div>
)
}
// slider (number with min/max)
if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) {
const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<span className="text-xs text-muted-foreground">{numValue}</span>
</div>
<Slider
value={[numValue]}
onValueChange={(v) => handleFieldChange(fieldName, v[0])}
min={fieldDef.min ?? 0}
max={fieldDef.max ?? 100}
step={fieldDef.step ?? 1}
disabled={disabled}
className="py-1"
/>
</div>
)
}
// select
if (fieldDef.type === 'select' && fieldDef.choices) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Select
value={String(fieldValue ?? fieldDef.default ?? '')}
onValueChange={(v) => handleFieldChange(fieldName, v)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={fieldDef.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{fieldDef.choices.map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
// number
if (fieldDef.type === 'number') {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="number"
value={(fieldValue as number) ?? fieldDef.default ?? ''}
onChange={(e) =>
handleFieldChange(fieldName, parseFloat(e.target.value) || 0)
}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
// string (default)
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="text"
value={(fieldValue as string) ?? fieldDef.default ?? ''}
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
return (
<Card className="p-3 space-y-2 bg-muted/30">
{Object.entries(fields).map(([fieldName, fieldDef]) => (
<div key={fieldName}>
{renderField(fieldName, fieldDef)}
</div>
))}
</Card>
)
}
// ============ 主组件 ============
export function ListFieldEditor({
value,
onChange,
itemType = 'string',
itemFields,
minItems,
maxItems,
disabled,
placeholder,
}: ListFieldEditorProps) {
// 确保 value 是数组
const items: unknown[] = useMemo(() => {
if (Array.isArray(value)) return value
if (typeof value === 'string' && value.trim()) {
// 尝试解析逗号分隔的字符串
return value.split(',').map((s: string) => s.trim())
}
return []
}, [value])
// 为每个项生成稳定的 ID
const [itemIds] = useState(() => new Map<number, string>())
const getItemId = useCallback(
(index: number) => {
if (!itemIds.has(index)) {
itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`)
}
return itemIds.get(index)!
},
[itemIds]
)
// 同步 itemIds
const sortableIds = useMemo(() => {
// 清理多余的 ID
const newIds: string[] = []
for (let i = 0; i < items.length; i++) {
newIds.push(getItemId(i))
}
return newIds
}, [items.length, getItemId])
// DnD 传感器配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// 拖拽结束处理
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = sortableIds.indexOf(active.id as string)
const newIndex = sortableIds.indexOf(over.id as string)
const newItems = arrayMove(items, oldIndex, newIndex)
onChange(newItems)
}
},
[items, sortableIds, onChange]
)
// 添加新项
const handleAddItem = useCallback(() => {
if (maxItems != null && items.length >= maxItems) return
let newItem: unknown
if (itemType === 'object' && itemFields) {
// 创建包含默认值的对象
newItem = Object.fromEntries(
Object.entries(itemFields).map(([k, v]) => [k, v.default ?? ''])
)
} else if (itemType === 'number') {
newItem = 0
} else {
newItem = ''
}
onChange([...items, newItem])
}, [items, maxItems, itemType, itemFields, onChange])
// 修改项
const handleItemChange = useCallback(
(index: number, newValue: unknown) => {
const newItems = [...items]
newItems[index] = newValue
onChange(newItems)
},
[items, onChange]
)
// 删除项
const handleRemoveItem = useCallback(
(index: number) => {
if (minItems != null && items.length <= minItems) return
const newItems = items.filter((_: unknown, i: number) => i !== index)
// 清理 itemIds 映射
itemIds.delete(index)
onChange(newItems)
},
[items, minItems, itemIds, onChange]
)
const canAdd = maxItems == null || items.length < maxItems
const canRemove = minItems == null || items.length > minItems
return (
<div className="space-y-2">
{/* 列表项 */}
{items.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center border border-dashed rounded-md">
<AlertCircle className="h-4 w-4" />
<span></span>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item: unknown, index: number) => (
<SortableItem
key={sortableIds[index]}
id={sortableIds[index]}
index={index}
itemType={itemType}
itemFields={itemFields}
value={item}
onChange={(newValue) => handleItemChange(index, newValue)}
onRemove={() => handleRemoveItem(index)}
disabled={disabled}
canRemove={canRemove}
placeholder={placeholder}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 添加按钮 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddItem}
disabled={disabled || !canAdd}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
{maxItems !== undefined && (
<span className="ml-2 text-xs text-muted-foreground">
({items.length}/{maxItems})
</span>
)}
</Button>
{/* 限制提示 */}
{(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (
<p className="text-xs text-muted-foreground text-center">
{minItems != null && maxItems != null
? `允许 ${minItems} - ${maxItems}`
: minItems != null
? `至少 ${minItems}`
: `最多 ${maxItems}`}
</p>
)}
</div>
)
}
export default ListFieldEditor

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'
import { Progress } from '@/components/ui/progress'
/**
* @deprecated 请使用新的 RestartOverlay 组件
* import { RestartOverlay } from '@/components/restart-overlay'
*/
interface RestartingOverlayProps {
onRestartComplete?: () => void
onRestartFailed?: () => void
}
/**
* @deprecated 请使用新的 RestartOverlay 组件
* import { RestartOverlay } from '@/components/restart-overlay'
*/
export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) {
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting')
const [elapsedTime, setElapsedTime] = useState(0)
const [checkAttempts, setCheckAttempts] = useState(0)
useEffect(() => {
// 进度条动画
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) return prev
return prev + 1
})
}, 200)
// 计时器
const timerInterval = setInterval(() => {
setElapsedTime((prev) => prev + 1)
}, 1000)
// 等待3秒后开始检查状态给后端重启时间
const initialDelay = setTimeout(() => {
setStatus('checking')
startHealthCheck()
}, 3000)
return () => {
clearInterval(progressInterval)
clearInterval(timerInterval)
clearTimeout(initialDelay)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const startHealthCheck = () => {
const maxAttempts = 60 // 最多尝试60次约2分钟
const checkHealth = async () => {
try {
setCheckAttempts((prev) => prev + 1)
const response = await fetch('/api/webui/system/status', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(3000), // 3秒超时
})
if (response.ok) {
// 重启成功
setProgress(100)
setStatus('success')
setTimeout(() => {
onRestartComplete?.()
}, 1500)
} else {
throw new Error('Status check failed')
}
} catch {
// 继续尝试
if (checkAttempts < maxAttempts) {
setTimeout(checkHealth, 2000) // 2秒后重试
} else {
// 超过最大尝试次数
setStatus('failed')
onRestartFailed?.()
}
}
}
checkHealth()
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div className="fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="max-w-md w-full mx-4 space-y-8">
{/* 图标和状态 */}
<div className="flex flex-col items-center space-y-4">
{status === 'restarting' && (
<>
<Loader2 className="h-16 w-16 text-primary animate-spin" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
...
</p>
</>
)}
{status === 'checking' && (
<>
<Loader2 className="h-16 w-16 text-primary animate-spin" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
... ( {checkAttempts}/60)
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="h-16 w-16 text-green-500" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
...
</p>
</>
)}
{status === 'failed' && (
<>
<AlertCircle className="h-16 w-16 text-destructive" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
</p>
</>
)}
</div>
{/* 进度条 */}
{status !== 'failed' && (
<div className="space-y-2">
<Progress value={progress} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress}%</span>
<span>: {formatTime(elapsedTime)}</span>
</div>
</div>
)}
{/* 提示信息 */}
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<p className="text-sm text-muted-foreground">
{status === 'restarting' && '🔄 配置已保存,正在重启主程序...'}
{status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'}
{status === 'success' && '✅ 配置已生效,服务运行正常'}
{status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'}
</p>
</div>
{/* 失败时的操作按钮 */}
{status === 'failed' && (
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
</button>
<button
onClick={() => {
setStatus('checking')
setCheckAttempts(0)
startHealthCheck()
}}
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { AnimationContext } from '@/lib/animation-context'
type AnimationProviderProps = {
children: ReactNode
defaultEnabled?: boolean
defaultWavesEnabled?: boolean
storageKey?: string
wavesStorageKey?: string
}
export function AnimationProvider({
children,
defaultEnabled = true,
defaultWavesEnabled = true,
storageKey = 'enable-animations',
wavesStorageKey = 'enable-waves-background',
}: AnimationProviderProps) {
const [enableAnimations, setEnableAnimations] = useState<boolean>(() => {
const stored = localStorage.getItem(storageKey)
return stored !== null ? stored === 'true' : defaultEnabled
})
const [enableWavesBackground, setEnableWavesBackground] = useState<boolean>(() => {
const stored = localStorage.getItem(wavesStorageKey)
return stored !== null ? stored === 'true' : defaultWavesEnabled
})
useEffect(() => {
const root = document.documentElement
if (enableAnimations) {
root.classList.remove('no-animations')
} else {
root.classList.add('no-animations')
}
localStorage.setItem(storageKey, String(enableAnimations))
}, [enableAnimations, storageKey])
useEffect(() => {
localStorage.setItem(wavesStorageKey, String(enableWavesBackground))
}, [enableWavesBackground, wavesStorageKey])
const value = {
enableAnimations,
setEnableAnimations,
enableWavesBackground,
setEnableWavesBackground,
}
return <AnimationContext value={value}>{children}</AnimationContext>
}

View File

@@ -0,0 +1,64 @@
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
import type { ReactNode } from 'react'
import { getAsset } from '@/lib/asset-store'
type AssetStoreContextType = {
getAssetUrl: (assetId: string) => Promise<string | undefined>
}
const AssetStoreContext = createContext<AssetStoreContextType | null>(null)
type AssetStoreProviderProps = {
children: ReactNode
}
export function AssetStoreProvider({ children }: AssetStoreProviderProps) {
const urlCache = useRef<Map<string, string>>(new Map())
const getAssetUrl = async (assetId: string): Promise<string | undefined> => {
// Check cache first
const cached = urlCache.current.get(assetId)
if (cached) {
return cached
}
// Fetch from IndexedDB
const record = await getAsset(assetId)
if (!record) {
return undefined
}
// Create blob URL and cache it
const url = URL.createObjectURL(record.blob)
urlCache.current.set(assetId, url)
return url
}
const value = useMemo(
() => ({
getAssetUrl,
}),
[],
)
// Cleanup: revoke all blob URLs on unmount
useEffect(() => {
return () => {
urlCache.current.forEach((url) => {
URL.revokeObjectURL(url)
})
urlCache.current.clear()
}
}, [])
return <AssetStoreContext value={value}>{children}</AssetStoreContext>
}
export function useAssetStore() {
const context = useContext(AssetStoreContext)
if (!context) {
throw new Error('useAssetStore must be used within AssetStoreProvider')
}
return context
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState, useRef } from 'react'
import { ArrowUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
export function BackToTop() {
const [progress, setProgress] = useState(0)
const [visible, setVisible] = useState(false)
const scrollerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
// 简单的启发式:如果是主要滚动容器(通常高度较大)
// 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个
// 只要它有足够的滚动空间
if (target.scrollHeight > target.clientHeight + 100) {
scrollerRef.current = target
const scrollTop = target.scrollTop
const height = target.scrollHeight - target.clientHeight
const scrolled = height > 0 ? (scrollTop / height) * 100 : 0
setProgress(scrolled)
setVisible(scrollTop > 300)
}
}
// 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
return () => window.removeEventListener('scroll', handleScroll, { capture: true })
}, [])
const scrollToTop = () => {
scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
// SVG 环形进度条参数
const radius = 18
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference - (progress / 100) * circumference
return (
<div
className={cn(
"fixed bottom-24 right-8 z-50 transition-all duration-500 ease-in-out transform",
visible ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0 pointer-events-none"
)}
>
<Button
variant="outline"
size="icon"
className={cn(
"relative h-12 w-12 rounded-full shadow-xl",
"bg-background/80 backdrop-blur-md border-border/50",
"hover:bg-accent hover:scale-110 hover:shadow-2xl hover:border-primary/50",
"transition-all duration-300",
"group"
)}
onClick={scrollToTop}
aria-label="回到顶部"
>
{/* 进度环背景 */}
<svg className="absolute inset-0 h-full w-full -rotate-90 transform p-1" viewBox="0 0 44 44">
<circle
className="text-muted-foreground/10"
strokeWidth="3"
stroke="currentColor"
fill="transparent"
r={radius}
cx="22"
cy="22"
/>
{/* 进度环 */}
<circle
className="text-primary transition-all duration-100 ease-out"
strokeWidth="3"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
stroke="currentColor"
fill="transparent"
r={radius}
cx="22"
cy="22"
/>
</svg>
{/* 图标 */}
<ArrowUp
className="h-5 w-5 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-110"
strokeWidth={2.5}
/>
{/* 内部发光效果 (仅在 dark 模式下明显) */}
<div className="absolute inset-0 rounded-full bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { RotateCcw } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { hexToHSL } from '@/lib/theme/palette'
import {
type BackgroundEffects,
defaultBackgroundEffects,
} from '@/lib/theme/tokens'
function hslToHex(hsl: string): string {
if (!hsl) return '#000000'
const parts = hsl.split(' ').filter(Boolean)
if (parts.length < 3) return '#000000'
const h = parseFloat(parts[0])
const s = parseFloat(parts[1].replace('%', ''))
const l = parseFloat(parts[2].replace('%', ''))
const sDecimal = s / 100
const lDecimal = l / 100
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = lDecimal - c / 2
let r = 0
let g = 0
let b = 0
if (h >= 0 && h < 60) {
r = c
g = x
} else if (h >= 60 && h < 120) {
r = x
g = c
} else if (h >= 120 && h < 180) {
g = c
b = x
} else if (h >= 180 && h < 240) {
g = x
b = c
} else if (h >= 240 && h < 300) {
r = x
b = c
} else if (h >= 300 && h < 360) {
r = c
b = x
}
const toHex = (value: number) => {
const hex = Math.round((value + m) * 255).toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
type BackgroundEffectsControlsProps = {
effects: BackgroundEffects
onChange: (effects: BackgroundEffects) => void
disabled?: boolean
}
export function BackgroundEffectsControls({
effects,
onChange,
disabled = false,
}: BackgroundEffectsControlsProps) {
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
if (disabled) return
onChange({
...effects,
[key]: value,
})
}
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const hex = e.target.value
const hsl = hexToHSL(hex)
onChange({
...effects,
overlayColor: hsl,
})
}
const handlePositionChange = (value: string) => {
if (disabled) return
onChange({
...effects,
position: value as BackgroundEffects['position'],
})
}
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return
onChange({
...effects,
gradientOverlay: e.target.value,
})
}
const handleReset = () => {
if (disabled) return
onChange(defaultBackgroundEffects)
}
return (
<div className={disabled ? 'space-y-6 opacity-50' : 'space-y-6'}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"></h3>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={disabled}
className="h-8 px-2 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="grid gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Blur)</Label>
<span className="text-xs text-muted-foreground">{effects.blur}px</span>
</div>
<Slider
value={[effects.blur]}
min={0}
max={50}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('blur', vals[0])}
/>
</div>
<div className="space-y-3">
<Label> (Overlay Color)</Label>
<div className="flex items-center gap-3">
<div className="h-9 w-9 overflow-hidden rounded-md border shadow-sm">
<input
type="color"
value={hslToHex(effects.overlayColor)}
onChange={handleColorChange}
disabled={disabled}
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
/>
</div>
<Input
value={hslToHex(effects.overlayColor)}
readOnly
disabled={disabled}
className="flex-1 font-mono uppercase"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Opacity)</Label>
<span className="text-xs text-muted-foreground">
{Math.round(effects.overlayOpacity * 100)}%
</span>
</div>
<Slider
value={[effects.overlayOpacity * 100]}
min={0}
max={100}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)}
/>
</div>
<div className="space-y-3">
<Label> (Position)</Label>
<Select value={effects.position} onValueChange={handlePositionChange} disabled={disabled}>
<SelectTrigger disabled={disabled}>
<SelectValue placeholder="选择位置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cover"> (Cover)</SelectItem>
<SelectItem value="contain"> (Contain)</SelectItem>
<SelectItem value="center"> (Center)</SelectItem>
<SelectItem value="stretch"> (Stretch)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Brightness)</Label>
<span className="text-xs text-muted-foreground">{effects.brightness}%</span>
</div>
<Slider
value={[effects.brightness]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Contrast)</Label>
<span className="text-xs text-muted-foreground">{effects.contrast}%</span>
</div>
<Slider
value={[effects.contrast]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Saturate)</Label>
<span className="text-xs text-muted-foreground">{effects.saturate}%</span>
</div>
<Slider
value={[effects.saturate]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
/>
</div>
<div className="space-y-3">
<Label>CSS (Gradient Overlay)</Label>
<Input
value={effects.gradientOverlay || ''}
onChange={handleGradientChange}
disabled={disabled}
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
className="font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground"> CSS gradient </p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { useAssetStore } from '@/components/asset-provider'
import type { BackgroundConfig } from '@/lib/theme/tokens'
type BackgroundLayerProps = {
config: BackgroundConfig
layerId: string
}
function getAutoOverlayOpacity(layerId: string): number {
switch (layerId) {
case 'page':
return 0.62
case 'header':
return 0.72
case 'sidebar':
return 0.78
case 'card':
return 0.82
case 'dialog':
return 0.88
default:
return 0.68
}
}
function getAutoGradientOverlay(layerId: string): string | undefined {
if (layerId !== 'page') {
return undefined
}
return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)'
}
function buildFilterString(effects: BackgroundConfig['effects']): string {
const parts: string[] = []
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
if (effects.brightness !== 100) parts.push(`brightness(${effects.brightness}%)`)
if (effects.contrast !== 100) parts.push(`contrast(${effects.contrast}%)`)
if (effects.saturate !== 100) parts.push(`saturate(${effects.saturate}%)`)
return parts.join(' ')
}
function getBackgroundSize(position: BackgroundConfig['effects']['position']): string {
switch (position) {
case 'cover':
return 'cover'
case 'contain':
return 'contain'
case 'center':
return 'auto'
case 'stretch':
return '100% 100%'
default:
return 'cover'
}
}
function getObjectFit(position: BackgroundConfig['effects']['position']): React.CSSProperties['objectFit'] {
switch (position) {
case 'cover':
return 'cover'
case 'contain':
return 'contain'
case 'center':
return 'none'
case 'stretch':
return 'fill'
default:
return 'cover'
}
}
export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
const { getAssetUrl } = useAssetStore()
const [blobUrl, setBlobUrl] = useState<string | undefined>()
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (!config.assetId) {
setBlobUrl(undefined)
return
}
getAssetUrl(config.assetId).then(setBlobUrl)
}, [config.assetId, getAssetUrl])
useEffect(() => {
if (config.type !== 'video' || !videoRef.current) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const apply = () => {
if (videoRef.current) {
if (mq.matches) {
videoRef.current.pause()
} else {
videoRef.current.play().catch(() => {})
}
}
}
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [config.type])
if (config.type === 'none') {
return null
}
const filterString = buildFilterString(config.effects)
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
const hasExplicitOverlay = overlayOpacity > 0
const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId)
const effectiveOverlayColor = hasExplicitOverlay
? `hsl(${overlayColor} / ${effectiveOverlayOpacity})`
: `hsl(var(--background) / ${effectiveOverlayOpacity})`
const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId)
return (
<div
key={layerId}
data-background-layer={layerId}
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
overflow: 'hidden',
pointerEvents: 'none',
}}
>
{config.type === 'image' && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundImage: blobUrl ? `url(${blobUrl})` : undefined,
backgroundSize: getBackgroundSize(config.effects.position),
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: filterString || undefined,
}}
/>
)}
{config.type === 'video' && blobUrl && (
<video
ref={videoRef}
src={blobUrl}
autoPlay
muted
loop
playsInline
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
width: '100%',
height: '100%',
objectFit: getObjectFit(config.effects.position),
filter: filterString || undefined,
}}
onError={() => {
if (videoRef.current) {
videoRef.current.pause()
}
}}
/>
)}
{effectiveOverlayOpacity > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
backgroundColor: effectiveOverlayColor,
pointerEvents: 'none',
}}
/>
)}
{effectiveGradientOverlay && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
background: effectiveGradientOverlay,
pointerEvents: 'none',
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useRef, useState } from 'react'
import { Link, Loader2, Trash2, Upload } from 'lucide-react'
import { useAssetStore } from '@/components/asset-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { addAsset, getAsset } from '@/lib/asset-store'
import { cn } from '@/lib/utils'
type BackgroundUploaderProps = {
assetId?: string
onAssetSelect: (id: string | undefined) => void
className?: string
disabled?: boolean
}
export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: BackgroundUploaderProps) {
const { getAssetUrl } = useAssetStore()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dragActive, setDragActive] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
const [assetType, setAssetType] = useState<'image' | 'video' | undefined>(undefined)
const [urlInput, setUrlInput] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
// 加载预览
useEffect(() => {
let active = true
const loadPreview = async () => {
if (!assetId) {
setPreviewUrl(undefined)
setAssetType(undefined)
return
}
try {
const url = await getAssetUrl(assetId)
const record = await getAsset(assetId)
if (active) {
if (url && record) {
setPreviewUrl(url)
setAssetType(record.type)
} else {
// 如果找不到资源,可能是被删除了
onAssetSelect(undefined)
}
}
} catch (err) {
console.error('Failed to load asset preview:', err)
}
}
loadPreview()
return () => {
active = false
}
}, [assetId, getAssetUrl, onAssetSelect])
const handleFile = async (file: File) => {
if (disabled) return
setError(null)
setIsLoading(true)
try {
// 验证文件类型
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
throw new Error('不支持的文件类型。请上传图片或视频。')
}
// 验证文件大小 (例如限制 50MB)
if (file.size > 50 * 1024 * 1024) {
throw new Error('文件过大。请上传小于 50MB 的文件。')
}
const id = await addAsset(file)
onAssetSelect(id)
setUrlInput('') // 清空 URL 输入框
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败')
} finally {
setIsLoading(false)
}
}
const handleUrlUpload = async () => {
if (disabled || !urlInput) return
setError(null)
setIsLoading(true)
try {
const response = await fetch(urlInput)
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`)
}
const blob = await response.blob()
// 尝试从 Content-Type 或 URL 推断文件名和类型
const contentType = response.headers.get('content-type') || ''
const urlFilename = urlInput.split('/').pop() || 'downloaded-file'
const filename = urlFilename.includes('.') ? urlFilename : `${urlFilename}.${contentType.split('/')[1] || 'bin'}`
const file = new File([blob], filename, { type: contentType })
await handleFile(file)
} catch (err) {
setError(err instanceof Error ? err.message : '从 URL 上传失败')
} finally {
setIsLoading(false)
}
}
// 拖拽处理
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (disabled) return
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0])
}
}
const handleClear = () => {
if (disabled) return
onAssetSelect(undefined)
setPreviewUrl(undefined)
setAssetType(undefined)
setError(null)
}
return (
<div className={cn("space-y-4", disabled && 'opacity-50', className)}>
<div className="grid gap-2">
<Label></Label>
{/* 预览区域 / 上传区域 */}
<div
className={cn(
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
disabled && 'pointer-events-none',
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
error ? "border-destructive/50 bg-destructive/5" : "",
assetId ? "border-solid" : ""
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{isLoading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">...</p>
</div>
) : assetId && previewUrl ? (
<div className="relative h-full w-full">
{assetType === 'video' ? (
<video
src={previewUrl}
className="h-full max-h-[300px] w-full rounded-md object-contain"
controls={false}
muted
/>
) : (
<img
src={previewUrl}
alt="Background preview"
className="h-full max-h-[300px] w-full rounded-md object-contain"
/>
)}
<div className="absolute right-2 top-2 flex gap-2">
<Button
variant="destructive"
size="icon"
className="h-8 w-8 shadow-sm"
onClick={handleClear}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="absolute bottom-2 left-2 rounded bg-black/50 px-2 py-1 text-xs text-white backdrop-blur">
{assetType === 'video' ? '视频' : '图片'}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 text-center">
<div className="rounded-full bg-muted p-4">
<Upload className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium"></p>
<p className="text-xs text-muted-foreground">
JPG, PNG, GIF, MP4, WebM
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
>
</Button>
</div>
)}
<Input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*,video/mp4,video/webm"
onChange={(e) => {
if (disabled) return
if (e.target.files?.[0]) {
handleFile(e.target.files[0])
}
// 重置 value允许重复选择同一文件
e.target.value = ''
}}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
{/* URL 上传 */}
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground"> URL </Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Link className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://example.com/image.jpg"
className="pl-9"
value={urlInput}
disabled={disabled}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUrlUpload()
}
}}
/>
</div>
<Button
variant="secondary"
onClick={handleUrlUpload}
disabled={disabled || !urlInput || isLoading}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { AlertTriangle, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CodeEditor } from '@/components/CodeEditor'
import { Label } from '@/components/ui/label'
import { sanitizeCSS } from '@/lib/theme/sanitizer'
export type ComponentCSSEditorProps = {
/** 组件唯一标识符 */
componentId: string
/** 当前 CSS 内容 */
value: string
/** CSS 内容变更回调 */
onChange: (css: string) => void
/** 编辑器标签文字 */
label?: string
/** 编辑器高度,默认 200px */
height?: string
disabled?: boolean
}
/**
* 组件级 CSS 编辑器
* 提供 CSS 代码编辑、语法高亮和安全过滤警告功能
*/
export function ComponentCSSEditor({
componentId,
value,
onChange,
label,
height = '200px',
disabled = false,
}: ComponentCSSEditorProps) {
// 实时计算 CSS 警告
const { warnings } = sanitizeCSS(value)
return (
<div className={disabled ? 'space-y-2 opacity-50' : 'space-y-2'}>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{label || '自定义 CSS'}
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => onChange('')}
disabled={disabled || !value}
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
title="清除所有 CSS"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
<div className="rounded-md border bg-card overflow-hidden">
<CodeEditor
value={value}
onChange={disabled ? undefined : onChange}
language="css"
readOnly={disabled}
height={height}
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
/>
{warnings.length > 0 && (
<div className="border-t border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-xs font-medium mb-1">
<AlertTriangle className="h-3.5 w-3.5" />
CSS
</div>
<ul className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-5 list-disc">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,462 @@
import * as React from 'react'
import * as LucideIcons from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { DynamicField } from './DynamicField'
export interface DynamicConfigFormProps {
schema: ConfigSchema
values: Record<string, unknown>
onChange: (field: string, value: unknown) => void
basePath?: string
hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层1 = section 内容层2+ = 更深嵌套 */
level?: number
advancedVisible?: boolean
sectionColumns?: 1 | 2
}
function buildFieldPath(basePath: string, fieldName: string) {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
function resolveSectionTitle(schema: ConfigSchema) {
return schema.uiLabel || schema.classDoc || schema.className
}
function SectionIcon({ iconName }: { iconName?: string }) {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
export function AdvancedSettingsButton({
active,
onClick,
}: {
active: boolean
onClick: () => void
}) {
return (
<Button
type="button"
variant={active ? 'default' : 'outline'}
size="sm"
onClick={onClick}
>
</Button>
)
}
function DynamicConfigSection({
advancedVisible,
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionKey,
sectionTitle,
values,
}: {
advancedVisible: boolean
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionKey: string
sectionTitle: string
values: Record<string, unknown>
}) {
return (
<Card className="min-w-0">
<CardHeader className="border-b border-border/50 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-lg text-primary">{sectionTitle}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={values}
onChange={(field, value) => onChange(`${sectionKey}.${field}`, value)}
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={advancedVisible}
sectionColumns={1}
/>
</CardContent>
</Card>
)
}
/**
* DynamicConfigForm - 动态配置表单组件
*
* 根据 ConfigSchema 渲染表单字段,支持:
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置
* 3. 高级设置:由栏目标题右侧按钮控制显示
*/
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema,
values,
onChange,
basePath = '',
hooks = fieldHooks,
level = 0,
advancedVisible,
sectionColumns = 1,
}) => {
const resolvedAdvancedVisible = advancedVisible ?? false
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields],
)
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
const nestedSchema = schema.nested?.[field.name]
if (hooks.has(fieldPath)) {
const hookEntry = hooks.get(fieldPath)
if (!hookEntry) return null
if (hookEntry.type === 'hidden') return null
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
onParentChange={onChange}
schema={field}
nestedSchema={nestedSchema}
parentValues={values}
/>
)
}
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
onParentChange={onChange}
schema={field}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
return (
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
)
}
const shouldRenderFieldInline = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
if (hooks.get(fieldPath)?.type === 'hidden') {
return false
}
if (!schema.nested?.[field.name]) {
return true
}
return hooks.get(fieldPath)?.type === 'replace'
}
const schemaHasVisibleContent = React.useCallback(
(targetSchema: ConfigSchema, targetBasePath: string): boolean => {
const targetFields = targetSchema.fields ?? []
const hasVisibleInlineField = targetFields.some((field) => {
const fieldPath = buildFieldPath(targetBasePath, field.name)
const hookEntry = hooks.get(fieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (targetSchema.nested?.[field.name] && hookEntry?.type !== 'replace') {
return false
}
return resolvedAdvancedVisible || !field.advanced
})
if (hasVisibleInlineField) {
return true
}
return Object.entries(targetSchema.nested ?? {}).some(([key, nestedSchema]) => {
const nestedField = targetFields.find((field) => field.name === key)
const nestedFieldPath = buildFieldPath(targetBasePath, key)
const hookEntry = hooks.get(nestedFieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (nestedField?.advanced && !resolvedAdvancedVisible) {
return false
}
if (hookEntry?.type === 'replace') {
return true
}
return schemaHasVisibleContent(nestedSchema, nestedFieldPath)
})
},
[hooks, resolvedAdvancedVisible],
)
const inlineFields = schema.fields.filter(shouldRenderFieldInline)
const inlineNestedFieldNames = new Set(
inlineFields
.filter((field) => Boolean(schema.nested?.[field.name]))
.map((field) => field.name),
)
const normalFields = inlineFields.filter((field) => !field.advanced)
const advancedFields = inlineFields.filter((field) => field.advanced)
const visibleFields = resolvedAdvancedVisible
? [...normalFields, ...advancedFields]
: normalFields
const groupFieldsByRow = (fields: FieldSchema[]) => {
const rows: FieldSchema[][] = []
let currentRow: FieldSchema[] = []
let currentRowKey: string | undefined
for (const field of fields) {
const rowKey = field['x-row']
if (rowKey && rowKey === currentRowKey) {
currentRow.push(field)
continue
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
currentRow = [field]
currentRowKey = rowKey
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
const renderRows = (rows: FieldSchema[][]) => (
<>
{rows.map((row) => (
row.length > 1 ? (
<div
key={row.map((field) => field.name).join('|')}
className="grid min-w-0 gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
style={{ '--field-row-count': row.length } as React.CSSProperties}
>
{row.map((field) => (
<div key={field.name} className="min-w-0">{renderField(field)}</div>
))}
</div>
) : (
<div key={row[0].name} className="min-w-0 py-1">{renderField(row[0])}</div>
)
))}
</>
)
const renderFieldList = (fields: FieldSchema[]) => (
<>
{groupFieldsByRow(fields).map((row, index) => (
<React.Fragment key={row.map((field) => field.name).join('|')}>
{index > 0 && <Separator className="my-2 bg-border/50" />}
{renderRows([row])}
</React.Fragment>
))}
</>
)
return (
<div className="min-w-0 space-y-6">
{visibleFields.length > 0 && (
<div>
{renderFieldList(visibleFields)}
</div>
)}
{schema.nested &&
(() => {
const nestedSections = Object.entries(schema.nested)
.filter(([key]) => !inlineNestedFieldNames.has(key))
.map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(basePath, key)
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
if (hookEntry.type === 'hidden') return null
if (nestedField?.advanced && !resolvedAdvancedVisible) return null
if (
hookEntry.type !== 'replace' &&
nestedSchema &&
!schemaHasVisibleContent(nestedSchema, nestedFieldPath)
) {
return null
}
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
onParentChange={onChange}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
/>
</div>
)
}
return (
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
onParentChange={onChange}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</HookComponent>
</div>
)
}
const sectionTitle = resolveSectionTitle(nestedSchema)
if (!schemaHasVisibleContent(nestedSchema, nestedFieldPath)) {
return null
}
if (level === 0) {
return (
<DynamicConfigSection
key={key}
advancedVisible={resolvedAdvancedVisible}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
sectionKey={key}
sectionTitle={sectionTitle}
/>
)
}
return (
<Card key={key} className="min-w-0 border-border/70 bg-muted/20 shadow-none">
<CardHeader className="border-b border-border/50 px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-sm text-primary">{sectionTitle}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</CardContent>
</Card>
)
})
const visibleNestedSections = nestedSections.filter(
(section): section is React.ReactElement => Boolean(section),
)
if (level === 0 && sectionColumns === 2 && visibleNestedSections.length > 1) {
return (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
{visibleNestedSections}
</div>
)
}
return visibleNestedSections
})()}
</div>
)
}

View File

@@ -0,0 +1,487 @@
import * as React from "react"
import * as LucideIcons from "lucide-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import { KeyValueEditor } from "@/components/ui/key-value-editor"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { resolveFieldLabel } from "@/lib/config-label"
import type { FieldSchema } from "@/types/config-schema"
export interface DynamicFieldProps {
schema: FieldSchema
value: unknown
onChange: (value: unknown) => void
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fieldPath?: string // 用于 Hook 系统(未来使用)
}
/**
* DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件
*
* 渲染逻辑:
* 1. x-widget 优先:如果 schema 有 x-widget使用对应组件
* 2. type 回退:如果没有 x-widget根据 type 选择默认组件
*/
export const DynamicField: React.FC<DynamicFieldProps> = ({
schema,
value,
onChange,
}) => {
const { i18n } = useTranslation()
const fieldLabel = resolveFieldLabel(schema, i18n.language)
const isNumericField = schema.type === 'integer' || schema.type === 'number'
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue
}
if (typeof rawValue === 'string') {
const parsedValue = parseFloat(rawValue)
if (Number.isFinite(parsedValue)) {
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
}
}
if (fallbackValue !== rawValue) {
return parseNumericValue(fallbackValue, 0)
}
return 0
}
const renderPrimitiveArrayEditor = () => {
const itemType = schema.items?.type ?? 'string'
const arrayValue = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: []
const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n')
return (
<Textarea
value={textareaValue}
onChange={(e) => {
const nextItems = e.target.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
if (itemType === 'integer') {
return parseInt(line, 10) || 0
}
if (itemType === 'number') {
return parseFloat(line) || 0
}
if (itemType === 'boolean') {
return line === 'true'
}
return line
})
onChange(nextItems)
}}
rows={Math.max(4, arrayValue.length || 4)}
/>
)
}
const renderObjectEditor = () => {
const objectValue =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
return (
<KeyValueEditor
value={objectValue}
onChange={onChange}
/>
)
}
/**
* 渲染字段图标
*/
const renderIcon = () => {
if (!schema['x-icon']) return null
const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined
if (!IconComponent) return null
return <IconComponent className="h-4 w-4" />
}
const optionDescriptions = schema['x-option-descriptions'] ?? {}
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
const descriptionDisplay = schema['x-description-display'] ?? 'label-hover'
const fieldDescription = schema.description
const inlineDescription = descriptionDisplay === 'inline' && !hasOptionDescriptions ? fieldDescription : ''
const renderDescriptionTooltip = (trigger: React.ReactElement, side: 'top' | 'right' = 'top') => {
if (!fieldDescription) return trigger
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
{trigger}
</TooltipTrigger>
<TooltipContent
side={side}
align="start"
className="max-w-80 whitespace-pre-line bg-background text-foreground border shadow-lg"
>
{fieldDescription}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const renderFieldHeader = () => (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{(() => {
const label = (
<Label
className={cn(
"inline-flex min-w-0 items-center gap-1.5 text-[15px] leading-6",
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
schema.advanced
? "text-sky-700 dark:text-sky-300"
: "text-foreground",
)}
>
{renderIcon()}
<span className="break-words">{fieldLabel}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
)
return descriptionDisplay === 'label-hover'
? renderDescriptionTooltip(label)
: label
})()}
{descriptionDisplay === 'icon' && fieldDescription && (
renderDescriptionTooltip(
<button
type="button"
aria-label={`${fieldLabel} 说明`}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LucideIcons.CircleAlert className="h-4 w-4" />
</button>,
'right',
)
)}
{inlineDescription && (
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
{inlineDescription}
</span>
)}
</div>
)
/**
* 根据 x-widget 或 type 选择并渲染对应的输入组件
*/
const renderInputComponent = () => {
const widget = schema['x-widget']
const type = schema.type
const resolvedWidget =
isNumericField && (widget === 'input' || widget === 'number' || !widget)
? 'number'
: widget
// x-widget 优先
if (resolvedWidget) {
switch (resolvedWidget) {
case 'slider':
return renderSlider()
case 'input':
return renderTextInput()
case 'number':
return renderNumberInput()
case 'password':
return renderTextInput('password')
case 'switch':
return renderSwitch()
case 'textarea':
return renderTextarea()
case 'select':
return renderSelect()
case 'custom':
if (type === 'array' && schema.items && schema.items.type !== 'object') {
return renderPrimitiveArrayEditor()
}
if (type === 'object') {
return renderObjectEditor()
}
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Custom field requires Hook
</div>
)
default:
// 未知的 x-widget回退到 type
break
}
}
// type 回退
switch (type) {
case 'boolean':
return renderSwitch()
case 'number':
case 'integer':
return renderNumberInput()
case 'string':
return renderTextInput()
case 'select':
return renderSelect()
case 'array':
if (!schema.items || schema.items.type === 'object') {
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Complex array requires Hook
</div>
)
}
return renderPrimitiveArrayEditor()
case 'object':
return renderObjectEditor()
case 'textarea':
return renderTextarea()
default:
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Unknown field type: {type}
</div>
)
}
}
/**
* 渲染 Switch 组件(用于 boolean 类型)
* 使用水平布局:标签+描述在左,开关在右
*/
const renderSwitch = () => {
const checked = Boolean(value)
return (
<div className="flex min-w-0 items-center justify-between gap-4 py-2">
<div className="min-w-0 pr-4">
{renderFieldHeader()}
</div>
<Switch
checked={checked}
onCheckedChange={(checked) => onChange(checked)}
/>
</div>
)
}
/**
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider
*/
const renderSlider = () => {
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue ?? 0
const max = schema.maxValue ?? 100
const step = schema.step ?? 1
return (
<div className="min-w-0 space-y-2">
<Slider
value={[numValue]}
onValueChange={(values) => onChange(values[0])}
min={min}
max={max}
step={step}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{min}</span>
<span className="font-medium text-foreground">{numValue}</span>
<span>{max}</span>
</div>
</div>
)
}
/**
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
*/
const renderNumberInput = () => {
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue
const max = schema.maxValue
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
return (
<Input
type="number"
value={numValue}
onChange={(e) => {
const nextValue = schema.type === 'integer'
? parseInt(e.target.value, 10)
: parseFloat(e.target.value)
onChange(Number.isFinite(nextValue) ? nextValue : 0)
}}
min={min}
max={max}
step={step}
/>
)
}
/**
* 渲染 Input[type="text"] 组件(用于 string 类型)
*/
const renderTextInput = (type: 'password' | 'text' = 'text') => {
const strValue =
typeof value === 'string'
? value
: value === null || value === undefined
? String(schema.default ?? '')
: String(value)
return (
<Input
type={type}
value={strValue}
onChange={(e) => onChange(e.target.value)}
/>
)
}
/**
* 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea
*/
const renderTextarea = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
? schema['x-textarea-min-height']
: undefined
const rows = typeof schema['x-textarea-rows'] === 'number'
? schema['x-textarea-rows']
: 4
return (
<Textarea
value={strValue}
onChange={(e) => onChange(e.target.value)}
rows={rows}
minHeight={minHeight}
/>
)
}
/**
* 渲染 Select 组件(用于 select 类型或 x-widget: select
*/
const renderSelect = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const options = schema.options ?? []
if (options.length === 0) {
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
No options available for select
</div>
)
}
return (
<Select value={strValue} onValueChange={(val) => onChange(val)}>
<SelectTrigger>
<SelectValue placeholder={`Select ${fieldLabel}`} />
</SelectTrigger>
<SelectContent>
{hasOptionDescriptions ? (
<TooltipProvider delayDuration={150}>
{options.map((option) => {
const description = optionDescriptions[option]
return description ? (
<Tooltip key={option}>
<TooltipTrigger asChild>
<SelectItem value={option} title={description}>
{option}
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="max-w-72 bg-background text-foreground border shadow-lg"
>
{description}
</TooltipContent>
</Tooltip>
) : (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
)
})}
</TooltipProvider>
) : (
options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))
)}
</SelectContent>
</Select>
)
}
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
const isBoolean =
schema['x-widget'] === 'switch' ||
(!schema['x-widget'] && schema.type === 'boolean')
const supportsInlineRight =
schema['x-layout'] === 'inline-right' &&
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
['string', 'number', 'integer', 'select'].includes(schema.type)
// Switch/Boolean 字段自带完整布局,直接返回
if (isBoolean) {
return renderInputComponent()
}
if (supportsInlineRight) {
return (
<div
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
>
<div className="min-w-0 sm:shrink-0">
{renderFieldHeader()}
</div>
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
{renderInputComponent()}
</div>
</div>
)
}
return (
<div className="min-w-0 space-y-2">
{renderFieldHeader()}
{/* Input component */}
{renderInputComponent()}
</div>
)
}

View File

@@ -0,0 +1,126 @@
# Dynamic Config Form System
## Overview
The Dynamic Config Form system is a schema-driven UI component designed to automatically generate configuration forms based on backend Pydantic models. It supports rich metadata for UI customization and a flexible Hook system for complex fields.
### Core Components
- **DynamicConfigForm**: The main component that takes a `ConfigSchema` and renders the entire form.
- **DynamicField**: A lower-level component that renders individual fields based on their type and UI metadata.
- **FieldHookRegistry**: A registry for custom React components that can replace or wrap default field rendering.
## Quick Start
To use the dynamic form in your page:
```typescript
import { DynamicConfigForm } from '@/components/dynamic-form'
import { fieldHooks } from '@/lib/field-hooks'
// Example usage in a component
export function ConfigPage() {
const [config, setConfig] = useState({})
const schema = useConfigSchema() // Fetch from API
const handleChange = (fieldPath: string, value: unknown) => {
// fieldPath can be nested, e.g., 'section.subfield'
updateConfigAt(fieldPath, value)
}
return (
<DynamicConfigForm
schema={schema}
values={config}
onChange={handleChange}
hooks={fieldHooks}
/>
)
}
```
## Adding UI Metadata (Backend)
You can customize how fields are rendered by adding `json_schema_extra` to your Pydantic `Field` definitions.
### Supported Metadata
- `x-widget`: Specifies the UI component to use.
- `slider`: A range slider (requires `ge`, `le`, and `step`).
- `switch`: A toggle switch (for booleans).
- `textarea`: A multi-line text input.
- `select`: A dropdown menu (for `Literal` or enum types).
- `custom`: Indicates that this field requires a Hook for rendering.
- `x-icon`: A Lucide icon name (e.g., `MessageSquare`, `Settings`).
- `step`: Incremental step for sliders or number inputs.
### Example
```python
class ChatConfig(ConfigBase):
talk_value: float = Field(
default=0.5,
ge=0.0,
le=1.0,
json_schema_extra={
"x-widget": "slider",
"x-icon": "MessageSquare",
"step": 0.1
}
)
```
## Creating Hook Components
Hooks allow you to provide custom UI for complex configuration sections or fields.
### FieldHookComponent Interface
A Hook component receives the following props:
- `fieldPath`: The full path to the field.
- `value`: The current value of the field/section.
- `onChange`: Callback to update the value.
- `children`: (Only for `wrapper` hooks) The default field renderer.
### Implementation Example
```typescript
import type { FieldHookComponent } from '@/lib/field-hooks'
export const CustomSectionHook: FieldHookComponent = ({
fieldPath,
value,
onChange
}) => {
return (
<div className="custom-section">
<h3>Custom UI</h3>
<input
value={value.some_prop}
onChange={(e) => onChange({ ...value, some_prop: e.target.value })}
/>
</div>
)
}
```
### Registering Hooks
Register hooks in your component's lifecycle:
```typescript
useEffect(() => {
fieldHooks.register('chat', ChatSectionHook, 'replace')
return () => fieldHooks.unregister('chat')
}, [])
```
## API Reference
### DynamicConfigForm
| Prop | Type | Description |
|------|------|-------------|
| `schema` | `ConfigSchema` | The schema generated by the backend. |
| `values` | `Record<string, any>` | Current configuration values. |
| `onChange` | `(field: string, value: any) => void` | Change handler. |
| `hooks` | `FieldHookRegistry` | Optional custom hook registry. |
### FieldHookRegistry
- `register(path, component, type)`: Register a hook.
- `get(path)`: Retrieve a registered hook.
- `has(path)`: Check if a hook exists.
- `unregister(path)`: Remove a hook.
## Troubleshooting
- **Hook not rendering**: Ensure the registration path matches the schema field name exactly (e.g., `chat` vs `Chat`).
- **Field missing**: Check if the field is present in the `ConfigSchema` returned by the backend.
- **TypeScript errors**: Ensure your Hook implements the `FieldHookComponent` type.

View File

@@ -0,0 +1,427 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicConfigForm } from '../DynamicConfigForm'
import { FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema } from '@/types/config-schema'
import type { FieldHookComponentProps } from '@/lib/field-hooks'
describe('DynamicConfigForm', () => {
describe('basic rendering', () => {
it('renders simple fields', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'field1',
type: 'string',
label: 'Field 1',
description: 'First field',
required: false,
default: 'value1',
},
{
name: 'field2',
type: 'boolean',
label: 'Field 2',
description: 'Second field',
required: false,
default: false,
},
],
}
const values = { field1: 'value1', field2: false }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Field 1')).toBeInTheDocument()
expect(screen.getByText('Field 2')).toBeInTheDocument()
expect(screen.getByText('First field')).toBeInTheDocument()
expect(screen.getByText('Second field')).toBeInTheDocument()
})
it('renders nested schema', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [
{
name: 'top_field',
type: 'string',
label: 'Top Field',
description: 'Top level field',
required: false,
},
],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'number',
label: 'Nested Field',
description: 'Nested field',
required: false,
default: 42,
},
],
},
},
}
const values = {
top_field: 'top',
sub_config: {
nested_field: 42,
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Top Field')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
})
describe('Hook system', () => {
it('renders Hook component in replace mode', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value }) => {
return <div data-testid="hook-component">Hook: {fieldPath} = {String(value)}</div>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A field with hook',
required: false,
},
{
name: 'normal_field',
type: 'string',
label: 'Normal Field',
description: 'A normal field',
required: false,
},
],
}
const values = { hooked_field: 'test', normal_field: 'normal' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('hook-component')).toBeInTheDocument()
expect(screen.getByText('Hook: hooked_field = test')).toBeInTheDocument()
expect(screen.queryByText('Hooked Field')).not.toBeInTheDocument()
expect(screen.getByText('Normal Field')).toBeInTheDocument()
})
it('renders Hook component in wrapper mode', () => {
const WrapperHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, children }) => {
return (
<div data-testid="wrapper-hook">
<div>Wrapper for: {fieldPath}</div>
{children}
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('wrapped_field', WrapperHookComponent, 'wrapper')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'wrapped_field',
type: 'string',
label: 'Wrapped Field',
description: 'A wrapped field',
required: false,
},
],
}
const values = { wrapped_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('wrapper-hook')).toBeInTheDocument()
expect(screen.getByText('Wrapper for: wrapped_field')).toBeInTheDocument()
expect(screen.getByText('Wrapped Field')).toBeInTheDocument()
})
it('passes correct props to Hook component', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value, onChange }) => {
return (
<div>
<div data-testid="field-path">{fieldPath}</div>
<div data-testid="field-value">{String(value)}</div>
<button onClick={() => onChange?.('new_value')}>Change</button>
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('test_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'original' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('field-path')).toHaveTextContent('test_field')
expect(screen.getByTestId('field-value')).toHaveTextContent('original')
})
})
describe('onChange propagation', () => {
it('propagates onChange from simple field', async () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: '' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange.mock.calls.every(call => call[0] === 'test_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'test_field', 'H')
expect(onChange).toHaveBeenNthCalledWith(5, 'test_field', 'o')
})
it('propagates onChange from nested field with correct path', async () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {
sub_config: {
nested_field: '',
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Test')
expect(onChange).toHaveBeenCalledTimes(4)
expect(onChange.mock.calls.every(call => call[0] === 'sub_config.nested_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'sub_config.nested_field', 'T')
expect(onChange).toHaveBeenNthCalledWith(4, 'sub_config.nested_field', 't')
})
it('propagates onChange from Hook component', async () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ onChange }) => {
return <button onClick={() => onChange?.('hook_value')}>Set Value</button>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A hooked field',
required: false,
},
],
}
const values = { hooked_field: '' }
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
})
it('renders nested Hook component with full field path', async () => {
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
return (
<button onClick={() => onChange?.([{ enabled: true }])}>
{fieldPath}
</button>
)
}
const hooks = new FieldHookRegistry()
hooks.register('mcp.servers', NestedHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'RootConfig',
classDoc: 'Root configuration',
fields: [],
nested: {
mcp: {
className: 'MCPConfig',
classDoc: 'MCP 配置',
fields: [
{
name: 'enable',
type: 'boolean',
label: '启用 MCP',
description: '是否启用 MCP',
required: false,
},
{
name: 'servers',
type: 'array',
label: '服务器列表',
description: '复杂对象数组',
required: false,
items: {
type: 'object',
},
},
],
nested: {
servers: {
className: 'MCPServerItemConfig',
classDoc: 'MCP 服务器项',
fields: [],
},
},
},
},
}
const values = {
mcp: {
enable: true,
servers: [],
},
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
})
})
describe('edge cases', () => {
it('renders with empty nested values', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
it('uses default hook registry when not provided', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Test Field')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,475 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicField } from '../DynamicField'
import type { FieldSchema } from '@/types/config-schema'
describe('DynamicField', () => {
describe('x-widget priority', () => {
it('renders Slider when x-widget is slider', () => {
const schema: FieldSchema = {
name: 'test_slider',
type: 'number',
label: 'Test Slider',
description: 'A test slider',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 100,
default: 50,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={50} onChange={onChange} />)
expect(screen.getByText('Test Slider')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
})
it('renders Switch when x-widget is switch', () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
'x-widget': 'switch',
default: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
expect(screen.getByText('Test Switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('renders Textarea when x-widget is textarea', () => {
const schema: FieldSchema = {
name: 'test_textarea',
type: 'string',
label: 'Test Textarea',
description: 'A test textarea',
required: false,
'x-widget': 'textarea',
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByText('Test Textarea')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Select when x-widget is select', () => {
const schema: FieldSchema = {
name: 'test_select',
type: 'string',
label: 'Test Select',
description: 'A test select',
required: false,
'x-widget': 'select',
options: ['Option 1', 'Option 2', 'Option 3'],
default: 'Option 1',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Option 1" onChange={onChange} />)
expect(screen.getByText('Test Select')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for custom widget', () => {
const schema: FieldSchema = {
name: 'test_custom',
type: 'string',
label: 'Test Custom',
description: 'A test custom field',
required: false,
'x-widget': 'custom',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
it('renders number Input when x-widget is input but type is integer', () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget',
type: 'integer',
label: 'Test Integer Input Widget',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(2)
})
it('parses string values for numeric input widgets', () => {
const schema: FieldSchema = {
name: 'test_string_number_input_widget',
type: 'integer',
label: 'Test String Number Input Widget',
description: 'A numeric field with legacy string value',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
expect(screen.getByRole('spinbutton')).toHaveValue(2)
})
})
describe('type fallback', () => {
it('renders Input for string type', () => {
const schema: FieldSchema = {
name: 'test_string',
type: 'string',
label: 'Test String',
description: 'A test string',
required: false,
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Switch for boolean type', () => {
const schema: FieldSchema = {
name: 'test_bool',
type: 'boolean',
label: 'Test Boolean',
description: 'A test boolean',
required: false,
default: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={true} onChange={onChange} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('renders number Input for number type', () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 42,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={42} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(42)
})
it('renders number Input for integer type', () => {
const schema: FieldSchema = {
name: 'test_integer',
type: 'integer',
label: 'Test Integer',
description: 'A test integer',
required: false,
default: 10,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={10} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(10)
})
it('renders Textarea for textarea type', () => {
const schema: FieldSchema = {
name: 'test_textarea_type',
type: 'textarea',
label: 'Test Textarea Type',
description: 'A test textarea type',
required: false,
default: 'Long text',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Long text" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Long text')
})
it('renders Select for select type', () => {
const schema: FieldSchema = {
name: 'test_select_type',
type: 'select',
label: 'Test Select Type',
description: 'A test select type',
required: false,
options: ['A', 'B', 'C'],
default: 'A',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="A" onChange={onChange} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders textarea editor for primitive array type', () => {
const schema: FieldSchema = {
name: 'test_array',
type: 'array',
label: 'Test Array',
description: 'A test array',
required: false,
items: {
type: 'string',
},
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
})
it('renders key-value editor for object type', () => {
const schema: FieldSchema = {
name: 'test_object',
type: 'object',
label: 'Test Object',
description: 'A test object',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={{ foo: 'bar' }} onChange={onChange} />)
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
expect(screen.getByDisplayValue('foo')).toBeInTheDocument()
})
})
describe('onChange events', () => {
it('triggers onChange for Switch', async () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
default: false,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith(true)
})
it('triggers onChange for Input', async () => {
const schema: FieldSchema = {
name: 'test_input',
type: 'string',
label: 'Test Input',
description: 'A test input',
required: false,
default: '',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange).toHaveBeenNthCalledWith(1, 'H')
expect(onChange).toHaveBeenNthCalledWith(2, 'e')
expect(onChange).toHaveBeenNthCalledWith(3, 'l')
expect(onChange).toHaveBeenNthCalledWith(4, 'l')
expect(onChange).toHaveBeenNthCalledWith(5, 'o')
})
it('triggers onChange for number Input', async () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
it('triggers numeric onChange for input widget with integer type', async () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget_change',
type: 'integer',
label: 'Test Integer Input Widget Change',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '5')
expect(onChange).toHaveBeenLastCalledWith(5)
})
})
describe('visual features', () => {
it('renders label with icon', () => {
const schema: FieldSchema = {
name: 'test_icon',
type: 'string',
label: 'Test Icon',
description: 'A test with icon',
required: false,
'x-icon': 'Settings',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Test Icon')).toBeInTheDocument()
})
it('renders required indicator', () => {
const schema: FieldSchema = {
name: 'test_required',
type: 'string',
label: 'Test Required',
description: 'A required field',
required: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders description', () => {
const schema: FieldSchema = {
name: 'test_desc',
type: 'string',
label: 'Test Description',
description: 'This is a description',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('This is a description')).toBeInTheDocument()
})
})
describe('slider features', () => {
it('renders slider with min/max/step', () => {
const schema: FieldSchema = {
name: 'test_slider_props',
type: 'number',
label: 'Test Slider Props',
description: 'A slider with props',
required: false,
'x-widget': 'slider',
minValue: 10,
maxValue: 50,
step: 5,
default: 25,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={25} onChange={onChange} />)
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('parses string values for slider widgets', () => {
const schema: FieldSchema = {
name: 'test_slider_string_value',
type: 'number',
label: 'Test Slider String Value',
description: 'A slider with legacy string value',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 10,
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
expect(screen.getByText('2.5')).toBeInTheDocument()
})
})
describe('select features', () => {
it('renders placeholder when no options', () => {
const schema: FieldSchema = {
name: 'test_select_no_options',
type: 'string',
label: 'Test Select No Options',
description: 'A select with no options',
required: false,
'x-widget': 'select',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('No options available for select')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,2 @@
export { DynamicConfigForm } from './DynamicConfigForm'
export { DynamicField } from './DynamicField'

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { Check, Loader2, Pencil, Plus, Server, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useBackendConnections } from '@/hooks/useBackendConnections'
import { isElectron } from '@/lib/runtime'
import type { BackendConnection } from '@/types/electron'
export interface BackendManagerProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
const {
activeId,
addBackend,
backends,
loading,
removeBackend,
switchBackend,
updateBackend,
} = useBackendConnections()
const [editConn, setEditConn] = useState<Partial<BackendConnection> | null>(null)
const [deleteConn, setDeleteConn] = useState<BackendConnection | null>(null)
if (!isElectron()) return null
const handleSave = async () => {
if (!editConn?.name || !editConn?.url) return
const urlPattern = /^https?:\/\//
if (!urlPattern.test(editConn.url)) return
if (editConn.id) {
await updateBackend(editConn.id, editConn)
} else {
await addBackend({
name: editConn.name,
url: editConn.url,
isDefault: editConn.isDefault ?? false,
})
}
setEditConn(null)
}
const handleDelete = async () => {
if (!deleteConn) return
if (deleteConn.id === activeId) return
await removeBackend(deleteConn.id)
setDeleteConn(null)
}
const handleSwitch = async (id: string) => {
if (id === activeId) return
await switchBackend(id)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-106.25">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<DialogBody className="pr-4">
<div className="flex flex-col gap-3 py-4">
{backends.map((backend) => {
const isActive = backend.id === activeId
return (
<div
key={backend.id}
className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-border'
}`}
>
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<div className="shrink-0">
{isActive ? (
<Check className="h-5 w-5 text-blue-500" />
) : (
<div className="h-3 w-3 rounded-full bg-muted-foreground/30 ml-1" title="未知状态" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium leading-none">
{backend.name}
</span>
<span className="truncate text-xs text-muted-foreground mt-1">
{backend.url}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{!isActive && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSwitch(backend.id)}
title="切换到此后端"
>
<Server className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditConn(backend)}
title="编辑"
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeleteConn(backend)}
disabled={isActive}
title={isActive ? '无法删除活跃后端' : '删除'}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
)
})}
</div>
</DialogBody>
)}
<div className="flex justify-end pt-4 border-t">
<Button
className="w-full"
onClick={() => setEditConn({ name: '', url: 'http://', isDefault: false })}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit/Add Dialog */}
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
<DialogContent className="sm:max-w-106.25" confirmOnEnter>
<DialogHeader>
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={editConn?.name || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
placeholder="我的服务器"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<Input
id="url"
value={editConn?.url || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null))
}
placeholder="http://192.168.1.100:8001"
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setEditConn(null)}>
</Button>
<Button
onClick={handleSave}
disabled={
!editConn?.name ||
!editConn?.url ||
!/^https?:\/\//.test(editConn.url)
}
data-dialog-action="confirm"
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteConn} onOpenChange={(open) => !open && setDeleteConn(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteConn?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,256 @@
import { useState } from 'react'
import {
ArrowRight,
Bot,
CheckCircle2,
Loader2,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { isElectron } from '@/lib/runtime'
interface BackendSetupWizardProps {
open: boolean
}
type TestStatus = 'idle' | 'loading' | 'success' | 'error'
/**
* First-launch backend setup wizard for Electron environment.
* Full-screen modal that guides users to configure their first backend connection.
* Cannot be dismissed until configuration is complete.
*/
export function BackendSetupWizard({ open }: BackendSetupWizardProps) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [testStatus, setTestStatus] = useState<TestStatus>('idle')
const [testError, setTestError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Validation errors
const [nameError, setNameError] = useState('')
const [urlError, setUrlError] = useState('')
// Only render in Electron environment
if (!isElectron()) {
return null
}
if (!open) {
return null
}
const validateName = (value: string): boolean => {
if (!value.trim()) {
setNameError('后端名称不能为空')
return false
}
setNameError('')
return true
}
const validateUrl = (value: string): boolean => {
if (!value.trim()) {
setUrlError('后端地址不能为空')
return false
}
if (!/^https?:\/\/.+/.test(value)) {
setUrlError('地址必须以 http:// 或 https:// 开头')
return false
}
if (value.endsWith('/')) {
setUrlError('地址末尾不能包含 /')
return false
}
setUrlError('')
return true
}
const handleTestConnection = async () => {
if (!validateUrl(url)) return
setTestStatus('loading')
setTestError('')
try {
const response = await fetch(`${url}/api/webui/system/health`, {
method: 'GET',
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
setTestStatus('success')
} else {
setTestStatus('error')
setTestError(`服务器返回状态码 ${response.status}`)
}
} catch (err) {
setTestStatus('error')
if (err instanceof DOMException && err.name === 'TimeoutError') {
setTestError('连接超时,请检查地址是否正确')
} else if (err instanceof TypeError) {
setTestError('无法连接到服务器,请检查地址和网络')
} else {
setTestError(err instanceof Error ? err.message : '未知错误')
}
}
}
const handleFinish = async () => {
const isNameValid = validateName(name)
const isUrlValid = validateUrl(url)
if (!isNameValid || !isUrlValid) return
setIsSubmitting(true)
try {
const newBackend = await window.electronAPI!.addBackend({
name: name.trim(),
url: url.trim(),
isDefault: true,
})
await window.electronAPI!.setActiveBackend(newBackend.id)
await window.electronAPI!.markFirstLaunchComplete()
window.location.reload()
} catch (err) {
setIsSubmitting(false)
setTestStatus('error')
setTestError(
err instanceof Error ? err.message : '保存配置失败,请重试'
)
}
}
const isFormValid = name.trim() !== '' && /^https?:\/\/.+/.test(url) && !url.endsWith('/')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
<Card className="relative z-10 max-w-md w-full mx-4 shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">使 MaiBot</CardTitle>
<CardDescription>
使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Backend name field */}
<div className="space-y-2">
<Label htmlFor="backend-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-name"
placeholder="例如:本地服务器"
value={name}
onChange={(e) => {
setName(e.target.value)
if (nameError) validateName(e.target.value)
}}
onBlur={() => validateName(name)}
/>
{nameError && (
<p className="text-sm text-destructive">{nameError}</p>
)}
</div>
{/* Backend URL field */}
<div className="space-y-2">
<Label htmlFor="backend-url">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-url"
placeholder="例如http://192.168.1.100:8001"
value={url}
onChange={(e) => {
setUrl(e.target.value)
if (urlError) validateUrl(e.target.value)
// Reset test status when URL changes
if (testStatus !== 'idle') {
setTestStatus('idle')
setTestError('')
}
}}
onBlur={() => validateUrl(url)}
/>
{urlError && (
<p className="text-sm text-destructive">{urlError}</p>
)}
</div>
{/* Test connection */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testStatus === 'loading' || !url.trim()}
className="w-full"
>
{testStatus === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
'测试连接'
)}
</Button>
{testStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
</div>
)}
{testStatus === 'error' && (
<div className="flex items-start gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{testError || '无法连接'}</span>
</div>
)}
</div>
{/* Submit button */}
<Button
onClick={handleFinish}
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
使
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { Copy, Minus, Square, X } from 'lucide-react'
import { useMemo } from 'react'
import { useWindowControls } from '@/hooks/useWindowControls'
import { getPlatform, isElectron } from '@/lib/runtime'
const dragStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties & { WebkitAppRegion: string }
const noDragStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion: string }
export function TitleBar() {
const { close, isMaximized, minimize, toggleMaximize } = useWindowControls()
const isMac = useMemo(() => getPlatform() === 'darwin', [])
if (!isElectron()) return null
return (
<div
className={`flex items-center justify-between border-b border-border bg-background select-none ${isMac ? 'h-7' : 'h-8'}`}
style={dragStyle}
>
{/* macOS traffic light padding */}
{isMac && <div className="h-full w-[78px]" style={noDragStyle} />}
{/* Title / Drag area */}
<div className="flex flex-1 items-center justify-center text-xs font-semibold text-foreground/80">
MaiBot
</div>
{/* Windows / Linux Controls */}
{!isMac && (
<div className="flex h-full items-center" style={noDragStyle}>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={minimize}
tabIndex={-1}
type="button"
aria-label="最小化"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={toggleMaximize}
tabIndex={-1}
type="button"
aria-label={isMaximized ? "还原窗口" : "最大化"}
>
{isMaximized ? (
<Copy className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-destructive hover:text-destructive-foreground"
onClick={close}
tabIndex={-1}
type="button"
aria-label="关闭窗口"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
/**
* 表情包缩略图组件
*
* 特性:
* - 自动处理 202 响应(缩略图生成中)
* - 显示 Skeleton 占位符
* - 自动重试加载
* - 加载失败显示占位图标
*/
import { useState, useEffect, useCallback } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { ImageIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface EmojiThumbnailProps {
src: string
alt?: string
className?: string
/** 最大重试次数 */
maxRetries?: number
/** 重试间隔(毫秒) */
retryInterval?: number
}
type LoadingState = 'loading' | 'loaded' | 'generating' | 'error'
export function EmojiThumbnail({
src,
alt = '表情包',
className,
maxRetries = 5,
retryInterval = 1500,
}: EmojiThumbnailProps) {
const [state, setState] = useState<LoadingState>('loading')
const [retryCount, setRetryCount] = useState(0)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [currentSrc, setCurrentSrc] = useState(src)
// 当 src 变化时重置状态
if (src !== currentSrc) {
setState('loading')
setRetryCount(0)
setImageSrc(null)
setCurrentSrc(src)
}
const loadImage = useCallback(async () => {
try {
const response = await fetch(src, {
credentials: 'include', // 携带 Cookie
})
if (response.status === 202) {
// 缩略图正在生成中
setState('generating')
if (retryCount < maxRetries) {
// 延迟后重试
setTimeout(() => {
setRetryCount(prev => prev + 1)
}, retryInterval)
} else {
// 超过最大重试次数,显示错误
setState('error')
}
return
}
if (!response.ok) {
setState('error')
return
}
// 成功获取图片
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
setImageSrc(objectUrl)
setState('loaded')
} catch (error) {
console.error('加载缩略图失败:', error)
setState('error')
}
}, [src, retryCount, maxRetries, retryInterval])
useEffect(() => {
loadImage()
}, [loadImage])
// 清理 Object URL
useEffect(() => {
return () => {
if (imageSrc) {
URL.revokeObjectURL(imageSrc)
}
}
}, [imageSrc])
// 加载中或生成中显示 Skeleton
if (state === 'loading' || state === 'generating') {
return (
<Skeleton className={cn('w-full h-full', className)} />
)
}
// 加载失败显示占位图标
if (state === 'error' || !imageSrc) {
return (
<div className={cn('w-full h-full flex items-center justify-center bg-muted', className)}>
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)
}
// 加载成功显示图片
return (
<img
src={imageSrc}
alt={alt}
className={cn('w-full h-full object-contain', className)}
/>
)
}

View File

@@ -0,0 +1,310 @@
import { Component } from 'react'
import { useTranslation } from 'react-i18next'
import type { ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useState } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
// 解析堆栈信息为结构化数据
interface StackFrame {
functionName: string
fileName: string
lineNumber: string
columnNumber: string
raw: string
}
function parseStackTrace(stack: string): StackFrame[] {
const lines = stack.split('\n').slice(1) // 跳过第一行(错误消息)
const frames: StackFrame[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('at ')) continue
// 匹配格式: at functionName (fileName:line:column) 或 at fileName:line:column
const match = trimmed.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/)
if (match) {
frames.push({
functionName: match[1] || '<anonymous>',
fileName: match[2],
lineNumber: match[3],
columnNumber: match[4],
raw: trimmed,
})
} else {
frames.push({
functionName: '<unknown>',
fileName: '',
lineNumber: '',
columnNumber: '',
raw: trimmed,
})
}
}
return frames
}
// 错误详情展示组件(函数组件,用于使用 hooks
function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo | null }) {
const [isStackOpen, setIsStackOpen] = useState(true)
const [isComponentStackOpen, setIsComponentStackOpen] = useState(false)
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const stackFrames = error.stack ? parseStackTrace(error.stack) : []
const copyErrorInfo = async () => {
const errorText = `
Error: ${error.name}
Message: ${error.message}
Stack Trace:
${error.stack || 'No stack trace available'}
Component Stack:
${errorInfo?.componentStack || 'No component stack available'}
URL: ${window.location.href}
User Agent: ${navigator.userAgent}
Time: ${new Date().toISOString()}
`.trim()
try {
await navigator.clipboard.writeText(errorText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<div className="space-y-4">
{/* 错误消息 */}
<Alert variant="destructive" className="border-red-500/50 bg-red-500/10">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="font-mono text-sm">
<span className="font-semibold">{error.name}:</span> {error.message}
</AlertDescription>
</Alert>
{/* 堆栈跟踪 */}
{stackFrames.length > 0 && (
<Collapsible open={isStackOpen} onOpenChange={setIsStackOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
<span className="font-semibold text-sm flex items-center gap-2">
<Bug className="h-4 w-4" />
Stack Trace ({stackFrames.length} frames)
</span>
{isStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[280px] rounded-md border bg-muted/30">
<div className="p-3 space-y-1">
{stackFrames.map((frame, index) => (
<div
key={index}
className="font-mono text-xs p-2 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-6 text-right flex-shrink-0">
{index + 1}.
</span>
<div className="flex-1 min-w-0">
<span className="text-primary font-medium">
{frame.functionName}
</span>
{frame.fileName && (
<div className="text-muted-foreground mt-0.5 break-all">
{frame.fileName}
{frame.lineNumber && (
<span className="text-yellow-600 dark:text-yellow-400">
:{frame.lineNumber}:{frame.columnNumber}
</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
)}
{/* 组件堆栈 */}
{errorInfo?.componentStack && (
<Collapsible open={isComponentStackOpen} onOpenChange={setIsComponentStackOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
<span className="font-semibold text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Component Stack
</span>
{isComponentStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[200px] rounded-md border bg-muted/30">
<pre className="p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground">
{errorInfo.componentStack}
</pre>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
)}
{/* 复制按钮 */}
<Button
variant="outline"
size="sm"
onClick={copyErrorInfo}
className="w-full"
>
{copied ? (
<>
<Check className="mr-2 h-4 w-4 text-green-500" />
{t('errorBoundary.copiedToClipboard')}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
{t('errorBoundary.copyError')}
</>
)}
</Button>
</div>
)
}
// 错误回退 UI
function ErrorFallback({
error,
errorInfo,
}: {
error: Error
errorInfo: ErrorInfo | null
}) {
const { t } = useTranslation()
const handleGoHome = () => {
window.location.href = '/'
}
const handleRefresh = () => {
window.location.reload()
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-2xl shadow-lg">
<CardHeader className="text-center pb-2">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<CardTitle className="text-2xl font-bold">{t('errorBoundary.title')}</CardTitle>
<CardDescription className="text-base mt-2">
{t('errorBoundary.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ErrorDetails error={error} errorInfo={errorInfo} />
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-2 pt-2">
<Button onClick={handleRefresh} className="flex-1">
<RefreshCw className="mr-2 h-4 w-4" />
{t('errorBoundary.refreshPage')}
</Button>
<Button onClick={handleGoHome} variant="outline" className="flex-1">
<Home className="mr-2 h-4 w-4" />
{t('errorBoundary.goHome')}
</Button>
</div>
{/* 提示信息 */}
<p className="text-xs text-center text-muted-foreground pt-2">
{t('errorBoundary.footer')}
</p>
</CardContent>
</Card>
</div>
)
}
// 错误边界类组件
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
this.setState({ errorInfo })
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
})
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<ErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
/>
)
}
return this.props.children
}
}
// 路由级别的错误边界组件(用于 TanStack Router
export function RouteErrorBoundary({ error }: { error: Error }) {
return (
<ErrorFallback
error={error}
errorInfo={null}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* HTTP 警告横幅组件
* 当用户通过 HTTP 访问时显示安全警告
*/
export function HttpWarningBanner() {
const { t } = useTranslation()
// 直接计算初始状态,避免 effect 中调用 setState
const isHttp = window.location.protocol === 'http:'
const hostname = window.location.hostname.toLowerCase()
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
const dismissed = sessionStorage.getItem('http-warning-dismissed') === 'true'
// 本地访问localhost/127.0.0.1)不显示警告
const [isVisible, setIsVisible] = useState(isHttp && !isLocalhost && !dismissed)
const [isDismissed, setIsDismissed] = useState(false)
const handleDismiss = () => {
setIsDismissed(true)
setIsVisible(false)
sessionStorage.setItem('http-warning-dismissed', 'true')
}
if (!isVisible || isDismissed) {
return null
}
return (
<div className="relative bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-sm">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
<span className="font-semibold">{t('httpWarning.title')}</span>
{t('httpWarning.message')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
{t('httpWarning.description')}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDismiss}
className="h-8 w-8 text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 flex-shrink-0"
aria-label={t('httpWarning.dismiss')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
export { CodeEditor } from './CodeEditor'
export type { Language } from './CodeEditor'
// 重启遮罩层
export { RestartOverlay } from './restart-overlay'
// 兼容旧版本
export { RestartingOverlay } from './RestartingOverlay.legacy'
// 列表编辑器
export { ListFieldEditor } from './ListFieldEditor'
// Markdown 渲染器
export { MarkdownRenderer } from './markdown-renderer'

View File

@@ -0,0 +1,278 @@
import { Link } from '@tanstack/react-router'
import {
BookOpen,
ChevronLeft,
Globe,
LogOut,
Menu,
MessageSquare,
Moon,
Search,
Server,
SlidersHorizontal,
Sun,
} from 'lucide-react'
import { LayoutGroup, motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BackgroundLayer } from '@/components/background-layer'
import { BackendManager } from '@/components/electron/BackendManager'
import { SearchDialog } from '@/components/search-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ShortcutKbd } from '@/components/ui/kbd'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import type { WorkspaceMode } from './types'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
const LANGUAGE_NAMES: Record<(typeof LANGUAGE_CODES)[number], string> = {
zh: '中文',
en: 'English',
ja: '日本語',
ko: '한국어',
}
interface HeaderProps {
sidebarOpen: boolean
mobileMenuOpen: boolean
searchOpen: boolean
actualTheme: 'light' | 'dark'
onSidebarToggle: () => void
onMobileMenuToggle: () => void
onSearchOpenChange: (open: boolean) => void
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
workspaceMode: WorkspaceMode
}
export function Header({
sidebarOpen,
mobileMenuOpen,
searchOpen,
actualTheme,
onSidebarToggle,
onMobileMenuToggle,
onSearchOpenChange,
onThemeChange,
workspaceMode,
}: HeaderProps) {
const { t, i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh'
const { config: headerBg, inheritedFrom } = useBackground('header')
const inheritsPageBackground = inheritedFrom === 'page'
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
const [activeBackendName, setActiveBackendName] = useState<string>('')
useEffect(() => {
if (!isElectron()) return
window.electronAPI!.getActiveBackend().then((b) => {
setActiveBackendName(b?.name ?? t('header.notConnected'))
})
}, [])
const handleLogout = async () => {
await logout()
}
return (
<header
className={cn(
'sticky top-0 isolate z-10 flex h-16 min-w-0 items-center justify-between gap-2 border-b px-3 backdrop-blur-md sm:px-4',
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
<div className="relative z-10 flex min-w-0 shrink-0 items-center gap-2 sm:gap-4">
{/* 移动端菜单按钮 */}
<button
onClick={onMobileMenuToggle}
aria-label={t('a11y.closeMenu')}
aria-expanded={mobileMenuOpen}
className={cn(
'hover:bg-accent rounded-lg p-2 lg:hidden',
workspaceMode === 'chat' && 'hidden'
)}
>
<Menu className="h-5 w-5" />
</button>
{/* 桌面端侧边栏收起/展开按钮 */}
<button
onClick={onSidebarToggle}
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
aria-expanded={sidebarOpen}
className={cn(
'hover:bg-accent hidden rounded-lg p-2 lg:block',
workspaceMode === 'chat' && 'lg:hidden'
)}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
/>
</button>
</div>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-end gap-1 sm:gap-2">
{/* 工作区切换:复用 Tabs 组件 + Motion 动画指示器 */}
<LayoutGroup id="workspace-switcher">
<Tabs value={workspaceMode} aria-label={t('workspace.switcherLabel')}>
<TabsList className="bg-background/60 relative h-9 gap-0.5 border p-1 shadow-sm backdrop-blur">
<TabsTrigger
asChild
value="settings"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/">
{workspaceMode === 'settings' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.settings')}</span>
</Link>
</TabsTrigger>
<TabsTrigger
asChild
value="chat"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/chat">
{workspaceMode === 'chat' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<MessageSquare className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.chat')}</span>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</LayoutGroup>
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 后端切换按钮(仅 Electron */}
{isElectron() && (
<>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setBackendManagerOpen(true)}
title={t('header.toggleConnection')}
>
<Server className="h-4 w-4" />
<span className="text-muted-foreground hidden max-w-25 truncate text-xs sm:inline">
{activeBackendName}
</span>
</Button>
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
<div className="bg-border h-6 w-px" />
</>
)}
{/* 搜索框 */}
<button
onClick={() => onSearchOpenChange(true)}
aria-label={t('header.searchPlaceholder')}
className="bg-background/50 hover:bg-accent/50 relative hidden h-9 w-64 items-center rounded-md border pr-16 pl-9 text-left transition-colors md:flex"
>
<Search
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">{t('header.searchPlaceholder')}</span>
<ShortcutKbd
size="sm"
className="absolute top-1/2 right-2 -translate-y-1/2"
keys={['mod', 'k']}
/>
</button>
{/* 搜索对话框 */}
<SearchDialog open={searchOpen} onOpenChange={onSearchOpenChange} />
{/* 麦麦文档链接 */}
<Button
variant="ghost"
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="hidden gap-2 sm:inline-flex"
title={t('header.viewDocs')}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t('header.docs')}</span>
</Button>
{/* 语言切换 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 px-2 sm:px-3">
<Globe className="h-4 w-4" />
<span className="hidden text-xs sm:inline">
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
currentLang}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGE_CODES.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => i18nInstance.changeLanguage(code)}
className={cn(
'cursor-pointer',
currentLang.split('-')[0] === code && 'text-primary font-semibold'
)}
>
{currentLang.split('-')[0] === code && <span className="mr-2"></span>}
{LANGUAGE_NAMES[code]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 主题切换按钮 */}
<button
onClick={(e) => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
toggleThemeWithTransition(newTheme, onThemeChange, e)
}}
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
className="hover:bg-accent rounded-lg p-2"
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* 分隔线 */}
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 登出按钮 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2 px-2 sm:px-3"
title={t('header.logout')}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">{t('header.logoutLabel')}</span>
</Button>
</div>
</header>
)
}

View File

@@ -0,0 +1,239 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion } from 'motion/react'
import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top'
import { HttpWarningBanner } from '@/components/http-warning-banner'
import { SkipNav } from '@/components/ui/skip-nav'
import { useAnnounce } from '@/components/ui/announcer'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { matchesShortcut } from '@/lib/keyboard'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { menuSections } from './constants'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types'
export function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态
const router = useRouter()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const announce = useAnnounce()
const workspaceMode = pathname.startsWith('/chat') ? 'chat' : 'settings'
const isChatWorkspace = workspaceMode === 'chat'
const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
const { theme, setTheme } = useTheme()
// 侧边栏状态变化时,延迟启用/禁用 tooltip
useEffect(() => {
if (sidebarOpen) {
// 侧边栏展开时,立即禁用 tooltip
setTooltipsEnabled(false)
} else {
// 侧边栏收起时,等待动画完成后再启用 tooltip
const timer = setTimeout(() => {
setTooltipsEnabled(true)
}, 350) // 稍大于 CSS transition duration (300ms)
return () => clearTimeout(timer)
}
}, [sidebarOpen])
// 搜索快捷键监听Cmd/Ctrl + K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (matchesShortcut(e, ['mod', 'k'])) {
e.preventDefault()
setSearchOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新
useEffect(() => {
// 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译)
const pathToLabel: Record<string, string> = {}
for (const section of menuSections) {
for (const item of section.items) {
pathToLabel[item.path] = t(item.label)
}
}
pathToLabel['/chat'] = t('workspace.chat')
return router.subscribe('onResolved', () => {
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
const fullTitle =
pageTitle === 'MaiBot Dashboard' ? 'MaiBot Dashboard' : `${pageTitle} — MaiBot Dashboard`
// 更新 document.title
document.title = fullTitle
// 屏幕阅读器朗读导航结果
announce(t('a11y.navigatedTo', { page: pageTitle }), 'polite')
// 将焦点移到主内容区(仅当焦点不在其内部时)
const mainEl = document.getElementById('main-content')
if (mainEl && !mainEl.contains(document.activeElement)) {
// requestAnimationFrame 确保 DOM 已渲染完成
requestAnimationFrame(() => {
mainEl.focus({ preventScroll: true })
})
}
})
}, [router, announce, t])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
const { config: pageBg } = useBackground('page')
// 认证检查中,显示加载状态
if (checking) {
return (
<div className="bg-background flex h-screen items-center justify-center">
<div className="text-muted-foreground">{t('layout.verifyingLogin')}</div>
</div>
)
}
return (
<TooltipProvider delayDuration={300}>
<SkipNav />
{isElectron() && <TitleBar />}
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
<BackgroundLayer config={pageBg} layerId="page" />
<div className="relative z-10 flex h-full w-full overflow-hidden">
{/* Sidebar仅在设置工作区显示伴随滑入/滑出动画 */}
<AnimatePresence initial={false}>
{!isChatWorkspace && (
<motion.div
key="settings-sidebar"
className="relative z-40 hidden shrink-0 lg:block"
initial={{ width: 0, opacity: 0 }}
animate={{ width: sidebarOpen ? 208 : 64, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 320,
damping: 36,
mass: 0.7,
opacity: { duration: 0.2 },
}}
style={{ overflow: 'hidden' }}
>
<Sidebar
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* 移动端 Sidebar 走自己的 fixed 定位,通过 mobileMenuOpen 控制显隐 */}
{!isChatWorkspace && (
<div className="lg:hidden">
<Sidebar
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
/>
</div>
)}
{/* Mobile overlay */}
<AnimatePresence>
{!isChatWorkspace && mobileMenuOpen && (
<motion.div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={() => setMobileMenuOpen(false)}
/>
)}
</AnimatePresence>
{/* Main content */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
{/* Topbar */}
<Header
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
searchOpen={searchOpen}
actualTheme={actualTheme}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
onSearchOpenChange={setSearchOpen}
onThemeChange={setTheme}
workspaceMode={workspaceMode}
/>
{/* Page content */}
<main
id="main-content"
tabIndex={-1}
className={cn(
'relative isolate flex-1 overflow-hidden outline-none',
isChatWorkspace
? 'bg-transparent'
: pageBg.type === 'none'
? 'bg-background'
: 'bg-transparent'
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={workspaceMode}
className="relative z-10 h-full min-w-0"
initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }}
transition={{
type: 'spring',
stiffness: 320,
damping: 34,
mass: 0.7,
opacity: { duration: 0.18 },
filter: { duration: 0.22 },
}}
>
{children}
</motion.div>
</AnimatePresence>
</main>
{/* Back to Top Button */}
{!isChatWorkspace && <BackToTop />}
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
import { cn } from '@/lib/utils'
import { APP_VERSION, formatVersion } from '@/lib/version'
interface LogoAreaProps {
sidebarOpen: boolean
}
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
const [versionStatus, setVersionStatus] = useState<DashboardVersionStatus | null>(null)
useEffect(() => {
let mounted = true
const loadVersionStatus = async () => {
try {
const status = await getDashboardVersionStatus(APP_VERSION)
if (mounted) {
setVersionStatus(status)
}
} catch (error) {
console.debug('检查 WebUI 版本更新失败:', error)
}
}
void loadVersionStatus()
return () => {
mounted = false
}
}, [])
const hasUpdate = versionStatus?.has_update === true && Boolean(versionStatus.latest_version)
return (
<div className="flex h-20 items-center border-b px-4">
<div
className={cn(
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
'lg:flex-1',
!sidebarOpen && 'lg:flex-none lg:w-8'
)}
>
{/* 移动端始终显示完整 Logo桌面端根据 sidebarOpen 切换 */}
<div className={cn(
"flex min-w-0 flex-col items-start justify-center gap-1",
!sidebarOpen && "lg:hidden"
)}>
<span className="max-w-full truncate whitespace-nowrap text-xl font-bold text-primary-gradient">
MaiBot WebUI
</span>
<div className="flex max-w-full items-center gap-2 overflow-hidden">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold text-primary/70">
{formatVersion()}
</span>
{hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 min-w-0 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
<span className="truncate"> v{versionStatus?.latest_version}</span>
</a>
)}
</div>
{false && hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
v{versionStatus?.latest_version}
</a>
)}
<div className="hidden">
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
<span className="text-base font-semibold text-primary/70 whitespace-nowrap">
{formatVersion()}
</span>
</div>
</div>
{/* 折叠时的 Logo - 仅桌面端显示 */}
{!sidebarOpen && (
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { Link, useMatchRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { MenuItem } from './types'
interface NavItemProps {
item: MenuItem
sidebarOpen: boolean
tooltipsEnabled: boolean
onMobileMenuClose: () => void
}
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
const { t } = useTranslation()
const matchRoute = useMatchRoute()
const isActive = matchRoute({ to: item.path })
const Icon = item.icon
const menuItemContent = (
<>
{/* 左侧高亮条 */}
{isActive && (
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
)}
<div className={cn(
'flex items-center transition-all duration-300',
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
)}>
<Icon
className={cn(
'h-5 w-5 flex-shrink-0',
isActive && 'text-primary'
)}
strokeWidth={2}
fill="none"
/>
<span className={cn(
'text-sm font-medium whitespace-nowrap transition-all duration-300',
isActive && 'font-semibold',
sidebarOpen
? 'opacity-100 max-w-[200px]'
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
)}>
{t(item.label)}
</span>
</div>
</>
)
return (
<li className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Link
to={item.path}
data-tour={item.tourId}
className={cn(
'relative flex items-center rounded-lg py-2 transition-all duration-300',
'hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground',
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
)}
onClick={onMobileMenuClose}
>
{menuItemContent}
</Link>
</TooltipTrigger>
{tooltipsEnabled && (
<TooltipContent side="right" className="hidden lg:block">
<p>{t(item.label)}</p>
</TooltipContent>
)}
</Tooltip>
</li>
)
}

View File

@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { useBackground } from '@/hooks/use-background'
import { BackgroundLayer } from '@/components/background-layer'
import { LogoArea } from './LogoArea'
import { NavItem } from './NavItem'
import { menuSections } from './constants'
interface SidebarProps {
sidebarOpen: boolean
mobileMenuOpen: boolean
tooltipsEnabled: boolean
onMobileMenuClose: () => void
}
export function Sidebar({
sidebarOpen,
mobileMenuOpen,
tooltipsEnabled,
onMobileMenuClose
}: SidebarProps) {
const { t } = useTranslation()
const { config: sidebarBg, inheritedFrom } = useBackground('sidebar')
const inheritsPageBackground = inheritedFrom === 'page'
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0 lg:h-full',
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
'w-52 lg:w-auto',
sidebarOpen ? 'lg:w-52' : 'lg:w-16',
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={sidebarBg} layerId="sidebar" />}
{/* Logo 区域 */}
<div className="relative z-10">
<LogoArea sidebarOpen={sidebarOpen} />
</div>
<ScrollArea className={cn(
'relative z-10',
"min-h-0 flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16"
)}
viewportClassName="[&>div]:!block"
>
<nav
aria-label={t('a11y.sidebarNav')}
className={cn(
"p-4",
!sidebarOpen && "lg:p-2 lg:w-16"
)}>
<ul className={cn(
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
"space-y-6",
!sidebarOpen && "lg:space-y-3 lg:w-full"
)}>
{menuSections.map((section, sectionIndex) => (
<li key={section.title}>
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
<div className={cn(
"px-3 h-[1.25rem]",
// 移动端始终显示,桌面端根据状态切换
"mb-2",
!sidebarOpen && "lg:mb-1 lg:invisible"
)}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
{t(section.title)}
</h3>
</div>
{/* 分割线 - 仅在桌面端折叠时显示 */}
{!sidebarOpen && sectionIndex > 0 && (
<div className="hidden lg:block mb-2 border-t border-border" />
)}
{/* 菜单项列表 */}
<ul className="space-y-1">
{section.items.map((item) => (
<NavItem
key={item.path}
item={item}
sidebarOpen={sidebarOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={onMobileMenuClose}
/>
))}
</ul>
</li>
))}
</ul>
</nav>
</ScrollArea>
</aside>
)
}

View File

@@ -0,0 +1,46 @@
import { Activity, Boxes, BrainCircuit, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react'
import type { MenuSection } from './types'
export const menuSections: MenuSection[] = [
{
title: 'sidebar.groups.overview',
items: [
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
],
},
{
title: 'sidebar.groups.botConfig',
items: [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
],
},
{
title: 'sidebar.groups.botResources',
items: [
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
],
},
{
title: 'sidebar.groups.extensionsMonitor',
items: [
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: Network, label: 'sidebar.menu.mcpSettings', path: '/mcp-settings' },
],
},
{
title: 'sidebar.groups.system',
items: [
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: BrainCircuit, label: 'sidebar.menu.reasoningProcess', path: '/reasoning-process', searchDescription: 'search.items.reasoningProcessDesc' },
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
],
},
]

View File

@@ -0,0 +1,2 @@
export { Layout } from './Layout'
export type { LayoutProps, MenuItem, MenuSection } from './types'

View File

@@ -0,0 +1,21 @@
import type { ComponentType, ReactNode } from 'react'
import type { LucideProps } from 'lucide-react'
export interface LayoutProps {
children: ReactNode
}
export type WorkspaceMode = 'settings' | 'chat'
export interface MenuItem {
icon: ComponentType<LucideProps>
label: string
path: string
searchDescription?: string
tourId?: string
}
export interface MenuSection {
title: string
items: MenuItem[]
}

View File

@@ -0,0 +1,134 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import type { ComponentPropsWithoutRef } from 'react'
interface MarkdownRendererProps {
content: string
className?: string
}
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// 自定义代码块样式
code({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { inline?: boolean }) {
return inline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
) : (
<code className={`${className} block bg-muted p-4 rounded-lg overflow-x-auto`} {...props}>
{children}
</code>
)
},
// 自定义表格样式
table({ children, ...props }) {
return (
<div className="overflow-x-auto">
<table className="border-collapse border border-border" {...props}>
{children}
</table>
</div>
)
},
th({ children, ...props }) {
return (
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold" {...props}>
{children}
</th>
)
},
td({ children, ...props }) {
return (
<td className="border border-border px-4 py-2" {...props}>
{children}
</td>
)
},
// 自定义链接样式
a({ children, ...props }) {
return (
<a className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
)
},
// 自定义引用块样式
blockquote({ children, ...props }) {
return (
<blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground" {...props}>
{children}
</blockquote>
)
},
// 自定义标题样式
h1({ children, ...props }) {
return (
<h1 className="text-3xl font-bold mt-6 mb-4" {...props}>
{children}
</h1>
)
},
h2({ children, ...props }) {
return (
<h2 className="text-2xl font-bold mt-5 mb-3" {...props}>
{children}
</h2>
)
},
h3({ children, ...props }) {
return (
<h3 className="text-xl font-bold mt-4 mb-2" {...props}>
{children}
</h3>
)
},
h4({ children, ...props }) {
return (
<h4 className="text-lg font-semibold mt-3 mb-2" {...props}>
{children}
</h4>
)
},
// 自定义列表样式
ul({ children, ...props }) {
return (
<ul className="list-disc list-inside space-y-1 my-2" {...props}>
{children}
</ul>
)
},
ol({ children, ...props }) {
return (
<ol className="list-decimal list-inside space-y-1 my-2" {...props}>
{children}
</ol>
)
},
// 自定义段落样式
p({ children, ...props }) {
return (
<p className="my-2 leading-relaxed" {...props}>
{children}
</p>
)
},
// 自定义分隔线样式
hr({ ...props }) {
return <hr className="my-4 border-border" {...props} />
},
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useMemo, useState } from 'react'
import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api'
interface MemoryConfigEditorProps {
schema: PluginConfigSchema
config: Record<string, unknown>
onChange: (nextConfig: Record<string, unknown>) => void
disabled?: boolean
}
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
const parts = path.split('.').filter(Boolean)
let current: unknown = config
for (const part of parts) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined
}
return current as Record<string, unknown>
}
function setNestedField(
config: Record<string, unknown>,
path: string,
fieldName: string,
value: unknown,
): Record<string, unknown> {
const parts = path.split('.').filter(Boolean)
const nextConfig: Record<string, unknown> = { ...config }
let target = nextConfig
let source: Record<string, unknown> | undefined = config
for (const part of parts) {
const sourceValue: unknown = source?.[part]
const nextValue =
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
? { ...(sourceValue as Record<string, unknown>) }
: {}
target[part] = nextValue
target = nextValue
source =
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
? (sourceValue as Record<string, unknown>)
: undefined
}
target[fieldName] = value
return nextConfig
}
function FieldRenderer({
field,
value,
onChange,
disabled,
}: {
field: ConfigFieldSchema
value: unknown
onChange: (value: unknown) => void
disabled?: boolean
}) {
const [jsonDraft, setJsonDraft] = useState(
typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2),
)
switch (field.ui_type) {
case 'switch':
return (
<div className="flex items-center justify-between rounded-lg border bg-background px-4 py-3">
<div className="space-y-1">
<Label>{field.label}</Label>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
<Switch
checked={Boolean(value ?? field.default)}
onCheckedChange={onChange}
disabled={disabled || field.disabled}
/>
</div>
)
case 'number':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
type="number"
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(Number(event.target.value))}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={disabled || field.disabled}
placeholder={field.placeholder}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'select':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Select
value={String(value ?? field.default ?? '')}
onValueChange={onChange}
disabled={disabled || field.disabled}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{(field.choices ?? []).map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'textarea':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Textarea
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(event.target.value)}
rows={field.rows ?? 4}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'list':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<ListFieldEditor
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
onChange={onChange as (value: unknown[]) => void}
itemType={field.item_type}
itemFields={field.item_fields}
minItems={field.min_items}
maxItems={field.max_items}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'json':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Textarea
value={jsonDraft}
rows={field.rows ?? 6}
disabled={disabled || field.disabled}
onChange={(event) => {
const nextValue = event.target.value
setJsonDraft(nextValue)
try {
onChange(JSON.parse(nextValue))
} catch {
// keep draft until valid JSON
}
}}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
default:
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(event.target.value)}
disabled={disabled || field.disabled}
placeholder={field.placeholder}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
}
}
function SectionCard({
sectionName,
schema,
config,
onChange,
disabled,
}: {
sectionName: string
schema: PluginConfigSchema
config: Record<string, unknown>
onChange: (nextConfig: Record<string, unknown>) => void
disabled?: boolean
}) {
const section = schema.sections[sectionName]
if (!section) {
return null
}
const sectionValues = getNestedRecord(config, sectionName) ?? {}
const orderedFields = Object.values(section.fields).sort((left, right) => left.order - right.order)
return (
<Card>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
{section.description && <CardDescription>{section.description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-4">
{orderedFields.map((field) => (
<FieldRenderer
key={`${sectionName}.${field.name}`}
field={field}
value={sectionValues[field.name]}
disabled={disabled}
onChange={(value) => onChange(setNestedField(config, sectionName, field.name, value))}
/>
))}
</CardContent>
</Card>
)
}
export function MemoryConfigEditor({ schema, config, onChange, disabled }: MemoryConfigEditorProps) {
const tabs = useMemo(
() => [...(schema.layout.tabs ?? [])].sort((left, right) => left.order - right.order),
[schema.layout.tabs],
)
if (tabs.length === 0) {
const orderedSections = Object.keys(schema.sections).sort(
(left, right) => (schema.sections[left]?.order ?? 0) - (schema.sections[right]?.order ?? 0),
)
return (
<div className="space-y-4">
{orderedSections.map((sectionName) => (
<SectionCard
key={sectionName}
sectionName={sectionName}
schema={schema}
config={config}
onChange={onChange}
disabled={disabled}
/>
))}
</div>
)
}
return (
<Tabs defaultValue={tabs[0]?.id} className="space-y-4">
<TabsList className="h-auto flex-wrap justify-start">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
{tab.sections.map((sectionName) => (
<SectionCard
key={sectionName}
sectionName={sectionName}
schema={schema}
config={config}
onChange={onChange}
disabled={disabled}
/>
))}
</TabsContent>
))}
</Tabs>
)
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, RotateCcw, Search, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import type {
MemoryDeleteExecutePayload,
MemoryDeletePreviewItemPayload,
MemoryDeletePreviewPayload,
} from '@/lib/memory-api'
const DELETE_PREVIEW_PAGE_SIZE = 8
function formatMode(mode: string): string {
switch (mode) {
case 'entity':
return '实体删除'
case 'relation':
return '关系删除'
case 'paragraph':
return '段落删除'
case 'source':
return '来源删除'
case 'mixed':
return '混合删除'
default:
return mode || '删除'
}
}
function formatCountLabel(label: string, value: number): string {
return `${label} ${value}`
}
function PreviewItemList({ items }: { items: MemoryDeletePreviewItemPayload[] }) {
if (items.length <= 0) {
return <p className="text-sm text-muted-foreground"></p>
}
return (
<div className="space-y-2">
{items.slice(0, 16).map((item) => (
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.item_type}</Badge>
{item.source ? <Badge variant="secondary">{item.source}</Badge> : null}
</div>
<div className="mt-2 text-sm font-medium break-words">{item.label || item.item_key || item.item_hash}</div>
{item.preview ? <div className="mt-1 text-xs text-muted-foreground break-words">{item.preview}</div> : null}
<code className="mt-2 block break-all text-[11px] text-muted-foreground">{item.item_hash || item.item_key}</code>
</div>
))}
</div>
)
}
interface MemoryDeleteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
preview: MemoryDeletePreviewPayload | null
result: MemoryDeleteExecutePayload | null
loadingPreview?: boolean
executing?: boolean
restoring?: boolean
error?: string | null
onExecute: () => void
onRestore?: () => void
}
export function MemoryDeleteDialog({
open,
onOpenChange,
title,
description,
preview,
result,
loadingPreview = false,
executing = false,
restoring = false,
error,
onExecute,
onRestore,
}: MemoryDeleteDialogProps) {
const [itemSearch, setItemSearch] = useState('')
const [itemPage, setItemPage] = useState(1)
const counts = preview?.counts ?? result?.counts ?? {}
const previewSources = Array.isArray(preview?.sources) ? preview.sources : []
const previewItems = Array.isArray(preview?.items) ? preview.items : []
const filteredPreviewItems = useMemo(() => {
const keyword = itemSearch.trim().toLowerCase()
if (!keyword) {
return previewItems
}
return previewItems.filter((item) =>
[
item.item_type,
item.item_hash,
item.item_key,
item.label,
item.preview,
item.source,
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(keyword)),
)
}, [itemSearch, previewItems])
const itemPageCount = Math.max(1, Math.ceil(filteredPreviewItems.length / DELETE_PREVIEW_PAGE_SIZE))
const pagedPreviewItems = useMemo(() => {
const start = (itemPage - 1) * DELETE_PREVIEW_PAGE_SIZE
return filteredPreviewItems.slice(start, start + DELETE_PREVIEW_PAGE_SIZE)
}, [filteredPreviewItems, itemPage])
const countBadges = [
{ key: 'entities', label: '实体', value: Number(counts.entities ?? 0) },
{ key: 'relations', label: '关系', value: Number(counts.relations ?? 0) },
{ key: 'paragraphs', label: '段落', value: Number(counts.paragraphs ?? 0) },
{ key: 'sources', label: '来源', value: Number(counts.sources ?? 0) },
].filter((item) => item.value > 0)
useEffect(() => {
setItemSearch('')
setItemPage(1)
}, [preview?.mode, preview?.item_count, open])
useEffect(() => {
setItemPage(1)
}, [itemSearch])
useEffect(() => {
if (itemPage > itemPageCount) {
setItemPage(itemPageCount)
}
}, [itemPage, itemPageCount])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] grid grid-rows-[auto_1fr_auto]" confirmOnEnter>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
{title}
</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogBody className="space-y-4 overflow-y-auto">
{loadingPreview ? (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">...</div>
) : null}
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{preview ? (
<>
<div className="rounded-xl border bg-muted/30 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge>{formatMode(preview.mode)}</Badge>
<Badge variant="secondary">{formatCountLabel('预览项', Number(preview.item_count ?? previewItems.length))}</Badge>
{countBadges.map((item) => (
<Badge key={item.key} variant="outline">
{formatCountLabel(item.label, item.value)}
</Badge>
))}
</div>
{previewSources.length > 0 ? (
<div className="mt-3 text-sm text-muted-foreground break-words">
{previewSources.join('、')}
</div>
) : null}
{preview.matched_source_count ? (
<div className="mt-2 text-xs text-muted-foreground">
{preview.matched_source_count}
{preview.requested_source_count ? ` / 请求来源 ${preview.requested_source_count}` : ''}
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold"></div>
<div className="text-xs text-muted-foreground">
{filteredPreviewItems.length} / {previewItems.length}
</div>
</div>
<div className="flex flex-col gap-2 md:min-w-[300px]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={itemSearch}
onChange={(event) => setItemSearch(event.target.value)}
placeholder="搜索类型 / hash / item_key / source"
className="pl-8"
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span> {itemPage} / {itemPageCount} </span>
<span> {DELETE_PREVIEW_PAGE_SIZE} </span>
</div>
</div>
</div>
<ScrollArea className="h-[320px] rounded-lg border bg-background/60">
<div className="p-3">
<PreviewItemList items={pagedPreviewItems} />
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setItemPage((current) => Math.max(1, current - 1))}
disabled={itemPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
hashitem_keysource
</div>
<Button
variant="outline"
size="sm"
onClick={() => setItemPage((current) => Math.min(itemPageCount, current + 1))}
disabled={itemPage >= itemPageCount}
>
</Button>
</div>
</div>
</>
) : null}
{result?.success ? (
<Alert>
<AlertDescription className="space-y-1">
<div> ID<code>{result.operation_id}</code></div>
<div>
{result.deleted_entity_count} {result.deleted_relation_count} {result.deleted_paragraph_count} {result.deleted_source_count}
</div>
</AlertDescription>
</Alert>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
{result?.success && onRestore ? (
<Button variant="outline" onClick={onRestore} disabled={restoring}>
<RotateCcw className="mr-2 h-4 w-4" />
{restoring ? '恢复中...' : '恢复本次删除'}
</Button>
) : null}
{!result?.success ? (
<Button data-dialog-action="confirm" variant="destructive" onClick={onExecute} disabled={loadingPreview || executing || !preview}>
<Trash2 className="mr-2 h-4 w-4" />
{executing ? '执行中...' : '确认删除'}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,518 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Loader2, Play, RefreshCw, RotateCcw, Search } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
getMemoryEpisode,
getMemoryEpisodes,
getMemoryEpisodeStatus,
processMemoryEpisodePending,
rebuildMemoryEpisodes,
type MemoryEpisodeDetailPayload,
type MemoryEpisodeItemPayload,
type MemoryEpisodeParagraphPayload,
type MemoryEpisodeStatusPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parseOptionalNumber(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function getEpisodeId(item: MemoryEpisodeItemPayload | null | undefined): string {
return String(item?.episode_id ?? item?.id ?? '')
}
function getEpisodeTitle(item: MemoryEpisodeItemPayload): string {
return String(item.title ?? item.summary ?? item.content ?? getEpisodeId(item) ?? '未命名 Episode')
}
function getEpisodeParagraphs(
item: MemoryEpisodeItemPayload | MemoryEpisodeDetailPayload['episode'] | null | undefined,
): MemoryEpisodeParagraphPayload[] {
const paragraphs = item?.paragraphs
return Array.isArray(paragraphs) ? paragraphs : []
}
function getStatusCount(status: MemoryEpisodeStatusPayload | null, key: string): number {
const counts = status?.counts
if (counts && typeof counts[key] === 'number') {
return counts[key]
}
const value = status?.[key]
return typeof value === 'number' ? value : 0
}
export function MemoryEpisodeManager() {
const { toast } = useToast()
const [query, setQuery] = useState('')
const [source, setSource] = useState('')
const [platform, setPlatform] = useState('')
const [userId, setUserId] = useState('')
const [personId, setPersonId] = useState('')
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawEpisodePayload, setShowRawEpisodePayload] = useState(false)
const [timeStart, setTimeStart] = useState('')
const [timeEnd, setTimeEnd] = useState('')
const [limit, setLimit] = useState('20')
const [items, setItems] = useState<MemoryEpisodeItemPayload[]>([])
const [status, setStatus] = useState<MemoryEpisodeStatusPayload | null>(null)
const [selectedId, setSelectedId] = useState('')
const [detail, setDetail] = useState<MemoryEpisodeDetailPayload | null>(null)
const [loading, setLoading] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [rebuildSource, setRebuildSource] = useState('')
const [rebuildSources, setRebuildSources] = useState('')
const [rebuildAll, setRebuildAll] = useState(false)
const [pendingLimit, setPendingLimit] = useState('20')
const [pendingMaxRetry, setPendingMaxRetry] = useState('3')
const initialLoadedRef = useRef(false)
const selectedEpisode = useMemo(() => detail?.episode ?? items.find((item) => getEpisodeId(item) === selectedId), [detail?.episode, items, selectedId])
const selectedEpisodeParagraphs = useMemo(() => getEpisodeParagraphs(selectedEpisode), [selectedEpisode])
const failedItems = Array.isArray(status?.failed) ? status.failed : []
const loadStatus = useCallback(async () => {
const payload = await getMemoryEpisodeStatus(parsePositiveInt(limit, 20))
setStatus(payload)
}, [limit])
const loadEpisodes = useCallback(async () => {
setLoading(true)
try {
const directPersonId = showAdvancedPersonId ? personId.trim() : ''
const [listPayload] = await Promise.all([
getMemoryEpisodes({
query: query.trim(),
source: source.trim(),
platform: platform.trim(),
userId: userId.trim(),
personId: directPersonId,
limit: parsePositiveInt(limit, 20),
timeStart: parseOptionalNumber(timeStart),
timeEnd: parseOptionalNumber(timeEnd),
}),
loadStatus(),
])
const nextItems = listPayload.items ?? []
setItems(nextItems)
if (!selectedId && nextItems.length > 0) {
setSelectedId(getEpisodeId(nextItems[0]))
}
} catch (error) {
toast({
title: '加载情节记忆失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [limit, loadStatus, personId, platform, query, selectedId, showAdvancedPersonId, source, timeEnd, timeStart, toast, userId])
const loadDetail = useCallback(async (episodeId: string) => {
if (!episodeId) {
setDetail(null)
return
}
setDetailLoading(true)
try {
const payload = await getMemoryEpisode(episodeId)
setDetail(payload)
} catch (error) {
toast({
title: '加载 Episode 详情失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setDetailLoading(false)
}
}, [toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadEpisodes()
}, [loadEpisodes])
useEffect(() => {
if (selectedId) {
void loadDetail(selectedId)
}
}, [loadDetail, selectedId])
const submitRebuild = useCallback(async () => {
if (rebuildAll && !window.confirm('确认重建全部可用来源的 Episode这个操作可能耗时较长。')) {
return
}
const sources = rebuildSources
.split(',')
.map((item) => item.trim())
.filter(Boolean)
setActionLoading(true)
try {
const payload = await rebuildMemoryEpisodes({
source: rebuildSource.trim(),
sources,
all: rebuildAll,
})
toast({
title: payload.success ? 'Episode 重建已提交' : 'Episode 重建失败',
description: String(payload.detail ?? payload.error ?? `影响来源 ${payload.rebuilt ?? 0}`),
variant: payload.success ? 'default' : 'destructive',
})
await loadEpisodes()
} catch (error) {
toast({
title: 'Episode 重建失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadEpisodes, rebuildAll, rebuildSource, rebuildSources, toast])
const submitProcessPending = useCallback(async () => {
setActionLoading(true)
try {
const payload = await processMemoryEpisodePending({
limit: parsePositiveInt(pendingLimit, 20),
max_retry: parsePositiveInt(pendingMaxRetry, 3),
})
toast({
title: payload.success ? '已处理待生成 Episode' : '处理待生成 Episode 失败',
description: String(payload.detail ?? payload.error ?? `已处理 ${payload.processed ?? 0}`),
variant: payload.success ? 'default' : 'destructive',
})
await loadEpisodes()
} catch (error) {
toast({
title: '处理待生成 Episode 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadEpisodes, pendingLimit, pendingMaxRetry, toast])
return (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-4">
{[
{ label: '待处理队列', value: Number(status?.pending_queue ?? 0) },
{ label: '待重建', value: getStatusCount(status, 'pending') },
{ label: '运行中', value: getStatusCount(status, 'running') },
{ label: '失败来源', value: failedItems.length || getStatusCount(status, 'failed') },
].map((item) => (
<Card key={item.label}>
<CardHeader className="pb-3">
<CardDescription>{item.label}</CardDescription>
<CardTitle className="text-2xl">{item.value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription>person_id </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-platform"></Label>
<Input
id="episode-platform"
value={platform}
onChange={(event) => setPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-user-id"></Label>
<Input id="episode-user-id" value={userId} onChange={(event) => setUserId(event.target.value)} placeholder="输入平台侧 user_id" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-query"></Label>
<Input id="episode-query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索摘要或内容" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-source"></Label>
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
</div>
<div className="space-y-2">
<Label htmlFor="episode-limit"></Label>
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-start"></Label>
<Input id="episode-time-start" value={timeStart} onChange={(event) => setTimeStart(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-end"></Label>
<Input id="episode-time-end" value={timeEnd} onChange={(event) => setTimeEnd(event.target.value)} placeholder="可选" />
</div>
</div>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="episode-person">person_id</Label>
<Input
id="episode-person"
value={personId}
onChange={(event) => setPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
<Button onClick={() => void loadEpisodes()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
Episode
</Button>
<ScrollArea className="h-[420px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead>Episode</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? items.map((item) => {
const episodeId = getEpisodeId(item)
return (
<TableRow
key={episodeId || getEpisodeTitle(item)}
className={cn('cursor-pointer', selectedId === episodeId && 'bg-muted/60')}
onClick={() => setSelectedId(episodeId)}
>
<TableCell>
<div className="max-w-[280px] truncate font-medium">{getEpisodeTitle(item)}</div>
{item.person_name || item.person_id ? (
<div className="max-w-[280px] truncate text-xs text-muted-foreground">
{String(item.person_name || item.person_id)}
{item.person_name && item.person_id ? <span className="font-mono"> · {String(item.person_id)}</span> : null}
</div>
) : null}
<div className="font-mono text-[11px] text-muted-foreground break-all">{episodeId || '-'}</div>
</TableCell>
<TableCell className="max-w-[180px] truncate">{String(item.source ?? '-')}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at ?? item.created_at)}</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载 Episode...' : '没有匹配的 Episode'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Episode </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{detailLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : selectedEpisode ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null}
{selectedEpisode.person_name ? <Badge>{String(selectedEpisode.person_name)}</Badge> : null}
{selectedEpisode.person_id ? <Badge variant="outline">{String(selectedEpisode.person_id)}</Badge> : null}
</div>
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" />
<Collapsible open={showRawEpisodePayload} onOpenChange={setShowRawEpisodePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawEpisodePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-56 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedEpisode, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
{selectedEpisodeParagraphs.length > 0 ? (
<ScrollArea className="h-[220px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{selectedEpisodeParagraphs.map((paragraph, index) => (
<div key={String(paragraph.hash ?? index)} className="rounded-lg border bg-muted/20 p-3">
<div className="font-mono text-[11px] text-muted-foreground break-all">{String(paragraph.hash ?? '-')}</div>
<div className="mt-2 text-sm break-words">{String(paragraph.preview ?? paragraph.content ?? '')}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground"> Episode </div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription> Episode </CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{failedItems.length > 0 ? (
<Alert>
<AlertDescription>
{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"> Episode</div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-rebuild-source"> ID</Label>
<Input
id="episode-rebuild-source"
value={rebuildSource}
onChange={(event) => setRebuildSource(event.target.value)}
placeholder="例如 chat_summary:test-webui:coffee"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-rebuild-sources"> ID</Label>
<Input
id="episode-rebuild-sources"
value={rebuildSources}
onChange={(event) => setRebuildSources(event.target.value)}
placeholder="用英文逗号分隔多个来源"
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
</label>
<Button onClick={() => void submitRebuild()} disabled={actionLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Episode
</Button>
</div>
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="episode-pending-limit"></Label>
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-pending-retry"></Label>
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
<Play className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Lock, RefreshCw, RotateCcw, Shield, Snowflake } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { useToast } from '@/hooks/use-toast'
import {
freezeMemory,
getMemoryRecycleBin,
protectMemory,
reinforceMemory,
restoreMaintainedMemory,
type MemoryMaintenanceActionPayload,
type MemoryMaintenanceItemPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
type MaintenanceAction = 'reinforce' | 'freeze' | 'protect' | 'restore'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function parseOptionalHours(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}
function getRelationTarget(item: MemoryMaintenanceItemPayload): string {
return String(item.hash ?? item.relation_hash ?? '')
}
function getRelationText(item: MemoryMaintenanceItemPayload): string {
const direct = String(item.text ?? '').trim()
if (direct) {
return direct
}
return [item.subject, item.predicate, item.object].map((value) => String(value ?? '').trim()).filter(Boolean).join(' ')
}
function getActionLabel(action: MaintenanceAction): string {
switch (action) {
case 'reinforce':
return '强化'
case 'freeze':
return '冻结'
case 'protect':
return '保护'
case 'restore':
return '恢复'
default:
return action
}
}
export function MemoryMaintenanceManager() {
const { toast } = useToast()
const [target, setTarget] = useState('')
const [action, setAction] = useState<MaintenanceAction>('reinforce')
const [protectHours, setProtectHours] = useState('')
const [recycleLimit, setRecycleLimit] = useState('50')
const [items, setItems] = useState<MemoryMaintenanceItemPayload[]>([])
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [itemSearch, setItemSearch] = useState('')
const initialLoadedRef = useRef(false)
const filteredItems = useMemo(() => {
const keyword = itemSearch.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[
getRelationTarget(item),
getRelationText(item),
item.source,
item.subject,
item.predicate,
item.object,
].some((value) => String(value ?? '').toLowerCase().includes(keyword)),
)
}, [itemSearch, items])
const loadRecycleBin = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryRecycleBin(parsePositiveInt(recycleLimit, 50))
setItems(payload.items ?? [])
} catch (error) {
toast({
title: '加载记忆回收站失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [recycleLimit, toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadRecycleBin()
}, [loadRecycleBin])
const runAction = useCallback(async (nextAction: MaintenanceAction, nextTarget: string) => {
const cleanTarget = nextTarget.trim()
if (!cleanTarget) {
toast({
title: '缺少维护目标',
description: '请输入关系 hash 或查询文本。',
variant: 'destructive',
})
return
}
if (nextAction === 'freeze' && !window.confirm('确认冻结命中的记忆关系?冻结后关系会从活跃图谱中移除。')) {
return
}
if (nextAction === 'restore' && !window.confirm('确认恢复命中的记忆关系?')) {
return
}
setActionLoading(true)
try {
let payload: MemoryMaintenanceActionPayload
if (nextAction === 'reinforce') {
payload = await reinforceMemory(cleanTarget)
} else if (nextAction === 'freeze') {
payload = await freezeMemory(cleanTarget)
} else if (nextAction === 'protect') {
payload = await protectMemory(cleanTarget, parseOptionalHours(protectHours))
} else {
payload = await restoreMaintainedMemory(cleanTarget)
}
toast({
title: payload.success ? `记忆${getActionLabel(nextAction)}完成` : `记忆${getActionLabel(nextAction)}失败`,
description: String(payload.detail ?? payload.error ?? ''),
variant: payload.success ? 'default' : 'destructive',
})
await loadRecycleBin()
} catch (error) {
toast({
title: `记忆${getActionLabel(nextAction)}失败`,
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadRecycleBin, protectHours, toast])
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
</CardTitle>
<CardDescription> hash </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
沿 hash
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="maintenance-target"></Label>
<Input id="maintenance-target" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="relation hash 或查询文本" />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select value={action} onValueChange={(value) => setAction(value as MaintenanceAction)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reinforce"></SelectItem>
<SelectItem value="freeze"></SelectItem>
<SelectItem value="protect"></SelectItem>
<SelectItem value="restore"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-hours"></Label>
<Input
id="maintenance-hours"
type="number"
value={protectHours}
onChange={(event) => setProtectHours(event.target.value)}
placeholder="空值表示永久保护"
disabled={action !== 'protect'}
/>
</div>
</div>
<Button onClick={() => void runAction(action, target)} disabled={actionLoading}>
{action === 'reinforce' ? <Lock className="mr-2 h-4 w-4" /> : null}
{action === 'freeze' ? <Snowflake className="mr-2 h-4 w-4" /> : null}
{action === 'protect' ? <Shield className="mr-2 h-4 w-4" /> : null}
{action === 'restore' ? <RotateCcw className="mr-2 h-4 w-4" /> : null}
{getActionLabel(action)}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_140px_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="maintenance-search"></Label>
<Input id="maintenance-search" value={itemSearch} onChange={(event) => setItemSearch(event.target.value)} placeholder="按 hash、主体、谓词、来源筛选" />
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-limit"></Label>
<Input id="maintenance-limit" type="number" value={recycleLimit} onChange={(event) => setRecycleLimit(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void loadRecycleBin()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline"> {items.length} </Badge>
<Badge variant="secondary"> {filteredItems.length} </Badge>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? filteredItems.map((item, index) => {
const rowTarget = getRelationTarget(item)
return (
<TableRow key={`${rowTarget}:${index}`}>
<TableCell>
<div className="font-medium break-words">{getRelationText(item) || '-'}</div>
<div className="mt-1 font-mono text-[11px] text-muted-foreground break-all">{rowTarget || '-'}</div>
{item.source ? <Badge variant="outline" className="mt-2">{String(item.source)}</Badge> : null}
</TableCell>
<TableCell>{formatMemoryTime(item.deleted_at ?? item.updated_at)}</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => void runAction('restore', rowTarget)}
disabled={!rowTarget || actionLoading}
>
</Button>
</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载回收站...' : '回收站没有可展示的关系'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
export interface MemoryMiniTabItem<TValue extends string> {
value: TValue
label: string
description?: string
}
export interface MemoryMiniTabsProps<TValue extends string> {
items: ReadonlyArray<MemoryMiniTabItem<TValue>>
className?: string
/** 触发器额外样式 */
triggerClassName?: string
}
/**
* 长期记忆控制台统一的迷你标签页样式。
*
* - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。
* - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。
*/
export function MemoryMiniTabs<TValue extends string>({
items,
className,
triggerClassName,
}: MemoryMiniTabsProps<TValue>) {
return (
<TabsList
className={cn(
'h-auto w-full flex-wrap justify-start gap-1.5 rounded-full border border-border/60',
'bg-gradient-to-r from-muted/40 via-background to-muted/30 p-1.5 shadow-inner',
className,
)}
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
title={item.description}
className={cn(
'rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors',
'hover:bg-background/80 hover:text-foreground',
'data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-primary/80',
'data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm',
triggerClassName,
)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
)
}

View File

@@ -0,0 +1,482 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Loader2, RefreshCw, Save, Search, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
deleteMemoryProfileOverride,
getMemoryProfiles,
queryMemoryProfile,
searchMemoryProfiles,
setMemoryProfileOverride,
type MemoryProfileItemPayload,
type MemoryProfileQueryPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function stringifyOverride(value: MemoryProfileItemPayload['manual_override']): string {
if (!value) {
return ''
}
if (typeof value === 'string') {
return value
}
const text = value.override_text ?? value.text
if (typeof text === 'string') {
return text
}
return JSON.stringify(value, null, 2)
}
function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selectedProfile: MemoryProfileItemPayload | null): string {
if (typeof queryResult?.profile_text === 'string') {
return queryResult.profile_text
}
const queryProfile = queryResult?.profile
if (queryProfile && typeof queryProfile === 'object' && typeof queryProfile.profile_text === 'string') {
return queryProfile.profile_text
}
return selectedProfile?.profile_text ?? ''
}
export function MemoryProfileManager() {
const { toast } = useToast()
const [profiles, setProfiles] = useState<MemoryProfileItemPayload[]>([])
const [profileListMode, setProfileListMode] = useState<'library' | 'search'>('library')
const [selectedPersonId, setSelectedPersonId] = useState('')
const [queryPersonId, setQueryPersonId] = useState('')
const [queryKeyword, setQueryKeyword] = useState('')
const [queryPlatform, setQueryPlatform] = useState('')
const [queryUserId, setQueryUserId] = useState('')
const [queryLimit, setQueryLimit] = useState('12')
const [forceRefresh, setForceRefresh] = useState(false)
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawProfilePayload, setShowRawProfilePayload] = useState(false)
const [overrideText, setOverrideText] = useState('')
const [queryResult, setQueryResult] = useState<MemoryProfileQueryPayload | null>(null)
const [loading, setLoading] = useState(false)
const [querying, setQuerying] = useState(false)
const [saving, setSaving] = useState(false)
const initialLoadedRef = useRef(false)
const selectedProfile = useMemo(
() => profiles.find((item) => item.person_id === selectedPersonId) ?? null,
[profiles, selectedPersonId],
)
const profileText = resolveProfileText(queryResult, selectedProfile)
const selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
const loadProfiles = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryProfiles(80)
const nextItems = payload.items ?? []
setProfiles(nextItems)
setProfileListMode('library')
if (!selectedPersonId && nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
} catch (error) {
toast({
title: '加载人物画像失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [selectedPersonId, toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadProfiles()
}, [loadProfiles])
useEffect(() => {
setOverrideText(stringifyOverride(selectedProfile?.manual_override))
}, [selectedProfile])
const submitQuery = useCallback(async () => {
const directPersonId = showAdvancedPersonId ? queryPersonId.trim() : ''
const cleanKeyword = queryKeyword.trim()
const cleanPlatform = queryPlatform.trim()
const cleanUserId = queryUserId.trim()
const hasAccountLocator = Boolean(cleanPlatform && cleanUserId)
if (!directPersonId && !cleanKeyword && !hasAccountLocator) {
toast({
title: '请输入查询条件',
description: '用户账号、关键词、或高级 person_id 至少填写一种。',
variant: 'destructive',
})
return
}
setQuerying(true)
try {
if (!directPersonId && !hasAccountLocator) {
const searchPayload = await searchMemoryProfiles({
personKeyword: cleanKeyword,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
setQueryResult(null)
setSelectedPersonId(nextItems[0]?.person_id ?? '')
toast({
title: '人物画像检索完成',
description: `命中 ${nextItems.length} 个画像。`,
})
return
}
const payload = await queryMemoryProfile({
personId: directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: parsePositiveInt(queryLimit, 12),
forceRefresh,
})
if (payload.success === false) {
throw new Error(String(payload.error ?? '人物画像查询失败'))
}
setQueryResult(payload)
const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? directPersonId ?? '')
const searchPayload = await searchMemoryProfiles({
personId: nextPersonId || directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
if (nextPersonId) {
setSelectedPersonId(nextPersonId)
setQueryPersonId(nextPersonId)
} else if (nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
toast({
title: '人物画像查询完成',
description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。',
})
} catch (error) {
toast({
title: '人物画像查询失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setQuerying(false)
}
}, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, toast])
const saveOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim()
if (!personId) {
toast({
title: '缺少人物 ID',
description: '请选择或输入一个 person_id 后再保存 override。',
variant: 'destructive',
})
return
}
setSaving(true)
try {
await setMemoryProfileOverride({
person_id: personId,
override_text: overrideText,
updated_by: 'knowledge_base',
source: 'webui',
})
toast({ title: '人物画像 override 已保存' })
await loadProfiles()
} catch (error) {
toast({
title: '保存人物画像 override 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [loadProfiles, overrideText, queryPersonId, selectedPersonId, toast])
const deleteOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim()
if (!personId) {
return
}
if (!window.confirm(`确认删除 ${personId} 的人物画像 override`)) {
return
}
setSaving(true)
try {
await deleteMemoryProfileOverride(personId)
setOverrideText('')
toast({ title: '人物画像 override 已删除' })
await loadProfiles()
} catch (error) {
toast({
title: '删除人物画像 override 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [loadProfiles, queryPersonId, selectedPersonId, toast])
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
</CardTitle>
<CardDescription>person_id </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-platform"></Label>
<Input
id="profile-platform"
value={queryPlatform}
onChange={(event) => setQueryPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-user-id"></Label>
<Input
id="profile-user-id"
value={queryUserId}
onChange={(event) => setQueryUserId(event.target.value)}
placeholder="输入平台侧 user_id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-keyword"></Label>
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label htmlFor="profile-limit"></Label>
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
</div>
<div className="flex items-center gap-2 self-end pb-2">
<Checkbox
id="profile-force-refresh"
checked={forceRefresh}
onCheckedChange={(value) => setForceRefresh(Boolean(value))}
/>
<Label htmlFor="profile-force-refresh" className="text-sm font-normal">
</Label>
</div>
</div>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="profile-person-id">person_id</Label>
<Input
id="profile-person-id"
value={queryPersonId}
onChange={(event) => setQueryPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
{selectedPersonId || queryPersonId ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="text-muted-foreground"> person_id</div>
<div className="mt-1 break-all font-mono text-xs">{selectedPersonId || queryPersonId}</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button onClick={() => void submitQuery()} disabled={querying}>
<Search className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="rounded-lg border bg-muted/10 px-3 py-2">
<div className="text-sm font-medium">{profileListMode === 'search' ? '检索结果' : '画像库'}</div>
<div className="mt-1 text-xs text-muted-foreground">
{profileListMode === 'search'
? '根据当前平台账号、关键词或 person_id 筛选出的画像候选。'
: '系统中已生成的最新人物画像快照,按更新时间排序。'}
</div>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles.length > 0 ? profiles.map((item) => (
<TableRow
key={item.person_id}
className={cn('cursor-pointer', selectedPersonId === item.person_id && 'bg-muted/60')}
onClick={() => setSelectedPersonId(item.person_id)}
>
<TableCell>
<div className="font-medium break-all">{item.person_name || item.person_id}</div>
{item.person_name ? <div className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{item.person_id}</div> : null}
<div className="mt-1 flex flex-wrap gap-1">
{item.has_manual_override ? <Badge variant="secondary"> override</Badge> : null}
{item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null}
</div>
</TableCell>
<TableCell>{Number(item.profile_version ?? 0)}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at)}</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{querying ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : null}
{selectedProfile || queryResult ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedPersonId || String(queryResult?.person_id ?? '未选择')}</Badge>
{selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
</div>
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" />
<Collapsible open={showRawProfilePayload} onOpenChange={setShowRawProfilePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawProfilePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-72 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Override</CardTitle>
<CardDescription> override </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!selectedPersonId && !queryPersonId.trim() ? (
<Alert>
<AlertDescription> person_id override</AlertDescription>
</Alert>
) : null}
{selectedDisplayName ? <div className="text-sm text-muted-foreground">{selectedDisplayName}</div> : null}
<Textarea
value={overrideText}
onChange={(event) => setOverrideText(event.target.value)}
className="min-h-[180px]"
placeholder="输入希望固定使用的人物画像文本"
/>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveOverride()} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
override
</Button>
<Button variant="outline" onClick={() => void deleteOverride()} disabled={saving || (!selectedPersonId && !queryPersonId.trim())}>
<Trash2 className="mr-2 h-4 w-4" />
override
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
export interface MemoryProgressIndicatorProps {
/** 0-100 之间的进度百分比 */
value: number
/** 任务状态文本(如 “运行中”、“已完成”) */
statusLabel?: string
/** 当前步骤文本(如 “分块中”) */
stepLabel?: string
/** 状态对应的语义色(用于左侧圆环和徽标) */
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted'
/** 是否显示加载动画(运行中/取消中场景) */
busy?: boolean
/** 紧凑模式:用于队列列表项 */
compact?: boolean
/** 额外说明(如 “已完成 36 / 120 分块”) */
detail?: string
className?: string
}
const TONE_RING_CLASS: Record<NonNullable<MemoryProgressIndicatorProps['tone']>, string> = {
default: 'text-primary',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-rose-500',
muted: 'text-muted-foreground',
}
const TONE_BADGE_VARIANT: Record<
NonNullable<MemoryProgressIndicatorProps['tone']>,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
default: 'default',
success: 'secondary',
warning: 'outline',
destructive: 'destructive',
muted: 'outline',
}
/**
* 长期记忆控制台统一的任务进度展示组件。
*
* 设计目标:
* - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。
* - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。
* - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。
*/
export function MemoryProgressIndicator({
value,
statusLabel,
stepLabel,
tone = 'default',
busy = false,
compact = false,
detail,
className,
}: MemoryProgressIndicatorProps) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const ringSize = compact ? 36 : 56
const ringStroke = compact ? 4 : 5
const radius = (ringSize - ringStroke) / 2
const circumference = 2 * Math.PI * radius
const dashOffset = circumference * (1 - safeValue / 100)
return (
<div className={cn('flex items-center gap-3', className)}>
<div
className={cn('relative shrink-0', TONE_RING_CLASS[tone])}
style={{ width: ringSize, height: ringSize }}
aria-hidden="true"
>
<svg width={ringSize} height={ringSize} className="-rotate-90">
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
className="stroke-muted/40"
fill="none"
/>
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
strokeLinecap="round"
stroke="currentColor"
fill="none"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
{busy ? (
<Loader2 className={cn('animate-spin', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : (
<span className={cn('font-medium tabular-nums', compact ? 'text-[10px]' : 'text-xs')}>
{Math.round(safeValue)}%
</span>
)}
</div>
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{statusLabel ? (
<Badge variant={TONE_BADGE_VARIANT[tone]} className="shrink-0">
{statusLabel}
</Badge>
) : null}
{stepLabel ? (
<span className="truncate text-xs text-muted-foreground">{stepLabel}</span>
) : null}
{!compact ? (
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{safeValue.toFixed(1)}%
</span>
) : null}
</div>
<Progress value={safeValue} className={cn(compact ? 'h-1' : 'h-1.5')} />
{detail ? <div className="truncate text-xs text-muted-foreground">{detail}</div> : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
/**
* 插件统计组件
* 显示点赞、点踩、评分和下载量
*/
import { useState, useEffect } from 'react'
import { ThumbsUp, ThumbsDown, Star, Download } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
getPluginStats,
likePlugin,
dislikePlugin,
ratePlugin,
type PluginStatsData,
} from '@/lib/plugin-stats'
interface PluginStatsProps {
pluginId: string
compact?: boolean // 紧凑模式(只显示数字)
}
export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
const [stats, setStats] = useState<PluginStatsData | null>(null)
const [loading, setLoading] = useState(true)
const [userRating, setUserRating] = useState(0)
const [userComment, setUserComment] = useState('')
const [isRatingDialogOpen, setIsRatingDialogOpen] = useState(false)
const { toast } = useToast()
// 加载统计数据
const loadStats = async () => {
setLoading(true)
const data = await getPluginStats(pluginId)
if (data) {
setStats(data)
}
setLoading(false)
}
useEffect(() => {
loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId])
// 处理点赞
const handleLike = async () => {
const result = await likePlugin(pluginId)
if (result.success) {
toast({ title: '已点赞', description: '感谢你的支持!' })
loadStats() // 重新加载统计数据
} else {
toast({
title: '点赞失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
// 处理点踩
const handleDislike = async () => {
const result = await dislikePlugin(pluginId)
if (result.success) {
toast({ title: '已反馈', description: '感谢你的反馈!' })
loadStats()
} else {
toast({
title: '操作失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
// 提交评分
const handleSubmitRating = async () => {
if (userRating === 0) {
toast({
title: '请选择评分',
description: '至少选择 1 颗星',
variant: 'destructive',
})
return
}
const result = await ratePlugin(pluginId, userRating, userComment || undefined)
if (result.success) {
toast({ title: '评分成功', description: '感谢你的评价!' })
setIsRatingDialogOpen(false)
setUserRating(0)
setUserComment('')
loadStats()
} else {
toast({
title: '评分失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
if (loading) {
return (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
<span>-</span>
</div>
<div className="flex items-center gap-1">
<Star className="h-4 w-4" />
<span>-</span>
</div>
</div>
)
}
if (!stats) {
return null
}
// 紧凑模式
if (compact) {
return (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1" title={`下载量: ${stats.downloads.toLocaleString()}`}>
<Download className="h-4 w-4" />
<span>{stats.downloads.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1" title={`评分: ${stats.rating.toFixed(1)} (${stats.rating_count} 条评价)`}>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span>{stats.rating.toFixed(1)}</span>
</div>
<div className="flex items-center gap-1" title={`点赞数: ${stats.likes}`}>
<ThumbsUp className="h-4 w-4" />
<span>{stats.likes}</span>
</div>
</div>
)
}
// 完整模式
return (
<div className="space-y-4">
{/* 统计数字 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<Download className="h-5 w-5 text-muted-foreground mb-1" />
<span className="text-2xl font-bold">{stats.downloads.toLocaleString()}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<Star className="h-5 w-5 text-yellow-400 mb-1 fill-yellow-400" />
<span className="text-2xl font-bold">{stats.rating.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">{stats.rating_count} </span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<ThumbsUp className="h-5 w-5 text-green-500 mb-1" />
<span className="text-2xl font-bold">{stats.likes}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<ThumbsDown className="h-5 w-5 text-red-500 mb-1" />
<span className="text-2xl font-bold">{stats.dislikes}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleLike}>
<ThumbsUp className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDislike}>
<ThumbsDown className="h-4 w-4 mr-1" />
</Button>
<Dialog open={isRatingDialogOpen} onOpenChange={setIsRatingDialogOpen}>
<DialogTrigger asChild>
<Button variant="default" size="sm">
<Star className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>使</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 星级评分 */}
<div className="flex flex-col items-center gap-2">
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setUserRating(star)}
className="focus:outline-none"
>
<Star
className={`h-8 w-8 transition-colors ${
star <= userRating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:text-yellow-300'
}`}
/>
</button>
))}
</div>
<span className="text-sm text-muted-foreground">
{userRating === 0 && '点击星星进行评分'}
{userRating === 1 && '很差'}
{userRating === 2 && '一般'}
{userRating === 3 && '还行'}
{userRating === 4 && '不错'}
{userRating === 5 && '非常好'}
</span>
</div>
{/* 评论 */}
<div>
<label htmlFor="plugin-rating-comment" className="text-sm font-medium mb-2 block"></label>
<Textarea
value={userComment}
id="plugin-rating-comment"
onChange={(e) => setUserComment(e.target.value)}
placeholder="分享你的使用体验..."
rows={4}
maxLength={500}
/>
<div className="text-xs text-muted-foreground mt-1 text-right">
{userComment.length} / 500
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRatingDialogOpen(false)}>
</Button>
<Button onClick={handleSubmitRating} disabled={userRating === 0}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* 最近评价 */}
{stats.recent_ratings && stats.recent_ratings.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold"></h4>
<div className="space-y-3">
{stats.recent_ratings.map((rating, index) => (
<div key={index} className="p-3 rounded-lg border bg-muted/50">
<div className="flex items-center justify-between mb-2">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-3 w-3 ${
star <= rating.rating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
{new Date(rating.created_at).toLocaleDateString()}
</span>
</div>
{rating.comment && (
<p className="text-sm text-muted-foreground">{rating.comment}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,416 @@
/**
* 重启遮罩层组件
*
* 用于显示重启进度和状态,阻止用户操作
*
* 使用方式 1: 配合 RestartProvider推荐
* <RestartProvider>
* <App />
* <RestartOverlay />
* </RestartProvider>
*
* 使用方式 2: 独立使用
* <RestartOverlay
* visible={true}
* onComplete={() => navigate('/auth')}
* />
*/
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Loader2,
CheckCircle2,
AlertCircle,
RefreshCw,
RotateCcw,
} from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useRestart, type RestartStatus, type RestartContextValue } from '@/lib/restart-context'
import { cn } from '@/lib/utils'
// Hook 用于安全获取 restart context
function useSafeRestart(): RestartContextValue | null {
try {
return useRestart()
} catch {
return null
}
}
// ============ 类型定义 ============
interface RestartOverlayProps {
/** 是否可见(仅独立模式使用) */
visible?: boolean
/** 重启完成回调 */
onComplete?: () => void
/** 重启失败回调 */
onFailed?: () => void
/** 自定义标题 */
title?: string
/** 自定义描述 */
description?: string
/** 是否显示背景动画 */
showAnimation?: boolean
/** 自定义类名 */
className?: string
}
// ============ 状态配置 ============
interface StatusConfig {
icon: React.ReactNode
title: string
description: string
tip: string
}
const getStatusConfig = (
status: RestartStatus,
checkAttempts: number,
maxAttempts: number,
t: (key: string, opts?: Record<string, unknown>) => string,
customTitle?: string,
customDescription?: string
): StatusConfig => {
const configs: Record<RestartStatus, StatusConfig> = {
idle: {
icon: null,
title: '',
description: '',
tip: '',
},
requesting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? t('restart.preparing'),
description: customDescription ?? t('restart.preparingDesc'),
tip: t('restart.preparingTip'),
},
restarting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? t('restart.restarting'),
description: customDescription ?? t('restart.restartingDesc'),
tip: t('restart.restartingTip'),
},
checking: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: t('restart.checking'),
description: t('restart.checkingDesc', { current: checkAttempts, max: maxAttempts }),
tip: t('restart.checkingTip'),
},
success: {
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
title: t('restart.success'),
description: t('restart.successDesc'),
tip: t('restart.successTip'),
},
failed: {
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
title: t('restart.failed'),
description: t('restart.failedDesc'),
tip: t('restart.failedTip'),
},
}
return configs[status]
}
// ============ 主组件(配合 Provider ============
export function RestartOverlay({
visible,
onComplete,
onFailed,
title,
description,
showAnimation = true,
className,
}: RestartOverlayProps) {
// 尝试使用 context可能不存在
const contextValue = useSafeRestart()
// 如果有 context使用 context 状态;否则使用 props
const isVisible = contextValue ? contextValue.isRestarting : visible
if (!isVisible) return null
if (contextValue) {
return (
<RestartOverlayContent
state={contextValue.state}
onRetry={contextValue.retryHealthCheck}
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// 独立模式
return (
<StandaloneRestartOverlay
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// ============ 内容组件 ============
interface RestartOverlayContentProps {
state: {
status: RestartStatus
progress: number
elapsedTime: number
checkAttempts: number
maxAttempts: number
error?: string
}
onRetry: () => void
onComplete?: () => void
onFailed?: () => void
title?: string
description?: string
showAnimation?: boolean
className?: string
}
function RestartOverlayContent({
state,
onRetry,
onComplete,
onFailed,
title,
description,
showAnimation,
className,
}: RestartOverlayContentProps) {
const { status, progress, elapsedTime, checkAttempts, maxAttempts } = state
const { t } = useTranslation()
// 回调处理
useEffect(() => {
if (status === 'success' && onComplete) {
onComplete()
} else if (status === 'failed' && onFailed) {
onFailed()
}
}, [status, onComplete, onFailed])
const config = getStatusConfig(
status,
checkAttempts,
maxAttempts,
t,
title,
description
)
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div
className={cn(
'fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center',
className
)}
>
{/* 背景动画 */}
{showAnimation && <BackgroundAnimation />}
<div className="max-w-md w-full mx-4 space-y-8 relative z-10">
{/* 图标和状态 */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{config.icon}
{/* 脉冲动画 */}
{(status === 'restarting' || status === 'checking') && (
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
)}
</div>
<h2 className="text-2xl font-bold">{config.title}</h2>
<p className="text-muted-foreground text-center">{config.description}</p>
</div>
{/* 进度条 */}
{status !== 'failed' && status !== 'idle' && (
<div className="space-y-2">
<Progress value={progress} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress}%</span>
<span>{t('restart.elapsed')} {formatTime(elapsedTime)}</span>
</div>
</div>
)}
{/* 提示信息 */}
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm text-muted-foreground">{config.tip}</p>
</div>
{/* 失败时的操作按钮 */}
{status === 'failed' && (
<div className="flex gap-2">
<Button
onClick={() => window.location.reload()}
variant="default"
className="flex-1"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('restart.refreshPage')}
</Button>
<Button onClick={onRetry} variant="secondary" className="flex-1">
<RotateCcw className="mr-2 h-4 w-4" />
{t('restart.retryCheck')}
</Button>
</div>
)}
</div>
</div>
)
}
// ============ 独立模式组件 ============
interface StandaloneRestartOverlayProps {
onComplete?: () => void
onFailed?: () => void
title?: string
description?: string
showAnimation?: boolean
className?: string
}
function StandaloneRestartOverlay({
onComplete,
onFailed,
title,
description,
showAnimation,
className,
}: StandaloneRestartOverlayProps) {
const [state, setState] = useState({
status: 'restarting' as RestartStatus,
progress: 0,
elapsedTime: 0,
checkAttempts: 0,
maxAttempts: 60,
})
const startHealthCheck = useCallback(() => {
let attempts = 0
const maxAttempts = 60
const check = async () => {
attempts++
setState((prev) => ({
...prev,
status: 'checking',
checkAttempts: attempts,
}))
try {
const response = await fetch('/api/webui/system/status', {
method: 'GET',
signal: AbortSignal.timeout(3000),
})
if (response.ok) {
setState((prev) => ({ ...prev, status: 'success', progress: 100 }))
setTimeout(() => {
onComplete?.()
window.location.href = '/auth'
}, 1500)
return
}
} catch {
// 继续重试
}
if (attempts >= maxAttempts) {
setState((prev) => ({ ...prev, status: 'failed' }))
onFailed?.()
} else {
setTimeout(check, 2000)
}
}
check()
}, [onComplete, onFailed])
useEffect(() => {
// 进度条动画
const progressInterval = setInterval(() => {
setState((prev) => ({
...prev,
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
}))
}, 200)
// 计时器
const timerInterval = setInterval(() => {
setState((prev) => ({ ...prev, elapsedTime: prev.elapsedTime + 1 }))
}, 1000)
// 3秒后开始健康检查
const initialDelay = setTimeout(() => {
startHealthCheck()
}, 3000)
return () => {
clearInterval(progressInterval)
clearInterval(timerInterval)
clearTimeout(initialDelay)
}
}, [startHealthCheck])
return (
<RestartOverlayContent
state={state}
onRetry={startHealthCheck}
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// ============ 背景动画 ============
function BackgroundAnimation() {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* 渐变圆环 */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px]">
<div className="absolute inset-0 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite]" />
<div className="absolute inset-8 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_0.5s]" />
<div className="absolute inset-16 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_1s]" />
</div>
{/* 浮动粒子 */}
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/20 rounded-full animate-bounce" />
<div className="absolute top-3/4 right-1/4 w-3 h-3 bg-primary/15 rounded-full animate-bounce delay-150" />
<div className="absolute top-1/2 right-1/3 w-2 h-2 bg-primary/20 rounded-full animate-bounce delay-300" />
</div>
)
}
// ============ 导出旧组件(兼容性) ============
// 如需使用旧版组件,请直接导入:
// import { RestartingOverlay } from '@/components/RestartingOverlay.legacy'

View File

@@ -0,0 +1,349 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { FileText, Search, SlidersHorizontal } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import type { LucideProps } from 'lucide-react'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ShortcutKbd } from '@/components/ui/kbd'
import { menuSections } from '@/components/layout/constants'
import { registeredRoutePaths } from '@/router'
import { getBotConfigSchema, getModelConfigSchema } from '@/lib/config-api'
import { getAllLocalizedText, resolveFieldLabel } from '@/lib/config-label'
import { cn } from '@/lib/utils'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface SearchItem {
id: string
icon: React.ComponentType<LucideProps>
title: string
description: string
path: string
category: string
keywords: string
}
function resolveSchemaTitle(schema: ConfigSchema, fallback: string) {
return schema.uiLabel || schema.classDoc || schema.className || fallback
}
function unwrapConfigSchema(payload: unknown): ConfigSchema | null {
if (!payload || typeof payload !== 'object') {
return null
}
if ('fields' in payload) {
return payload as ConfigSchema
}
if ('schema' in payload) {
const schema = (payload as { schema?: unknown }).schema
if (schema && typeof schema === 'object' && 'fields' in schema) {
return schema as ConfigSchema
}
}
return null
}
function getModelConfigPath(_fieldPath: string) {
return '/config/model'
}
function buildFieldSearchText(field: FieldSchema, fieldPath: string, sectionTitle: string, language?: string) {
const options = field.options?.join(' ') ?? ''
const optionDescriptions = field['x-option-descriptions']
? Object.entries(field['x-option-descriptions'])
.map(([key, value]) => `${key} ${value}`)
.join(' ')
: ''
return [
resolveFieldLabel(field, language),
...getAllLocalizedText(field.label),
field.name,
fieldPath,
field.description,
sectionTitle,
field.type,
options,
optionDescriptions,
].join(' ')
}
function collectConfigFields(
schema: ConfigSchema,
sourceLabel: string,
basePath: string,
routePath: (fieldPath: string) => string,
language?: string,
): SearchItem[] {
const items: SearchItem[] = []
const walk = (currentSchema: ConfigSchema, pathPrefix: string, sectionTrail: string[]) => {
const sectionTitle = resolveSchemaTitle(currentSchema, sourceLabel)
const nextTrail = [...sectionTrail, sectionTitle].filter(Boolean)
for (const field of currentSchema.fields) {
const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
const nestedSchema = currentSchema.nested?.[field.name]
const fieldTitle = resolveFieldLabel(field, language)
const description = field.description || nextTrail.join(' / ') || fieldPath
const fullPath = basePath ? `${basePath}.${fieldPath}` : fieldPath
const route = routePath(fullPath)
items.push({
id: `config:${sourceLabel}:${fullPath}`,
icon: sourceLabel === '模型配置' ? SlidersHorizontal : FileText,
title: fieldTitle,
description: `${sourceLabel} / ${nextTrail.join(' / ')} / ${fullPath} · ${description}`,
path: route,
category: '配置项',
keywords: buildFieldSearchText(field, fullPath, nextTrail.join(' / '), language),
})
if (nestedSchema) {
walk(nestedSchema, fieldPath, nextTrail)
}
}
}
walk(schema, '', [])
return items
}
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [configSearchItems, setConfigSearchItems] = useState<SearchItem[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const { i18n, t } = useTranslation()
useEffect(() => {
setConfigSearchItems([])
}, [i18n.language])
useEffect(() => {
if (!open) {
return
}
const frameId = window.requestAnimationFrame(() => {
inputRef.current?.focus()
})
return () => window.cancelAnimationFrame(frameId)
}, [open])
useEffect(() => {
if (!open || configSearchItems.length > 0) {
return
}
let cancelled = false
const loadConfigSearchItems = async () => {
const [botSchemaResult, modelSchemaResult] = await Promise.all([
getBotConfigSchema(),
getModelConfigSchema(),
])
if (cancelled) {
return
}
const nextItems: SearchItem[] = []
if (botSchemaResult.success) {
const botSchema = unwrapConfigSchema(botSchemaResult.data)
if (botSchema) {
nextItems.push(...collectConfigFields(
botSchema,
'Bot 配置',
'',
() => '/config/bot',
i18n.language,
))
}
}
if (modelSchemaResult.success) {
const modelSchema = unwrapConfigSchema(modelSchemaResult.data)
if (modelSchema) {
nextItems.push(...collectConfigFields(
modelSchema,
'模型配置',
'',
getModelConfigPath,
i18n.language,
))
}
}
setConfigSearchItems(nextItems)
}
loadConfigSearchItems().catch(() => {
if (!cancelled) {
setConfigSearchItems([])
}
})
return () => {
cancelled = true
}
}, [configSearchItems.length, i18n.language, open])
const searchItems: SearchItem[] = useMemo(
() =>
menuSections.flatMap((section) =>
section.items
.filter((item) => registeredRoutePaths.has(item.path))
.map((item) => ({
id: `route:${item.path}`,
icon: item.icon,
title: t(item.label),
description: item.searchDescription ? t(item.searchDescription) : item.path,
path: item.path,
category: t(section.title),
keywords: [
t(item.label),
item.path,
item.searchDescription ? t(item.searchDescription) : '',
t(section.title),
].join(' '),
}))
),
[t]
)
// 过滤搜索结果
const normalizedQuery = searchQuery.trim().toLowerCase()
const filteredItems = (normalizedQuery ? [...searchItems, ...configSearchItems] : searchItems)
.filter((item) => item.keywords.toLowerCase().includes(normalizedQuery))
.slice(0, 80)
// 导航到页面
const handleNavigate = useCallback((path: string) => {
navigate({ to: path })
onOpenChange(false)
// 在导航后重置状态
setSearchQuery('')
setSelectedIndex(0)
}, [navigate, onOpenChange])
// 键盘导航
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
if (filteredItems.length === 0) return
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (filteredItems.length === 0) return
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
e.preventDefault()
handleNavigate(filteredItems[selectedIndex].path)
}
},
[filteredItems, selectedIndex, handleNavigate]
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0" confirmOnEnter>
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setSelectedIndex(0)
}}
onKeyDown={handleKeyDown}
placeholder={t('search.placeholder')}
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
/>
</div>
</DialogHeader>
<div className="border-t">
<DialogBody className="h-100" viewportClassName="px-0">
{filteredItems.length > 0 ? (
<div className="p-2">
{filteredItems.map((item, index) => {
const Icon = item.icon
return (
<button
key={item.id}
onClick={() => handleNavigate(item.path)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left transition-colors',
index === selectedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
)}
>
<Icon className="h-5 w-5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground truncate">
{item.description}
</div>
</div>
<div className="text-xs text-muted-foreground px-2 py-1 bg-muted rounded">
{item.category}
</div>
</button>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-sm text-muted-foreground">
{searchQuery ? t('search.noResults') : t('search.startSearch')}
</p>
</div>
)}
</DialogBody>
</div>
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['up']} />
<ShortcutKbd size="sm" keys={['down']} />
{t('search.navigate')}
</span>
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['enter']} />
{t('search.select')}
</span>
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['esc']} />
{t('search.close')}
</span>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,685 @@
/**
* 分享 Pack 对话框
*
* 允许用户将当前配置导出并分享到 Pack 市场
*/
import { useState, useEffect } from 'react'
import {
Package,
Share2,
Server,
Layers,
ListChecks,
Tag,
Loader2,
Check,
Info,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/hooks/use-toast'
import {
createPack,
exportCurrentConfigAsPack,
type PackProvider,
type PackModel,
type PackTaskConfigs,
} from '@/lib/pack-api'
// 任务类型名称映射
const TASK_TYPE_NAMES: Record<string, string> = {
utils: '通用工具',
utils_small: '轻量工具',
tool_use: '工具调用',
replyer: '回复生成',
planner: '规划推理',
vlm: '视觉模型',
voice: '语音处理',
embedding: '向量嵌入',
lpmm_entity_extract: '实体提取',
lpmm_rdf_build: 'RDF构建',
lpmm_qa: '问答模型',
}
// 预设标签
const PRESET_TAGS = [
'官方推荐',
'性价比',
'高性能',
'免费模型',
'国内可用',
'海外模型',
'OpenAI',
'Claude',
'Gemini',
'国产模型',
'多模态',
'轻量级',
]
interface SharePackDialogProps {
trigger?: React.ReactNode
}
export function SharePackDialog({ trigger }: SharePackDialogProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
// 配置数据
const [providers, setProviders] = useState<PackProvider[]>([])
const [models, setModels] = useState<PackModel[]>([])
const [taskConfig, setTaskConfig] = useState<PackTaskConfigs>({})
// 选择状态
const [selectedProviders, setSelectedProviders] = useState<Set<string>>(new Set())
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set())
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
// Pack 信息
const [packName, setPackName] = useState('')
const [packDescription, setPackDescription] = useState('')
const [packAuthor, setPackAuthor] = useState('')
const [packTags, setPackTags] = useState<string[]>([])
// 加载当前配置
useEffect(() => {
if (open && step === 1) {
loadCurrentConfig()
}
}, [open, step])
const loadCurrentConfig = async () => {
setLoading(true)
try {
const config = await exportCurrentConfigAsPack({
name: '',
description: '',
author: '',
})
setProviders(config.providers)
setModels(config.models)
setTaskConfig(config.task_config)
// 默认全选
setSelectedProviders(new Set(config.providers.map(p => p.name)))
setSelectedModels(new Set(config.models.map(m => m.name)))
setSelectedTasks(new Set(Object.keys(config.task_config)))
} catch (error) {
console.error('加载配置失败:', error)
toast({ title: '加载当前配置失败', variant: 'destructive' })
} finally {
setLoading(false)
}
}
// 切换选择
const toggleProvider = (name: string) => {
const newSet = new Set(selectedProviders)
const newModels = new Set(selectedModels)
const newTasks = new Set(selectedTasks)
if (newSet.has(name)) {
// 取消选择提供商
newSet.delete(name)
// 取消选择该提供商下的所有模型
const providerModels = models.filter(m => m.api_provider === name)
providerModels.forEach(m => newModels.delete(m.name))
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
if (!hasSelectedModel) {
newTasks.delete(key)
}
}
})
} else {
// 选择提供商
newSet.add(name)
// 自动选择该提供商下的所有模型
const providerModels = models.filter(m => m.api_provider === name)
providerModels.forEach(m => newModels.add(m.name))
// 自动选择使用这些模型的任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasProviderModel = config.model_list.some((modelName: string) => {
const model = models.find(m => m.name === modelName)
return model && model.api_provider === name
})
if (hasProviderModel) {
newTasks.add(key)
}
}
})
}
setSelectedProviders(newSet)
setSelectedModels(newModels)
setSelectedTasks(newTasks)
}
const toggleModel = (name: string) => {
const newModels = new Set(selectedModels)
const newTasks = new Set(selectedTasks)
if (newModels.has(name)) {
// 取消选择模型
newModels.delete(name)
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
if (!hasSelectedModel) {
newTasks.delete(key)
}
}
})
} else {
// 选择模型
newModels.add(name)
// 自动选择使用这个模型的任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list && config.model_list.includes(name)) {
newTasks.add(key)
}
})
}
setSelectedModels(newModels)
setSelectedTasks(newTasks)
}
const toggleTask = (key: string) => {
const newSet = new Set(selectedTasks)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
setSelectedTasks(newSet)
}
const toggleTag = (tag: string) => {
if (packTags.includes(tag)) {
setPackTags(packTags.filter(t => t !== tag))
} else if (packTags.length < 5) {
setPackTags([...packTags, tag])
} else {
toast({ title: '最多选择 5 个标签', variant: 'destructive' })
}
}
// 全选/取消全选
const selectAllProviders = () => {
if (selectedProviders.size === providers.length) {
setSelectedProviders(new Set())
} else {
setSelectedProviders(new Set(providers.map(p => p.name)))
}
}
const selectAllModels = () => {
if (selectedModels.size === models.length) {
setSelectedModels(new Set())
} else {
setSelectedModels(new Set(models.map(m => m.name)))
}
}
const selectAllTasks = () => {
const taskKeys = Object.keys(taskConfig)
if (selectedTasks.size === taskKeys.length) {
setSelectedTasks(new Set())
} else {
setSelectedTasks(new Set(taskKeys))
}
}
// 提交
const handleSubmit = async () => {
// 验证
if (!packName.trim()) {
toast({ title: '请输入模板名称', variant: 'destructive' })
return
}
if (!packDescription.trim()) {
toast({ title: '请输入模板描述', variant: 'destructive' })
return
}
if (!packAuthor.trim()) {
toast({ title: '请输入作者名称', variant: 'destructive' })
return
}
if (selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0) {
toast({ title: '请至少选择一项配置', variant: 'destructive' })
return
}
setSubmitting(true)
try {
// 过滤选中的配置
const selectedProviderConfigs = providers.filter(p => selectedProviders.has(p.name))
const selectedModelConfigs = models.filter(m => selectedModels.has(m.name))
const selectedTaskConfigs: PackTaskConfigs = {}
for (const [key, config] of Object.entries(taskConfig)) {
if (selectedTasks.has(key)) {
selectedTaskConfigs[key as keyof PackTaskConfigs] = config
}
}
await createPack({
name: packName.trim(),
description: packDescription.trim(),
author: packAuthor.trim(),
tags: packTags,
providers: selectedProviderConfigs,
models: selectedModelConfigs,
task_config: selectedTaskConfigs,
})
toast({ title: '模板已提交审核,审核通过后将显示在市场中' })
setOpen(false)
resetForm()
} catch (error) {
console.error('提交失败:', error)
toast({ title: error instanceof Error ? error.message : '提交失败', variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
// 重置表单
const resetForm = () => {
setStep(1)
setPackName('')
setPackDescription('')
setPackAuthor('')
setPackTags([])
setSelectedProviders(new Set())
setSelectedModels(new Set())
setSelectedTasks(new Set())
}
const totalSteps = 2
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline">
<Share2 className="w-4 h-4 mr-2" />
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl flex flex-col" confirmOnEnter>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
{step} / {totalSteps}
{step === 1 && '选择要分享的配置'}
{step === 2 && '填写模板信息'}
</DialogDescription>
</DialogHeader>
<DialogBody>
{loading ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">...</p>
</div>
) : (
<>
{/* 步骤 1: 选择配置 */}
{step === 1 && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
<strong></strong> API Key
</AlertDescription>
</Alert>
<Tabs defaultValue="providers" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="providers">
<Server className="w-4 h-4 mr-2" />
API
<Badge variant="secondary" className="ml-2">
{selectedProviders.size}/{providers.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="models">
<Layers className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2">
{selectedModels.size}/{models.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="tasks">
<ListChecks className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2">
{selectedTasks.size}/{Object.keys(taskConfig).length}
</Badge>
</TabsTrigger>
</TabsList>
{/* 提供商选择 */}
<TabsContent value="providers" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllProviders}>
{selectedProviders.size === providers.length ? '取消全选' : '全选'}
</Button>
</div>
{providers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
providers.map(provider => (
<div
key={provider.name}
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
>
<Checkbox
id={`provider-${provider.name}`}
checked={selectedProviders.has(provider.name)}
onCheckedChange={() => toggleProvider(provider.name)}
/>
<Label
htmlFor={`provider-${provider.name}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">{provider.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{provider.base_url}
</span>
</Label>
<Badge variant="outline" className="text-xs">
{provider.client_type}
</Badge>
</div>
))
)}
</div>
</TabsContent>
{/* 模型选择 */}
<TabsContent value="models" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllModels}>
{selectedModels.size === models.length ? '取消全选' : '全选'}
</Button>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
models.map(model => (
<div
key={model.name}
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
>
<Checkbox
id={`model-${model.name}`}
checked={selectedModels.has(model.name)}
onCheckedChange={() => toggleModel(model.name)}
/>
<Label
htmlFor={`model-${model.name}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{model.model_identifier}
</span>
</Label>
<span className="text-xs text-muted-foreground">
{model.api_provider}
</span>
</div>
))
)}
</div>
</TabsContent>
{/* 任务配置选择 */}
<TabsContent value="tasks" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllTasks}>
{selectedTasks.size === Object.keys(taskConfig).length ? '取消全选' : '全选'}
</Button>
</div>
{Object.keys(taskConfig).length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
Object.entries(taskConfig).map(([key, config]) => (
<div
key={key}
className="space-y-2 p-2 rounded hover:bg-muted"
>
<div className="flex items-center space-x-2">
<Checkbox
id={`task-${key}`}
checked={selectedTasks.has(key)}
onCheckedChange={() => toggleTask(key)}
/>
<Label
htmlFor={`task-${key}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">
{TASK_TYPE_NAMES[key] || key}
</span>
</Label>
<Badge variant="outline" className="text-xs">
{config.model_list.length}
</Badge>
</div>
{config.model_list && config.model_list.length > 0 && (
<div className="ml-6 flex flex-wrap gap-1">
{config.model_list.map((modelName: string) => {
const model = models.find(m => m.name === modelName)
const isSelected = selectedModels.has(modelName)
return (
<Badge
key={modelName}
variant={isSelected ? "default" : "outline"}
className="text-xs cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleModel(modelName)}
>
{modelName}
{model && (
<span className="ml-1 opacity-70">
({model.api_provider})
</span>
)}
</Badge>
)
})}
</div>
)}
</div>
))
)}
</div>
</TabsContent>
</Tabs>
</div>
)}
{/* 步骤 2: 填写信息 */}
{step === 2 && (
<div className="space-y-4">
{/* 选择摘要 */}
<div className="flex gap-4 text-sm p-3 bg-muted rounded-lg">
<span className="flex items-center gap-1">
<Server className="w-4 h-4" />
{selectedProviders.size}
</span>
<span className="flex items-center gap-1">
<Layers className="w-4 h-4" />
{selectedModels.size}
</span>
<span className="flex items-center gap-1">
<ListChecks className="w-4 h-4" />
{selectedTasks.size}
</span>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pack-name"> *</Label>
<Input
id="pack-name"
placeholder="例如:高性价比国产模型配置"
value={packName}
onChange={e => setPackName(e.target.value)}
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
{packName.length}/50
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pack-description"> *</Label>
<Textarea
id="pack-description"
placeholder="详细描述这个配置模板的特点、适用场景等..."
value={packDescription}
onChange={e => setPackDescription(e.target.value)}
rows={4}
maxLength={500}
/>
<p className="text-xs text-muted-foreground">
{packDescription.length}/500
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pack-author"> *</Label>
<Input
id="pack-author"
placeholder="你的昵称或 ID"
value={packAuthor}
onChange={e => setPackAuthor(e.target.value)}
maxLength={30}
/>
</div>
<div className="space-y-2">
<Label> 5 </Label>
<div className="flex flex-wrap gap-2">
{PRESET_TAGS.map(tag => (
<Badge
key={tag}
variant={packTags.includes(tag) ? 'default' : 'outline'}
className="cursor-pointer transition-colors"
onClick={() => toggleTag(tag)}
>
{packTags.includes(tag) && <Check className="w-3 h-3 mr-1" />}
<Tag className="w-3 h-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
1-3
</AlertDescription>
</Alert>
</div>
)}
</>
)}
</DialogBody>
<DialogFooter className="flex justify-between pt-4 border-t">
<div>
{step > 1 && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={submitting}>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setOpen(false)
resetForm()
}}
disabled={submitting}
>
</Button>
{step < totalSteps ? (
<Button
data-dialog-action="confirm"
onClick={() => setStep(step + 1)}
disabled={
loading ||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
}
>
</Button>
) : (
<Button data-dialog-action="confirm" onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,8 @@
/**
* 问卷组件导出
*/
export { SurveyRenderer } from './survey-renderer'
export { SurveyQuestion } from './survey-question'
export { SurveyResults } from './survey-results'
export type { SurveyRendererProps } from './survey-renderer'

View File

@@ -0,0 +1,247 @@
/**
* 单个问题渲染组件
*/
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Star } from 'lucide-react'
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
interface SurveyQuestionProps {
question: SurveyQuestionType
value: string | string[] | number | undefined
onChange: (value: string | string[] | number) => void
error?: string
disabled?: boolean
}
export function SurveyQuestion({
question,
value,
onChange,
error,
disabled = false
}: SurveyQuestionProps) {
const [hoverRating, setHoverRating] = useState<number | null>(null)
// 如果问题设置了只读,则禁用输入
const isDisabled = disabled || question.readOnly
const renderQuestion = () => {
switch (question.type) {
case 'single':
return (
<RadioGroup
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
className="space-y-2"
>
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
)
case 'multiple': {
const selectedValues = (value as string[]) || []
return (
<div className="space-y-2">
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<Checkbox
id={`${question.id}-${option.id}`}
checked={selectedValues.includes(option.value)}
disabled={isDisabled || (
question.maxSelections !== undefined &&
selectedValues.length >= question.maxSelections &&
!selectedValues.includes(option.value)
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...selectedValues, option.value])
} else {
onChange(selectedValues.filter(v => v !== option.value))
}
}}
/>
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
{question.maxSelections && (
<p className="text-xs text-muted-foreground">
{question.maxSelections}
</p>
)}
</div>
)
}
case 'text':
return (
<Input
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
)
case 'textarea':
return (
<div className="space-y-1">
<Textarea
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
rows={4}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
{question.maxLength && (
<p className="text-xs text-muted-foreground text-right">
{(value as string || '').length} / {question.maxLength}
</p>
)}
</div>
)
case 'rating': {
const ratingValue = (value as number) || 0
const displayRating = hoverRating !== null ? hoverRating : ratingValue
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={isDisabled}
className={cn(
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
isDisabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => !isDisabled && setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => !isDisabled && onChange(star)}
>
<Star
className={cn(
"h-6 w-6 transition-colors",
star <= displayRating
? "fill-yellow-400 text-yellow-400"
: "text-muted-foreground"
)}
/>
</button>
))}
{ratingValue > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
{ratingValue} / 5
</span>
)}
</div>
)
}
case 'scale': {
const min = question.min ?? 1
const max = question.max ?? 10
const step = question.step ?? 1
const scaleValue = (value as number) ?? min
return (
<div className="space-y-4">
<Slider
value={[scaleValue]}
onValueChange={([val]) => onChange(val)}
min={min}
max={max}
step={step}
disabled={isDisabled}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{question.minLabel || min}</span>
<span className="font-medium text-foreground">{scaleValue}</span>
<span>{question.maxLabel || max}</span>
</div>
</div>
)
}
case 'dropdown':
return (
<Select
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue placeholder={question.placeholder || '请选择...'} />
</SelectTrigger>
<SelectContent>
{question.options?.map((option) => (
<SelectItem key={option.id} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return <div className="text-muted-foreground"></div>
}
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base font-medium">
{question.title}
{question.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{question.description && (
<p className="text-sm text-muted-foreground">{question.description}</p>
)}
</div>
{renderQuestion()}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,407 @@
/**
* 问卷渲染器组件
* 读取 JSON 配置并展示问卷界面
*/
import { useState, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
import { SurveyQuestion } from './survey-question'
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
export interface SurveyRendererProps {
/** 问卷配置 */
config: SurveyConfig
/** 初始答案(用于预填充,如自动填写版本号) */
initialAnswers?: QuestionAnswer[]
/** 提交成功回调 */
onSubmitSuccess?: (submissionId: string) => void
/** 提交失败回调 */
onSubmitError?: (error: string) => void
/** 是否显示进度条 */
showProgress?: boolean
/** 是否分页显示(每页一题) */
paginateQuestions?: boolean
/** 自定义类名 */
className?: string
}
type AnswerMap = Record<string, string | string[] | number | undefined>
export function SurveyRenderer({
config,
initialAnswers,
onSubmitSuccess,
onSubmitError,
showProgress = true,
paginateQuestions = false,
className
}: SurveyRendererProps) {
// 将 initialAnswers 转换为 AnswerMap
const getInitialAnswerMap = useCallback((): AnswerMap => {
if (!initialAnswers || initialAnswers.length === 0) return {}
return initialAnswers.reduce((acc, answer) => {
acc[answer.questionId] = answer.value
return acc
}, {} as AnswerMap)
}, [initialAnswers])
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
const [errors, setErrors] = useState<Record<string, string>>({})
const [currentPage, setCurrentPage] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submissionId, setSubmissionId] = useState<string | null>(null)
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
// 当 initialAnswers 变化时更新答案(合并而非替换)
useEffect(() => {
if (initialAnswers && initialAnswers.length > 0) {
setAnswers(prev => ({
...prev,
...getInitialAnswerMap()
}))
}
}, [initialAnswers, getInitialAnswerMap])
// 检查是否已提交过
useEffect(() => {
const checkSubmission = async () => {
if (!config.settings?.allowMultiple) {
const result = await checkUserSubmission(config.id)
if (result.success && result.hasSubmitted) {
setHasAlreadySubmitted(true)
}
}
setIsCheckingSubmission(false)
}
checkSubmission()
}, [config.id, config.settings?.allowMultiple])
// 检查问卷是否在有效期内
const isWithinTimeRange = useCallback(() => {
const now = new Date()
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
return false
}
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
return false
}
return true
}, [config.settings?.startTime, config.settings?.endTime])
// 计算进度
const answeredCount = config.questions.filter(q => {
const answer = answers[q.id]
if (answer === undefined || answer === null) return false
if (Array.isArray(answer)) return answer.length > 0
if (typeof answer === 'string') return answer.trim() !== ''
return true
}).length
const progress = (answeredCount / config.questions.length) * 100
// 更新答案
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
setAnswers(prev => ({ ...prev, [questionId]: value }))
// 清除该问题的错误
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[questionId]
return newErrors
})
}, [])
// 验证答案
const validateAnswers = useCallback(() => {
const newErrors: Record<string, string> = {}
for (const question of config.questions) {
if (question.required) {
const answer = answers[question.id]
if (answer === undefined || answer === null) {
newErrors[question.id] = '此题为必填项'
continue
}
if (Array.isArray(answer) && answer.length === 0) {
newErrors[question.id] = '请至少选择一项'
continue
}
if (typeof answer === 'string' && answer.trim() === '') {
newErrors[question.id] = '此题为必填项'
continue
}
}
// 文本长度验证
if (question.minLength && typeof answers[question.id] === 'string') {
const text = answers[question.id] as string
if (text.length < question.minLength) {
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [config.questions, answers])
// 提交问卷
const handleSubmit = useCallback(async () => {
if (!validateAnswers()) {
// 如果是分页模式,跳转到第一个有错误的问题
if (paginateQuestions) {
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
if (firstErrorIndex >= 0) {
setCurrentPage(firstErrorIndex)
}
}
return
}
setIsSubmitting(true)
setSubmitError(null)
try {
// 构建答案列表
const answerList: QuestionAnswer[] = config.questions
.filter(q => answers[q.id] !== undefined)
.map(q => ({
questionId: q.id,
value: answers[q.id]!
}))
const result = await submitSurvey(
config.id,
config.version,
answerList,
{ allowMultiple: config.settings?.allowMultiple }
)
if (result.success && result.submissionId) {
setIsSubmitted(true)
setSubmissionId(result.submissionId)
onSubmitSuccess?.(result.submissionId)
} else {
const error = result.error || '提交失败'
setSubmitError(error)
onSubmitError?.(error)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '提交失败'
setSubmitError(errorMsg)
onSubmitError?.(errorMsg)
} finally {
setIsSubmitting(false)
}
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
// 分页导航
const goToPage = useCallback((page: number) => {
if (page >= 0 && page < config.questions.length) {
setCurrentPage(page)
}
}, [config.questions.length])
// 检查中
if (isCheckingSubmission) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
// 已提交过
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 不在有效期内
if (!isWithinTimeRange()) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 提交成功
if (isSubmitted) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-6 w-6" />
</CardTitle>
</CardHeader>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
{config.settings?.thankYouMessage || '感谢你的参与!'}
</p>
{submissionId && (
<p className="text-center text-xs text-muted-foreground mt-4">
{submissionId}
</p>
)}
</CardContent>
</Card>
)
}
// 问卷展示
const questionsToShow = paginateQuestions
? [config.questions[currentPage]]
: config.questions
return (
<div className={cn("h-full flex flex-col", className)}>
{/* 问卷头部 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
<h2 className="text-xl font-semibold">{config.title}</h2>
{config.description && (
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
)}
{showProgress && (
<div className="space-y-1 pt-3">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{answeredCount} / {config.questions.length}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
{/* 问卷内容 - 可滚动区域 */}
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-4 pr-4">
{questionsToShow.map((question, index) => (
<div
key={question.id}
className={cn(
"p-4 rounded-lg border bg-card",
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
)}
>
{paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{currentPage + 1} / {config.questions.length}
</div>
)}
{!paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{index + 1}.
</div>
)}
<SurveyQuestion
question={question}
value={answers[question.id]}
onChange={(value) => handleAnswerChange(question.id, value)}
error={errors[question.id]}
disabled={isSubmitting}
/>
</div>
))}
{submitError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
{/* 提交按钮区域 */}
<div className="flex justify-between items-center py-4">
{paginateQuestions ? (
<>
<Button
variant="outline"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 0 || isSubmitting}
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
{currentPage === config.questions.length - 1 ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
) : (
<Button
onClick={() => goToPage(currentPage + 1)}
disabled={isSubmitting}
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</>
) : (
<>
<div className="text-sm text-muted-foreground">
{Object.keys(errors).length > 0 && (
<span className="text-destructive">
{Object.keys(errors).length}
</span>
)}
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="lg"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,292 @@
/**
* 问卷结果查看组件
* 展示问卷统计数据和用户提交记录
*/
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
interface SurveyResultsProps {
/** 问卷配置 */
config: SurveyConfig
/** 是否显示用户提交记录 */
showUserSubmissions?: boolean
/** 自定义类名 */
className?: string
}
export function SurveyResults({
config,
showUserSubmissions = true,
className
}: SurveyResultsProps) {
const [stats, setStats] = useState<SurveyStats | null>(null)
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
setError(null)
try {
// 获取统计数据
const statsResult = await getSurveyStats(config.id)
if (statsResult.success && statsResult.stats) {
setStats(statsResult.stats)
}
// 获取用户提交记录
if (showUserSubmissions) {
const submissionsResult = await getUserSubmissions(config.id)
if (submissionsResult.success && submissionsResult.submissions) {
setUserSubmissions(submissionsResult.submissions)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败')
} finally {
setIsLoading(false)
}
}
fetchData()
}, [config.id, showUserSubmissions])
if (isLoading) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 text-center text-muted-foreground">
{error}
</CardContent>
</Card>
)
}
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
{config.title} -
</CardTitle>
{config.description && (
<CardDescription>{config.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{/* 概览统计 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.totalSubmissions || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Users className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.uniqueUsers || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Clock className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-sm font-medium">
{stats?.lastSubmissionAt
? new Date(stats.lastSubmissionAt).toLocaleDateString()
: '-'
}
</div>
</div>
</div>
<Tabs defaultValue="stats" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stats"></TabsTrigger>
{showUserSubmissions && (
<TabsTrigger value="submissions"></TabsTrigger>
)}
</TabsList>
<TabsContent value="stats" className="mt-4">
<ScrollArea className="max-h-[60vh]">
<div className="space-y-6 pr-4">
{config.questions.map((question, index) => {
const qStats = stats?.questionStats[question.id]
return (
<div key={question.id} className="p-4 rounded-lg border">
<div className="text-xs text-muted-foreground mb-1">
{index + 1}
</div>
<div className="font-medium mb-3">{question.title}</div>
{qStats ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{qStats.answered}
</div>
{/* 选择题统计 */}
{qStats.optionCounts && question.options && (
<div className="space-y-2">
{question.options.map(option => {
const count = qStats.optionCounts?.[option.value] || 0
const percentage = qStats.answered > 0
? (count / qStats.answered) * 100
: 0
return (
<div key={option.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span>{option.label}</span>
<span className="text-muted-foreground">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<Progress value={percentage} className="h-2" />
</div>
)
})}
</div>
)}
{/* 评分/量表统计 */}
{qStats.average !== undefined && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-400" />
<span className="text-sm">
{qStats.average.toFixed(2)}
</span>
</div>
)}
{/* 文本答案样本 */}
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
</div>
<div className="space-y-1">
{qStats.sampleAnswers.map((answer, i) => (
<div
key={i}
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
>
"{answer}"
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
</div>
)}
</div>
)
})}
</div>
</ScrollArea>
</TabsContent>
{showUserSubmissions && (
<TabsContent value="submissions" className="mt-4">
<ScrollArea className="max-h-[60vh]">
{userSubmissions.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
</div>
) : (
<div className="space-y-4 pr-4">
{userSubmissions.map((submission) => (
<div key={submission.id} className="p-4 rounded-lg border">
<div className="flex items-center justify-between mb-3">
<Badge variant="outline">
{new Date(submission.submittedAt).toLocaleString()}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {submission.id}
</span>
</div>
<div className="space-y-2">
{submission.answers.map((answer) => {
const question = config.questions.find(
q => q.id === answer.questionId
)
if (!question) return null
// 格式化答案显示
let displayValue: string
if (Array.isArray(answer.value)) {
const labels = answer.value.map(v => {
const opt = question.options?.find(o => o.value === v)
return opt?.label || v
})
displayValue = labels.join('、')
} else if (typeof answer.value === 'number') {
displayValue = answer.value.toString()
} else {
const opt = question.options?.find(
o => o.value === answer.value
)
displayValue = opt?.label || answer.value
}
return (
<div key={answer.questionId} className="text-sm">
<span className="text-muted-foreground">
{question.title}
</span>
<span>{displayValue}</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { ThemeProviderContext } from '@/lib/theme-context'
import type { UserThemeConfig } from '@/lib/theme/tokens'
import {
THEME_STORAGE_KEYS,
loadThemeConfig,
migrateOldKeys,
resetThemeToDefault,
saveThemePartial,
} from '@/lib/theme/storage'
import { applyThemePipeline, removeCustomCSS } from '@/lib/theme/pipeline'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey: _storageKey,
}: ThemeProviderProps) {
const [themeMode, setThemeMode] = useState<Theme>(() => {
const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null
return saved || defaultTheme
})
const [themeConfig, setThemeConfig] = useState<UserThemeConfig>(() => loadThemeConfig())
const [systemThemeTick, setSystemThemeTick] = useState(0)
const resolvedTheme = useMemo<'dark' | 'light'>(() => {
if (themeMode !== 'system') return themeMode
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}, [themeMode, systemThemeTick])
useEffect(() => {
migrateOldKeys()
}, [])
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
if (themeMode === 'system') {
setSystemThemeTick((prev) => prev + 1)
}
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [themeMode])
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(resolvedTheme)
const isDark = resolvedTheme === 'dark'
applyThemePipeline(themeConfig, isDark)
}, [resolvedTheme, themeConfig])
const setTheme = useCallback((mode: Theme) => {
localStorage.setItem(THEME_STORAGE_KEYS.MODE, mode)
setThemeMode(mode)
}, [])
const updateThemeConfig = useCallback((partial: Partial<UserThemeConfig>) => {
saveThemePartial(partial)
setThemeConfig((prev) => ({ ...prev, ...partial }))
}, [])
const resetTheme = useCallback(() => {
resetThemeToDefault()
removeCustomCSS()
setThemeConfig(loadThemeConfig())
}, [])
const value = useMemo(
() => ({
theme: themeMode,
resolvedTheme,
setTheme,
themeConfig,
updateThemeConfig,
resetTheme,
}),
[themeMode, resolvedTheme, setTheme, themeConfig, updateThemeConfig, resetTheme],
)
return (
<ThemeProviderContext value={value}>
{children}
</ThemeProviderContext>
)
}

View File

@@ -0,0 +1,5 @@
export { TourProvider } from './tour-provider'
export { TourRenderer } from './tour-renderer'
export { useTour } from './use-tour'
export { TourContext } from './tour-context'
export type { TourId, TourState, TourContextType } from './types'

View File

@@ -0,0 +1,4 @@
import { createContext } from 'react'
import type { TourContextType } from './types'
export const TourContext = createContext<TourContextType | null>(null)

View File

@@ -0,0 +1,177 @@
import { useState, useCallback, type ReactNode } from 'react'
import type { Step, CallBackProps, Status } from 'react-joyride'
import { TourContext } from './tour-context'
import type { TourId, TourState } from './types'
const COMPLETED_TOURS_KEY = 'maibot-completed-tours'
// 从 localStorage 读取已完成的 Tours
function getCompletedTours(): Set<TourId> {
try {
const stored = localStorage.getItem(COMPLETED_TOURS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
} catch {
return new Set()
}
}
// 保存已完成的 Tours 到 localStorage
function saveCompletedTours(tours: Set<TourId>) {
localStorage.setItem(COMPLETED_TOURS_KEY, JSON.stringify([...tours]))
}
export function TourProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<TourState>({
activeTourId: null,
stepIndex: 0,
isRunning: false,
})
// 使用 useState 存储 toursMap 对象是可变的,可以直接修改)
const [tours] = useState<Map<TourId, Step[]>>(() => new Map())
const [completedTours, setCompletedTours] = useState<Set<TourId>>(getCompletedTours)
// 用于强制重新渲染的计数器
const [, forceUpdate] = useState(0)
const registerTour = useCallback((tourId: TourId, steps: Step[]) => {
tours.set(tourId, steps)
// 强制更新以确保 context 消费者能获取到最新数据
forceUpdate(n => n + 1)
}, [tours])
const unregisterTour = useCallback((tourId: TourId) => {
tours.delete(tourId)
// 如果正在运行的 Tour 被注销,停止它
setState(prev => {
if (prev.activeTourId === tourId) {
return { ...prev, activeTourId: null, isRunning: false, stepIndex: 0 }
}
return prev
})
}, [tours])
const startTour = useCallback((tourId: TourId, startIndex = 0) => {
if (tours.has(tourId)) {
setState({
activeTourId: tourId,
stepIndex: startIndex,
isRunning: true,
})
}
}, [tours])
const stopTour = useCallback(() => {
setState(prev => ({
...prev,
isRunning: false,
}))
}, [])
const goToStep = useCallback((index: number) => {
setState(prev => ({
...prev,
stepIndex: index,
}))
}, [])
const nextStep = useCallback(() => {
setState(prev => ({
...prev,
stepIndex: prev.stepIndex + 1,
}))
}, [])
const prevStep = useCallback(() => {
setState(prev => ({
...prev,
stepIndex: Math.max(0, prev.stepIndex - 1),
}))
}, [])
const getCurrentSteps = useCallback((): Step[] => {
if (!state.activeTourId) return []
return tours.get(state.activeTourId) || []
}, [state.activeTourId, tours])
const markTourCompleted = useCallback((tourId: TourId) => {
setCompletedTours(prev => {
const next = new Set(prev)
next.add(tourId)
saveCompletedTours(next)
return next
})
}, [])
const handleJoyrideCallback = useCallback((data: CallBackProps) => {
const { action, index, status, type } = data
const finishedStatuses: Status[] = ['finished', 'skipped']
// 处理关闭按钮点击
if (action === 'close') {
setState(prev => ({
...prev,
isRunning: false,
stepIndex: 0,
}))
return
}
if (finishedStatuses.includes(status)) {
// Tour 完成或跳过
setState(prev => {
if (status === 'finished' && prev.activeTourId) {
// 使用 setTimeout 避免在 setState 中调用另一个 setState
setTimeout(() => markTourCompleted(prev.activeTourId!), 0)
}
return {
...prev,
isRunning: false,
stepIndex: 0,
}
})
} else if (type === 'step:after') {
// 步骤切换后更新索引
if (action === 'next') {
setState(prev => ({ ...prev, stepIndex: index + 1 }))
} else if (action === 'prev') {
setState(prev => ({ ...prev, stepIndex: index - 1 }))
}
}
}, [markTourCompleted])
const isTourCompleted = useCallback((tourId: TourId): boolean => {
return completedTours.has(tourId)
}, [completedTours])
const resetTourCompleted = useCallback((tourId: TourId) => {
setCompletedTours(prev => {
const next = new Set(prev)
next.delete(tourId)
saveCompletedTours(next)
return next
})
}, [])
return (
<TourContext
value={{
state,
tours,
registerTour,
unregisterTour,
startTour,
stopTour,
goToStep,
nextStep,
prevStep,
getCurrentSteps,
handleJoyrideCallback,
isTourCompleted,
markTourCompleted,
resetTourCompleted,
}}
>
{children}
</TourContext>
)
}

View File

@@ -0,0 +1,211 @@
import Joyride from 'react-joyride'
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTour } from './use-tour'
// Joyride 主题配置
const joyrideStyles = {
options: {
// 提到 portal 容器99999之上确保 overlay/spotlight/tooltip 都在最上层;
// overlay 的 z-index 由 react-joyride 内部基于 options.zIndex 推算,必须大于 floater 才能让 tooltip 按钮可点击。
zIndex: 100000,
primaryColor: 'hsl(var(--color-primary))',
textColor: 'hsl(var(--color-foreground))',
backgroundColor: 'hsl(var(--color-background))',
arrowColor: 'hsl(var(--color-background))',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 'var(--radius)',
padding: '1rem',
},
tooltipContainer: {
textAlign: 'left' as const,
},
tooltipTitle: {
fontSize: '1rem',
fontWeight: 600,
marginBottom: '0.5rem',
},
tooltipContent: {
fontSize: '0.875rem',
padding: '0.5rem 0',
},
buttonNext: {
backgroundColor: 'hsl(var(--color-primary))',
color: 'hsl(var(--color-primary-foreground))',
borderRadius: 'calc(var(--radius) - 2px)',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
buttonBack: {
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
marginRight: '0.5rem',
},
buttonSkip: {
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
},
buttonClose: {
color: 'hsl(var(--color-muted-foreground))',
},
spotlight: {
borderRadius: 'var(--radius)',
},
}
// 中文本地化
const locale = {
back: '上一步',
close: '关闭',
last: '完成',
next: '下一步',
nextLabelWithProgress: '下一步 ({step}/{steps})',
open: '打开对话框',
skip: '跳过',
}
export function TourRenderer() {
const { state, getCurrentSteps, handleJoyrideCallback } = useTour()
const steps = getCurrentSteps()
const [targetReady, setTargetReady] = useState(false)
const prevStepIndexRef = useRef(state.stepIndex)
const cleanupRef = useRef<(() => void) | null>(null)
// 当步骤变化时,重置 targetReady 以强制重新检测和定位
useEffect(() => {
if (prevStepIndexRef.current !== state.stepIndex) {
setTargetReady(false)
prevStepIndexRef.current = state.stepIndex
}
}, [state.stepIndex])
// 等待当前步骤的目标元素出现
useEffect(() => {
if (!state.isRunning || steps.length === 0) {
setTargetReady(false)
return
}
const currentStep = steps[state.stepIndex]
if (!currentStep) {
setTargetReady(false)
return
}
const target = currentStep.target
if (target === 'body') {
setTargetReady(true)
return
}
// 重置状态
setTargetReady(false)
// 每次步骤变化时,先等待一段时间让 DOM 更新(弹窗关闭动画等)
const initialDelay = setTimeout(() => {
const checkTarget = () => {
const element = document.querySelector(target as string)
if (element) {
// 确保元素可见
const rect = element.getBoundingClientRect()
const isVisible = rect.width > 0 && rect.height > 0
if (isVisible) {
return true
}
}
return false
}
if (checkTarget()) {
// 找到元素后再等一小段时间,确保动画完成
setTimeout(() => setTargetReady(true), 100)
return
}
// 使用轮询检测元素
const intervalId = setInterval(() => {
if (checkTarget()) {
clearInterval(intervalId)
// 找到元素后再等一小段时间
setTimeout(() => setTargetReady(true), 100)
}
}, 100)
const timeout = setTimeout(() => {
clearInterval(intervalId)
// 超时后设置 targetReady 为 true让 Joyride 显示错误提示
setTargetReady(true)
}, 5000)
// 保存清理函数
const cleanup = () => {
clearInterval(intervalId)
clearTimeout(timeout)
}
// 将清理函数保存到 ref 中以便外部清理
cleanupRef.current = cleanup
}, 150) // 等待 150ms 让 DOM 更新和动画完成
return () => {
clearTimeout(initialDelay)
if (cleanupRef.current) {
cleanupRef.current()
cleanupRef.current = null
}
}
}, [state.isRunning, state.stepIndex, steps])
// 创建一个高层级的容器用于渲染 Joyride
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null)
useEffect(() => {
// 创建或获取 tour 专用容器
let container = document.getElementById('tour-portal-container') as HTMLDivElement | null
if (!container) {
container = document.createElement('div')
container.id = 'tour-portal-container'
container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 99999; pointer-events: none;'
document.body.appendChild(container)
}
setPortalElement(container)
return () => {
// 组件卸载时不删除容器,因为可能还会再用
}
}, [])
if (!state.isRunning || steps.length === 0 || !targetReady) {
return null
}
const joyrideElement = (
<Joyride
key={`tour-step-${state.stepIndex}`}
steps={steps}
stepIndex={state.stepIndex}
run={state.isRunning}
continuous
showSkipButton
showProgress
disableOverlayClose
disableScrolling={false}
disableScrollParentFix={false}
callback={handleJoyrideCallback}
styles={joyrideStyles}
locale={locale}
scrollOffset={80}
scrollToFirstStep
/>
)
// 使用 Portal 渲染到高层容器
if (portalElement) {
return createPortal(joyrideElement, portalElement)
}
return joyrideElement
}

View File

@@ -0,0 +1,202 @@
import type { Placement, Step } from 'react-joyride'
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
// Tour 步骤定义
export const modelAssignmentTourSteps: Step[] = [
{
target: 'body',
content: '本引导会帮你在同一个页面完成模型厂商、模型列表和功能分配配置。',
placement: 'center' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="providers-tab-trigger"]',
content: '第一步,进入"模型厂商设置"。这里用于配置要连接的模型服务厂商或模型平台。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="add-provider-button"]',
content: '点击"添加提供商"按钮,开始配置模型厂商的连接信息。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="provider-dialog"]',
content: '在这里可以选择厂商模板,填写 API Key、URL 和连接参数,保存后即可供模型引用。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-name-input"]',
content: '这里的名称用于在后续模型配置中识别这个厂商。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-apikey-input"]',
content: '这里填写从模型厂商获取的 API Key用于验证并调用模型服务。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-url-input"]',
content: '这里填写模型厂商的 API 访问地址。不同厂商或平台的地址可能不同。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-template-select"]',
content: '如果不确定如何填写,可以从预设模板中选择常用厂商,相关信息会自动填充。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-save-button"]',
content: '填写完成后点击保存,模型厂商就配置好了。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-cancel-button"]',
content: '这次只是演示流程,点击取消关闭厂商配置窗口。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="models-tab-trigger"]',
content: '厂商配置完成后,切换到"添加模型",把具体要使用的模型加入列表。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="add-model-button"]',
content: '点击"添加模型"按钮,开始添加一个可分配给功能的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="model-dialog"]',
content: '在这里选择刚才配置好的厂商,并填写模型名称、标识符、价格和能力参数。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-name-input"]',
content: '模型名称用于在任务分配时识别这个模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-provider-select"]',
content: '这里选择模型所属的厂商,系统会根据厂商配置获取或调用对应模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-identifier-input"]',
content: '这里填写模型标识符。不同厂商的模型标识符格式可能不同,请参考对应厂商文档。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-save-button"]',
content: '填写完成后点击保存,模型就会加入可用模型列表。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-cancel-button"]',
content: '这次只是演示流程,点击取消关闭模型配置窗口。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="tasks-tab-trigger"]',
content: '最后切换到"为模型分配功能",为麦麦的各个组件选择合适的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="task-model-select"]',
content: '在这里可以为每个组件选择一个或多个模型,选择完成后配置会自动保存。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
]
// 需要用户点击才能继续的步骤索引0-based
export const CLICK_TO_CONTINUE_STEPS = new Set([1, 2, 9, 10, 11, 17, 18])
// 合并后所有步骤都在模型管理与分配页面内完成
export const STEP_ROUTE_MAP: Record<number, string> = Object.fromEntries(
modelAssignmentTourSteps.map((_, index) => [index, '/config/model'])
)

View File

@@ -0,0 +1,49 @@
import type { Step, CallBackProps } from 'react-joyride'
// Tour ID 类型,用于区分不同的引导流程
export type TourId = string
export interface TourState {
// 当前激活的 Tour ID
activeTourId: TourId | null
// 当前步骤索引
stepIndex: number
// Tour 是否正在运行
isRunning: boolean
}
export interface TourContextType {
// 状态
state: TourState
// 注册的所有 Tour 步骤
tours: Map<TourId, Step[]>
// 注册一个 Tour
registerTour: (tourId: TourId, steps: Step[]) => void
// 注销一个 Tour
unregisterTour: (tourId: TourId) => void
// 开始一个 Tour
startTour: (tourId: TourId, startIndex?: number) => void
// 停止当前 Tour
stopTour: () => void
// 跳转到指定步骤
goToStep: (index: number) => void
// 下一步
nextStep: () => void
// 上一步
prevStep: () => void
// 获取当前 Tour 的步骤
getCurrentSteps: () => Step[]
// Joyride 回调处理
handleJoyrideCallback: (data: CallBackProps) => void
// 检查用户是否已完成某个 Tour
isTourCompleted: (tourId: TourId) => boolean
// 标记 Tour 已完成
markTourCompleted: (tourId: TourId) => void
// 重置 Tour 完成状态
resetTourCompleted: (tourId: TourId) => void
}

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { TourContext } from './tour-context'
export function useTour() {
const context = useContext(TourContext)
if (!context) {
throw new Error('useTour must be used within a TourProvider')
}
return context
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
}
>(({ className, variant, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/heading-has-content -- content passed via spread props
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useRef, useState } from 'react'
type Politeness = 'polite' | 'assertive'
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null)
/**
* useAnnounce — 向屏幕阅读器播报消息
*
* @example
* const announce = useAnnounce()
* announce('保存成功') // polite默认
* announce('操作失败,请重试', 'assertive') // assertive立即打断
*/
export function useAnnounce(): (message: string, politeness?: Politeness) => void {
const ctx = useContext(AnnouncerContext)
if (!ctx) {
// 未在 AnnouncerProvider 内时静默降级,不抛错
return () => {}
}
return ctx.announce
}
interface AnnouncerState {
polite: string
assertive: string
}
/**
* AnnouncerProvider — 在应用根部挂载两个 aria-live 区域
*
* 将此组件包裹在应用根节点,所有子组件即可通过 useAnnounce() 播报消息。
* aria-live 区域视觉上隐藏sr-only不影响布局。
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<AnnouncerState>({ polite: '', assertive: '' })
// 用于清空 -> 重新设置,触发屏幕阅读器重新朗读相同消息
const politeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const assertiveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const announce = useCallback((message: string, politeness: Politeness = 'polite') => {
if (politeness === 'assertive') {
// 先清空,再填入,确保屏幕阅读器重新朗读
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: '' }))
if (assertiveTimerRef.current) clearTimeout(assertiveTimerRef.current)
assertiveTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: message }))
}, 50)
} else {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: '' }))
if (politeTimerRef.current) clearTimeout(politeTimerRef.current)
politeTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: message }))
}, 50)
}
}, [])
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* aria-live 区域:视觉隐藏,屏幕阅读器可读 */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{messages.polite}
</div>
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{messages.assertive}
</div>
</AnnouncerContext.Provider>
)
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,37 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -0,0 +1,211 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,29 @@
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { BackgroundLayer } from '@/components/background-layer'
import { Card } from '@/components/ui/card'
import { useBackground } from '@/hooks/use-background'
type CardWithBackgroundProps = ComponentPropsWithoutRef<typeof Card>
export const CardWithBackground = forwardRef<
ElementRef<typeof Card>,
CardWithBackgroundProps
>(({ className, children, ...props }, ref) => {
const { config: bg } = useBackground('card')
return (
<Card ref={ref} className={cn('relative isolate', className)} {...props}>
<BackgroundLayer config={bg} layerId="card" />
<div className="relative z-10">
{children}
</div>
</Card>
)
})
CardWithBackground.displayName = 'CardWithBackground'

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,378 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
type ChartTooltipContentProps = React.ComponentProps<"div"> & {
active?: boolean
payload?: any[]
label?: string
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (label: any, payload: any[]) => React.ReactNode
formatter?: (value: any, name: string, item: any, index: number, payload?: any) => React.ReactNode
color?: string
labelClassName?: string
}
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
ChartTooltipContentProps
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item: any) => item.type !== "none")
.map((item: any, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
type ChartLegendContentProps = React.ComponentProps<"div"> & {
payload?: any[]
verticalAlign?: "top" | "bottom"
hideIcon?: boolean
nameKey?: string
}
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
ChartLegendContentProps
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item: any) => item.type !== "none")
.map((item: any) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 cursor-pointer rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,152 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,197 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,29 @@
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { BackgroundLayer } from '@/components/background-layer'
import { DialogContent } from '@/components/ui/dialog'
import { useBackground } from '@/hooks/use-background'
type DialogContentWithBackgroundProps = ComponentPropsWithoutRef<typeof DialogContent>
export const DialogContentWithBackground = forwardRef<
ElementRef<typeof DialogContent>,
DialogContentWithBackgroundProps
>(({ className, children, ...props }, ref) => {
const { config: bg } = useBackground('dialog')
return (
<DialogContent ref={ref} className={cn('relative isolate', className)} {...props}>
<BackgroundLayer config={bg} layerId="dialog" />
<div className="relative z-10">
{children}
</div>
</DialogContent>
)
})
DialogContentWithBackground.displayName = 'DialogContentWithBackground'

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
import { isEditableTarget, matchesShortcut } from "@/lib/keyboard"
import { ScrollArea } from "@/components/ui/scroll-area"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 阻止点击外部关闭(用于 Tour 运行时) */
preventOutsideClose?: boolean
/** 隐藏默认关闭按钮(当使用自定义关闭按钮时) */
hideCloseButton?: boolean
/** 回车触发主操作按钮 */
confirmOnEnter?: boolean
}
interface DialogBodyProps extends React.ComponentPropsWithoutRef<typeof ScrollArea> {
allowHorizontalScroll?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, confirmOnEnter = false, onKeyDownCapture, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 flex w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onKeyDownCapture={(event) => {
onKeyDownCapture?.(event)
if (
!confirmOnEnter ||
event.defaultPrevented ||
!matchesShortcut(event, ['enter']) ||
event.nativeEvent.isComposing ||
isEditableTarget(event.target)
) {
return
}
const confirmButton = event.currentTarget.querySelector<HTMLElement>('[data-dialog-action="confirm"]:not([disabled])')
if (!confirmButton) {
return
}
event.preventDefault()
confirmButton.click()
}}
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, type, ...props }, ref) => (
// 关键:在 flex-col 的 DialogContent 中DialogBody 既要在内容多时撑到 max-h 上限并滚动,
// 又要在内容少时让 dialog 自然收缩。直接在 ScrollArea Root 上 flex-1 + min-h-0 即可:
// Radix Viewport 内部 wrapper 默认 display:table 会撑开自然高度,所以需要强制 block。
<ScrollArea
ref={ref as never}
className={cn("min-h-0 flex-1 flex flex-col", className)}
contentClassName={cn(allowHorizontalScroll && "min-w-full w-max", contentClassName)}
scrollbars={scrollbars ?? (allowHorizontalScroll ? "both" : "vertical")}
viewportClassName={cn("min-h-0 flex-1 pr-4 [&>div]:!block", viewportClassName)}
type={type ?? "always"}
{...props}
>
{children}
</ScrollArea>
)
)
DialogBody.displayName = "DialogBody"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,82 @@
"use client"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { KeyValueEditor } from "@/components/ui/key-value-editor"
interface ExtraParamsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
}
export function ExtraParamsDialog({
open,
onOpenChange,
value,
onChange,
}: ExtraParamsDialogProps) {
const [editingValue, setEditingValue] = useState<Record<string, unknown>>(value)
useEffect(() => {
if (open) {
setEditingValue(value)
}
}, [open, value])
// 当对话框打开状态改变时的处理
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
// 打开时同步最新的 value
setEditingValue(value)
}
onOpenChange(newOpen)
}
const handleSave = () => {
onChange(editingValue)
onOpenChange(false)
}
const handleCancel = () => {
setEditingValue(value) // 恢复原始值
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<KeyValueEditor
value={editingValue}
onChange={setEditingValue}
placeholder="添加额外参数(如 thinking、top_p 等)..."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,63 @@
import * as React from "react"
import { HelpCircle } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface HelpTooltipProps {
content: React.ReactNode
className?: string
iconClassName?: string
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
maxWidth?: string
}
export function HelpTooltip({
content,
className,
iconClassName,
side = "top",
align = "center",
maxWidth = "300px",
}: HelpTooltipProps) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground",
"transition-colors cursor-help",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
onClick={(e) => e.preventDefault()}
>
<HelpCircle className={cn("h-4 w-4", iconClassName)} />
<span className="sr-only"></span>
</button>
</TooltipTrigger>
<TooltipContent
side={side}
align={align}
className={cn(
"max-w-[var(--max-width)] text-sm leading-relaxed",
"bg-background text-foreground",
"border-2 border-primary shadow-lg",
"p-4"
)}
style={{ "--max-width": maxWidth } as React.CSSProperties}
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { getPlatformModifierAriaLabel, getShortcutKeyLabel, type ShortcutKey } from "@/lib/keyboard"
import { cn } from "@/lib/utils"
const kbdVariants = cva(
"pointer-events-none inline-flex select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono font-medium opacity-100",
{
variants: {
size: {
sm: "h-5 text-[10px]",
default: "h-6 text-xs",
lg: "h-7 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
export interface KbdProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof kbdVariants> {
abbrTitle?: string
}
interface ShortcutKbdProps extends Omit<KbdProps, "children"> {
keys: ShortcutKey[]
}
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
({ className, size, abbrTitle, children, ...props }, ref) => {
return (
<kbd
className={cn(kbdVariants({ size, className }))}
ref={ref}
{...props}
>
{abbrTitle ? <abbr title={abbrTitle}>{children}</abbr> : children}
</kbd>
)
}
)
Kbd.displayName = "Kbd"
function ShortcutKbd({ keys, className, size, ...props }: ShortcutKbdProps) {
return (
<span className={cn("inline-flex items-center gap-1", className)}>
{keys.map((key) => {
const label = getShortcutKeyLabel(key)
const abbrTitle = key === 'mod' ? getPlatformModifierAriaLabel() : undefined
return (
<Kbd key={`${key}-${label}`} size={size} abbrTitle={abbrTitle} {...props}>
{label}
</Kbd>
)
})}
</span>
)
}
export { Kbd, ShortcutKbd }

View File

@@ -0,0 +1,180 @@
"use client"
import { useState, useEffect, useCallback, useMemo } from "react"
import { AlertCircle, Check } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { NestedKeyValueEditor } from "./nested-key-value-editor"
interface KeyValueEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
className?: string
placeholder?: string
}
// 验证 JSON 字符串
function validateJson(jsonStr: string): { valid: boolean; error?: string; parsed?: Record<string, unknown> } {
if (!jsonStr.trim()) {
return { valid: true, parsed: {} }
}
try {
const parsed = JSON.parse(jsonStr)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { valid: false, error: '必须是一个 JSON 对象 {}' }
}
// 支持任意 JSON 值类型(包括嵌套对象和数组)
return { valid: true, parsed: parsed as Record<string, unknown> }
} catch {
return { valid: false, error: 'JSON 格式错误' }
}
}
export function KeyValueEditor({
value,
onChange,
className,
placeholder = "添加额外参数...",
}: KeyValueEditorProps) {
const [mode, setMode] = useState<'list' | 'json'>('list')
const initialJsonText = useMemo(() =>
Object.keys(value || {}).length > 0 ? JSON.stringify(value, null, 2) : '',
[value]
)
const [editingJsonText, setEditingJsonText] = useState(initialJsonText)
const [jsonError, setJsonError] = useState<string | null>(null)
// 当 value 变化时重置编辑状态
useEffect(() => {
setEditingJsonText(initialJsonText)
}, [initialJsonText])
// JSON 预览数据
const previewData = useMemo(() => {
const validation = validateJson(editingJsonText)
if (validation.valid && validation.parsed) {
return { success: true, data: validation.parsed }
}
return { success: false, data: {} }
}, [editingJsonText])
// 切换模式时同步数据
const handleModeChange = useCallback((newMode: string) => {
const targetMode = newMode as 'list' | 'json'
if (targetMode === 'json' && mode === 'list') {
// 从列表模式切换到 JSON 模式将当前value转换为JSON
setEditingJsonText(Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : '')
setJsonError(null)
}
setMode(targetMode)
}, [mode, value])
// JSON 文本变化
const handleJsonChange = useCallback((text: string) => {
setEditingJsonText(text)
const validation = validateJson(text)
if (validation.valid && validation.parsed) {
setJsonError(null)
onChange(validation.parsed)
} else {
setJsonError(validation.error || 'JSON 格式错误')
}
}, [onChange])
return (
<div className={cn("h-full flex flex-col", className)}>
<Tabs value={mode} onValueChange={handleModeChange} className="w-full flex-1 flex flex-col">
<TabsList className="h-8 p-0.5 bg-muted/60 w-fit">
<TabsTrigger
value="list"
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="json"
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
JSON
</TabsTrigger>
</TabsList>
{/* 可视化编辑模式(嵌套键值对) */}
<TabsContent
value="list"
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
>
<NestedKeyValueEditor
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</TabsContent>
{/* JSON 编辑模式 - 左右分栏 */}
<TabsContent
value="json"
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 overflow-hidden">
{/* 左侧JSON 编辑器 */}
<div className="flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
{jsonError ? (
<div className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span className="truncate max-w-[150px]">{jsonError}</span>
</div>
) : editingJsonText.trim() && (
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
<span></span>
</div>
)}
</div>
<Textarea
value={editingJsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={'{\n "key": "value"\n}'}
className={cn(
"font-mono text-sm flex-1 resize-none",
jsonError && "border-destructive focus-visible:ring-destructive"
)}
/>
<p className="text-xs text-muted-foreground">
JSON
</p>
</div>
{/* 右侧:预览 */}
<div className="flex flex-col gap-2 overflow-hidden">
<span className="text-xs text-muted-foreground"></span>
<div className="flex-1 rounded-md border bg-muted/30 p-3 overflow-auto">
{previewData.success && Object.keys(previewData.data).length > 0 ? (
<pre className="font-mono text-xs whitespace-pre-wrap break-words">
{JSON.stringify(previewData.data, null, 2)}
</pre>
) : previewData.success ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
</div>
) : (
<div className="flex items-center justify-center h-full text-sm text-destructive">
JSON
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,28 @@
import { MarkdownRenderer } from '@/components/markdown-renderer'
interface MarkdownProps {
children: string
className?: string
}
/**
* Markdown 组件 - 用于渲染 Markdown 内容(支持 GFM 和 LaTeX
*
* @example
* ```tsx
* <Markdown>
* # 标题
* 这是一段 **加粗** 的文字
*
* 数学公式:$E = mc^2$
*
* 块级公式:
* $$
* \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
* $$
* </Markdown>
* ```
*/
export function Markdown({ children, className }: MarkdownProps) {
return <MarkdownRenderer content={children} className={className} />
}

View File

@@ -0,0 +1,259 @@
/**
* 多选下拉框组件
* 支持搜索、单击选择、标签展示、拖动排序
*/
import * as React from 'react'
import { X, Check, ChevronsUpDown, GripVertical } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
export interface MultiSelectOption {
label: string
value: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
emptyText?: string
className?: string
}
// 可排序的标签组件
function SortableBadge({
value,
label,
onRemove,
}: {
value: string
label: string
onRemove: (value: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// 处理删除按钮点击,阻止事件冒泡和默认行为
const handleRemoveClick = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
onRemove(value)
}
// 阻止删除按钮上的指针事件被 DndContext 捕获
const handleRemovePointerDown = (e: React.PointerEvent) => {
e.stopPropagation()
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'inline-flex items-center gap-1',
isDragging && 'shadow-lg'
)}
>
<Badge
variant="secondary"
className="cursor-move hover:bg-secondary/80 flex items-center gap-1"
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing flex items-center"
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
</div>
<span>{label}</span>
<span
role="button"
tabIndex={0}
className="ml-1 rounded-sm hover:bg-destructive/20 focus:outline-none focus:ring-1 focus:ring-destructive cursor-pointer"
onClick={handleRemoveClick}
onPointerDown={handleRemovePointerDown}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleRemoveClick(e)
}
}}
>
<X
className="h-3 w-3 hover:text-destructive"
strokeWidth={2}
fill="none"
/>
</span>
</Badge>
</div>
)
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = '选择选项...',
emptyText = '未找到选项',
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 拖动至少8px才触发避免与点击冲突
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleSelect = (value: string) => {
if (selected.includes(value)) {
// 取消选择
onChange(selected.filter((item) => item !== value))
} else {
// 添加选择
onChange([...selected, value])
}
}
const handleRemove = (value: string) => {
onChange(selected.filter((item) => item !== value))
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = selected.indexOf(active.id as string)
const newIndex = selected.indexOf(over.id as string)
onChange(arrayMove(selected, oldIndex, newIndex))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between min-h-10 h-auto', className)}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selected}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-1 flex-wrap flex-1">
{selected.length === 0 ? (
<span className="text-muted-foreground">{placeholder}</span>
) : (
selected.map((value) => {
const option = options.find((opt) => opt.value === value)
return (
<SortableBadge
key={value}
value={value}
label={option?.label || value}
onRemove={handleRemove}
/>
)
})
)}
</div>
</SortableContext>
</DndContext>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" strokeWidth={2} fill="none" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="搜索..." className="h-9" />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<Check className="h-3 w-3" strokeWidth={2} fill="none" />
</div>
<span>{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,486 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Plus, Trash2, ChevronRight, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
// 生成唯一 ID
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`
}
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
interface TreeNode {
id: string
key: string
value: unknown
type: ValueType
expanded?: boolean
children?: TreeNode[]
}
interface NestedKeyValueEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
placeholder?: string
}
// 推断值的类型
function inferType(value: unknown): ValueType {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
if (typeof value === 'object') return 'object'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
return 'string'
}
// 将 Record 转换为树节点数组
function recordToTree(record: Record<string, unknown>): TreeNode[] {
return Object.entries(record).map(([key, value]) => {
const type = inferType(value)
const node: TreeNode = {
id: generateId(),
key,
value,
type,
expanded: true,
}
if (type === 'object' && value && typeof value === 'object') {
node.children = recordToTree(value as Record<string, unknown>)
} else if (type === 'array' && Array.isArray(value)) {
node.children = value.map((item, index) => {
const itemType = inferType(item)
const childNode: TreeNode = {
id: generateId(),
key: String(index),
value: item,
type: itemType,
expanded: true,
}
if (itemType === 'object' && item && typeof item === 'object') {
childNode.children = recordToTree(item as Record<string, unknown>)
} else if (itemType === 'array' && Array.isArray(item)) {
childNode.children = item.map((subItem, subIndex) => ({
id: generateId(),
key: String(subIndex),
value: subItem,
type: inferType(subItem),
expanded: true,
}))
}
return childNode
})
}
return node
})
}
// 将树节点数组转换为 Record
function treeToRecord(nodes: TreeNode[]): Record<string, unknown> {
const record: Record<string, unknown> = {}
for (const node of nodes) {
if (!node.key.trim()) continue
if (node.type === 'object' && node.children) {
record[node.key] = treeToRecord(node.children)
} else if (node.type === 'array' && node.children) {
record[node.key] = node.children.map(child => {
if (child.type === 'object' && child.children) {
return treeToRecord(child.children)
} else if (child.type === 'array' && child.children) {
return child.children.map(c => c.value)
}
return child.value
})
} else if (node.type === 'null') {
record[node.key] = null
} else {
record[node.key] = node.value
}
}
return record
}
// 转换简单值
function convertSimpleValue(value: string, type: ValueType): unknown {
switch (type) {
case 'boolean':
return value === 'true'
case 'number': {
const num = parseFloat(value)
return isNaN(num) ? 0 : num
}
case 'null':
return null
default:
return value
}
}
// 树节点组件
function TreeNodeItem({
node,
level,
onUpdate,
onRemove,
onAddChild,
onToggleExpand,
}: {
node: TreeNode
level: number
onUpdate: (id: string, field: 'key' | 'value' | 'type', value: unknown) => void
onRemove: (id: string) => void
onAddChild: (parentId: string) => void
onToggleExpand: (id: string) => void
}) {
const isContainer = node.type === 'object' || node.type === 'array'
const hasChildren = node.children && node.children.length > 0
return (
<div className="space-y-1">
<div
className="grid gap-2 items-center"
style={{
gridTemplateColumns: isContainer
? '32px 1fr 90px 64px'
: '32px 1fr 1fr 90px 32px',
paddingLeft: `${level * 20}px`,
}}
>
{/* 展开/折叠按钮 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onToggleExpand(node.id)}
disabled={!isContainer || !hasChildren}
>
{isContainer && hasChildren ? (
node.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)
) : (
<span className="w-4" />
)}
</Button>
{/* 键名 */}
<Input
value={node.key}
onChange={(e) => onUpdate(node.id, 'key', e.target.value)}
placeholder="key"
className="h-8 text-sm"
/>
{/* 值(仅简单类型显示) */}
{!isContainer && (
<>
{node.type === 'boolean' ? (
<div className="flex items-center h-8 px-3 border rounded-md bg-background">
<Switch
checked={node.value === true}
onCheckedChange={(checked) => onUpdate(node.id, 'value', checked)}
/>
<span className="ml-2 text-sm text-muted-foreground">
{node.value ? 'true' : 'false'}
</span>
</div>
) : node.type === 'null' ? (
<div className="flex items-center h-8 px-3 border rounded-md bg-muted text-sm text-muted-foreground">
null
</div>
) : (
<Input
type={node.type === 'number' ? 'number' : 'text'}
value={node.value as string | number}
onChange={(e) => onUpdate(node.id, 'value', e.target.value)}
placeholder="value"
className="h-8 text-sm"
step={node.type === 'number' ? 'any' : undefined}
/>
)}
</>
)}
{/* 类型选择 */}
<Select
value={node.type}
onValueChange={(v) => onUpdate(node.id, 'type', v as ValueType)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
<SelectItem value="null">Null</SelectItem>
<SelectItem value="object"></SelectItem>
<SelectItem value="array"></SelectItem>
</SelectContent>
</Select>
{/* 操作按钮 */}
<div className="flex gap-1 justify-end">
{isContainer && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={() => onAddChild(node.id)}
title="添加子项"
>
<Plus className="h-4 w-4" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(node.id)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 子节点 */}
{isContainer && node.expanded && node.children && node.children.length > 0 && (
<div className="space-y-1">
{node.children.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
level={level + 1}
onUpdate={onUpdate}
onRemove={onRemove}
onAddChild={onAddChild}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
</div>
)
}
export function NestedKeyValueEditor({
value,
onChange,
placeholder = "添加参数...",
}: NestedKeyValueEditorProps) {
const [nodes, setNodes] = useState<TreeNode[]>(() => recordToTree(value || {}))
const lastEmittedValueRef = useRef<string | null>(null)
useEffect(() => {
const nextValueJson = JSON.stringify(value || {})
if (lastEmittedValueRef.current === nextValueJson) {
return
}
setNodes(recordToTree(value || {}))
}, [value])
// 同步到父组件
const syncToParent = useCallback(
(newNodes: TreeNode[]) => {
const nextValue = treeToRecord(newNodes)
lastEmittedValueRef.current = JSON.stringify(nextValue)
setNodes(newNodes)
onChange(nextValue)
},
[onChange]
)
// 添加根节点
const addRootNode = useCallback(() => {
const newNode: TreeNode = {
id: generateId(),
key: '',
value: '',
type: 'string',
expanded: false,
}
syncToParent([...nodes, newNode])
}, [nodes, syncToParent])
// 更新节点
const updateNode = useCallback(
(id: string, field: 'key' | 'value' | 'type', newValue: unknown) => {
const updateRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === id) {
if (field === 'type') {
const newType = newValue as ValueType
if (newType === 'object') {
return { ...node, type: newType, value: {}, children: [] }
} else if (newType === 'array') {
return { ...node, type: newType, value: [], children: [] }
} else if (newType === 'null') {
return { ...node, type: newType, value: null }
} else {
const converted = convertSimpleValue(String(node.value), newType)
return { ...node, type: newType, value: converted, children: undefined }
}
} else if (field === 'value') {
const converted = convertSimpleValue(String(newValue), node.type)
return { ...node, value: converted }
} else {
return { ...node, [field]: String(newValue) }
}
}
if (node.children) {
return { ...node, children: updateRecursive(node.children) }
}
return node
})
}
syncToParent(updateRecursive(nodes))
},
[nodes, syncToParent]
)
// 删除节点
const removeNode = useCallback(
(id: string) => {
const removeRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter((node) => node.id !== id)
.map((node) => {
if (node.children) {
return { ...node, children: removeRecursive(node.children) }
}
return node
})
}
syncToParent(removeRecursive(nodes))
},
[nodes, syncToParent]
)
// 添加子节点
const addChildNode = useCallback(
(parentId: string) => {
const addRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === parentId) {
const newChild: TreeNode = {
id: generateId(),
key: node.type === 'array' ? String(node.children?.length || 0) : '',
value: '',
type: 'string',
expanded: true,
}
return {
...node,
children: [...(node.children || []), newChild],
}
}
if (node.children) {
return { ...node, children: addRecursive(node.children) }
}
return node
})
}
syncToParent(addRecursive(nodes))
},
[nodes, syncToParent]
)
// 切换展开/折叠
const toggleExpand = useCallback(
(id: string) => {
const toggleRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === id) {
return { ...node, expanded: !node.expanded }
}
if (node.children) {
return { ...node, children: toggleRecursive(node.children) }
}
return node
})
}
setNodes(toggleRecursive(nodes))
},
[nodes]
)
return (
<div className="h-full flex flex-col gap-2">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{nodes.length}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={addRootNode}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto space-y-1">
{nodes.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded-md">
{placeholder}
</div>
) : (
<div className="space-y-1">
{/* 表头 */}
<div
className="grid gap-2 text-xs text-muted-foreground px-1 sticky top-0 bg-background z-10"
style={{
gridTemplateColumns: '32px 1fr 1fr 90px 32px',
}}
>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{nodes.map((node) => (
<TreeNodeItem
key={node.id}
node={node}
level={0}
onUpdate={updateNode}
onRemove={removeNode}
onAddChild={addChildNode}
onToggleExpand={toggleExpand}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,118 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
// eslint-disable-next-line jsx-a11y/anchor-has-content -- content passed via spread props
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span></span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span></span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
viewportRef?: React.RefObject<HTMLDivElement | null>
viewportClassName?: string
contentClassName?: string
scrollbars?: "vertical" | "horizontal" | "both"
}
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
ScrollAreaProps
>(({ className, children, viewportRef, viewportClassName, contentClassName, scrollbars = "both", ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
<div className={contentClassName}>{children}</div>
</ScrollAreaPrimitive.Viewport>
{scrollbars !== "horizontal" && <ScrollBar />}
{scrollbars !== "vertical" && <ScrollBar orientation="horizontal" />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-px",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[100] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-2 pl-2 pr-8 text-sm outline-none bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
/**
* Skip-to-content 无障碍导航链接
*
* 默认视觉上隐藏sr-only当键盘用户 Tab 聚焦时显示,
* 允许屏幕阅读器/键盘用户跳过重复的导航区域直达主内容。
*
* 使用 focus-visible 而非 focus鼠标点击不触发显示。
*/
export function SkipNav() {
const { t } = useTranslation()
return (
<a
href="#main-content"
className={[
'sr-only',
'focus-visible:not-sr-only',
'focus-visible:fixed',
'focus-visible:left-4',
'focus-visible:top-4',
'focus-visible:z-[9999]',
'focus-visible:rounded-md',
'focus-visible:bg-background',
'focus-visible:px-4',
'focus-visible:py-2',
'focus-visible:text-sm',
'focus-visible:font-medium',
'focus-visible:text-foreground',
'focus-visible:shadow-md',
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-ring',
].join(' ')}
>
{t('a11y.skipToContent')}
</a>
)
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:animate-in data-[state=active]:fade-in data-[state=active]:duration-300",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,110 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.ComponentProps<"textarea"> {
/**
* 是否启用自动高度调整
* @default true
*/
autoResize?: boolean
/**
* 最小高度(像素),仅在 autoResize=true 时生效
* @default 60
*/
minHeight?: number
/**
* 最大高度(像素),仅在 autoResize=true 时生效
* 设置为 undefined 或 0 表示不限制最大高度
*/
maxHeight?: number
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, autoResize = true, minHeight = 60, maxHeight, value, onChange, ...props }, ref) => {
const innerRef = React.useRef<HTMLTextAreaElement>(null)
const [hasFixedHeight, setHasFixedHeight] = React.useState(false)
// 合并 ref
React.useImperativeHandle(ref, () => innerRef.current!)
// 检测是否设置了固定高度
React.useEffect(() => {
if (className) {
// 检查是否包含固定高度的类(如 h-20, h-[200px], min-h-[xxx] 等)
const hasFixedHeightClass = /\b(h-\d+|h-\[[\d.]+(?:px|rem|em)\]|min-h-\[[\d.]+(?:px|rem|em)\])\b/.test(className)
setHasFixedHeight(hasFixedHeightClass)
}
}, [className])
// 自动调整高度函数
const adjustHeight = React.useCallback(() => {
const textarea = innerRef.current
if (!textarea || !autoResize || hasFixedHeight) return
// 重置高度以获取真实的 scrollHeight
textarea.style.height = 'auto'
// 计算新高度
const scrollHeight = textarea.scrollHeight
let newHeight = Math.max(scrollHeight, minHeight)
// 应用最大高度限制
if (maxHeight && maxHeight > 0) {
newHeight = Math.min(newHeight, maxHeight)
}
textarea.style.height = `${newHeight}px`
// 如果内容超过最大高度,启用滚动
if (maxHeight && maxHeight > 0 && scrollHeight > maxHeight) {
textarea.style.overflowY = 'auto'
} else {
textarea.style.overflowY = 'hidden'
}
}, [autoResize, hasFixedHeight, minHeight, maxHeight])
// 监听 value 变化并调整高度
React.useEffect(() => {
adjustHeight()
}, [value, adjustHeight])
// 组件挂载时调整高度
React.useEffect(() => {
adjustHeight()
}, [adjustHeight])
// 处理 onChange 事件
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e)
// 延迟调整高度,确保值已更新
requestAnimationFrame(() => {
adjustHeight()
})
},
[onChange, adjustHeight]
)
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"custom-scrollbar",
autoResize && !hasFixedHeight && "resize-none overflow-hidden",
className
)}
ref={innerRef}
value={value}
onChange={handleChange}
style={{
minHeight: autoResize && !hasFixedHeight ? `${minHeight}px` : undefined,
}}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

Some files were not shown because too many files have changed in this diff Show More