chore: import deployable mai-bot source tree
This commit is contained in:
BIN
dashboard/src/assets/maimai.ico
Normal file
BIN
dashboard/src/assets/maimai.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal 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 |
54
dashboard/src/components/CodeEditor.tsx
Normal file
54
dashboard/src/components/CodeEditor.tsx
Normal 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
|
||||
105
dashboard/src/components/CodeEditorImpl.tsx
Normal file
105
dashboard/src/components/CodeEditorImpl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
525
dashboard/src/components/ListFieldEditor.tsx
Normal file
525
dashboard/src/components/ListFieldEditor.tsx
Normal 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
|
||||
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal file
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
dashboard/src/components/animation-provider.tsx
Normal file
54
dashboard/src/components/animation-provider.tsx
Normal 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>
|
||||
}
|
||||
64
dashboard/src/components/asset-provider.tsx
Normal file
64
dashboard/src/components/asset-provider.tsx
Normal 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
|
||||
}
|
||||
101
dashboard/src/components/back-to-top.tsx
Normal file
101
dashboard/src/components/back-to-top.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
dashboard/src/components/background-effects-controls.tsx
Normal file
267
dashboard/src/components/background-effects-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
dashboard/src/components/background-layer.tsx
Normal file
196
dashboard/src/components/background-layer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
284
dashboard/src/components/background-uploader.tsx
Normal file
284
dashboard/src/components/background-uploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
dashboard/src/components/component-css-editor.tsx
Normal file
83
dashboard/src/components/component-css-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
462
dashboard/src/components/dynamic-form/DynamicConfigForm.tsx
Normal file
462
dashboard/src/components/dynamic-form/DynamicConfigForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal file
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
dashboard/src/components/dynamic-form/README.md
Normal file
126
dashboard/src/components/dynamic-form/README.md
Normal 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.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
2
dashboard/src/components/dynamic-form/index.ts
Normal file
2
dashboard/src/components/dynamic-form/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DynamicConfigForm } from './DynamicConfigForm'
|
||||
export { DynamicField } from './DynamicField'
|
||||
245
dashboard/src/components/electron/BackendManager.tsx
Normal file
245
dashboard/src/components/electron/BackendManager.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal file
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
dashboard/src/components/electron/TitleBar.tsx
Normal file
67
dashboard/src/components/electron/TitleBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
dashboard/src/components/emoji-thumbnail.tsx
Normal file
123
dashboard/src/components/emoji-thumbnail.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
310
dashboard/src/components/error-boundary.tsx
Normal file
310
dashboard/src/components/error-boundary.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1726
dashboard/src/components/expression-reviewer.tsx
Normal file
1726
dashboard/src/components/expression-reviewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
61
dashboard/src/components/http-warning-banner.tsx
Normal file
61
dashboard/src/components/http-warning-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
dashboard/src/components/index.ts
Normal file
13
dashboard/src/components/index.ts
Normal 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'
|
||||
278
dashboard/src/components/layout/Header.tsx
Normal file
278
dashboard/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
dashboard/src/components/layout/Layout.tsx
Normal file
239
dashboard/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
dashboard/src/components/layout/LogoArea.tsx
Normal file
102
dashboard/src/components/layout/LogoArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
dashboard/src/components/layout/NavItem.tsx
Normal file
81
dashboard/src/components/layout/NavItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
dashboard/src/components/layout/Sidebar.tsx
Normal file
103
dashboard/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
dashboard/src/components/layout/constants.ts
Normal file
46
dashboard/src/components/layout/constants.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
2
dashboard/src/components/layout/index.ts
Normal file
2
dashboard/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Layout } from './Layout'
|
||||
export type { LayoutProps, MenuItem, MenuSection } from './types'
|
||||
21
dashboard/src/components/layout/types.ts
Normal file
21
dashboard/src/components/layout/types.ts
Normal 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[]
|
||||
}
|
||||
134
dashboard/src/components/markdown-renderer.tsx
Normal file
134
dashboard/src/components/markdown-renderer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal 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">
|
||||
支持按对象类型、hash、item_key、source 和预览内容检索
|
||||
</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>
|
||||
)
|
||||
}
|
||||
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal file
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal file
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
dashboard/src/components/memory/MemoryMiniTabs.tsx
Normal file
54
dashboard/src/components/memory/MemoryMiniTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal file
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
dashboard/src/components/memory/MemoryProgressIndicator.tsx
Normal file
130
dashboard/src/components/memory/MemoryProgressIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
303
dashboard/src/components/plugin-stats.tsx
Normal file
303
dashboard/src/components/plugin-stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
416
dashboard/src/components/restart-overlay.tsx
Normal file
416
dashboard/src/components/restart-overlay.tsx
Normal 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'
|
||||
349
dashboard/src/components/search-dialog.tsx
Normal file
349
dashboard/src/components/search-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
685
dashboard/src/components/share-pack-dialog.tsx
Normal file
685
dashboard/src/components/share-pack-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
dashboard/src/components/survey/index.ts
Normal file
8
dashboard/src/components/survey/index.ts
Normal 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'
|
||||
247
dashboard/src/components/survey/survey-question.tsx
Normal file
247
dashboard/src/components/survey/survey-question.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
407
dashboard/src/components/survey/survey-renderer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
292
dashboard/src/components/survey/survey-results.tsx
Normal file
292
dashboard/src/components/survey/survey-results.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
dashboard/src/components/theme-provider.tsx
Normal file
97
dashboard/src/components/theme-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
dashboard/src/components/tour/index.ts
Normal file
5
dashboard/src/components/tour/index.ts
Normal 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'
|
||||
4
dashboard/src/components/tour/tour-context.ts
Normal file
4
dashboard/src/components/tour/tour-context.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react'
|
||||
import type { TourContextType } from './types'
|
||||
|
||||
export const TourContext = createContext<TourContextType | null>(null)
|
||||
177
dashboard/src/components/tour/tour-provider.tsx
Normal file
177
dashboard/src/components/tour/tour-provider.tsx
Normal 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 存储 tours(Map 对象是可变的,可以直接修改)
|
||||
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>
|
||||
)
|
||||
}
|
||||
211
dashboard/src/components/tour/tour-renderer.tsx
Normal file
211
dashboard/src/components/tour/tour-renderer.tsx
Normal 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
|
||||
}
|
||||
202
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal file
202
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal 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'])
|
||||
)
|
||||
49
dashboard/src/components/tour/types.ts
Normal file
49
dashboard/src/components/tour/types.ts
Normal 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
|
||||
}
|
||||
10
dashboard/src/components/tour/use-tour.ts
Normal file
10
dashboard/src/components/tour/use-tour.ts
Normal 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
|
||||
}
|
||||
58
dashboard/src/components/ui/accordion.tsx
Normal file
58
dashboard/src/components/ui/accordion.tsx
Normal 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 }
|
||||
141
dashboard/src/components/ui/alert-dialog.tsx
Normal file
141
dashboard/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
60
dashboard/src/components/ui/alert.tsx
Normal file
60
dashboard/src/components/ui/alert.tsx
Normal 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 }
|
||||
83
dashboard/src/components/ui/announcer.tsx
Normal file
83
dashboard/src/components/ui/announcer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
dashboard/src/components/ui/avatar.tsx
Normal file
48
dashboard/src/components/ui/avatar.tsx
Normal 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 }
|
||||
37
dashboard/src/components/ui/badge.tsx
Normal file
37
dashboard/src/components/ui/badge.tsx
Normal 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 }
|
||||
58
dashboard/src/components/ui/button.tsx
Normal file
58
dashboard/src/components/ui/button.tsx
Normal 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 }
|
||||
211
dashboard/src/components/ui/calendar.tsx
Normal file
211
dashboard/src/components/ui/calendar.tsx
Normal 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 }
|
||||
29
dashboard/src/components/ui/card-with-background.tsx
Normal file
29
dashboard/src/components/ui/card-with-background.tsx
Normal 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'
|
||||
76
dashboard/src/components/ui/card.tsx
Normal file
76
dashboard/src/components/ui/card.tsx
Normal 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 }
|
||||
378
dashboard/src/components/ui/chart.tsx
Normal file
378
dashboard/src/components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
27
dashboard/src/components/ui/checkbox.tsx
Normal file
27
dashboard/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
11
dashboard/src/components/ui/collapsible.tsx
Normal file
11
dashboard/src/components/ui/collapsible.tsx
Normal 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 }
|
||||
152
dashboard/src/components/ui/command.tsx
Normal file
152
dashboard/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
197
dashboard/src/components/ui/context-menu.tsx
Normal file
197
dashboard/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
29
dashboard/src/components/ui/dialog-with-background.tsx
Normal file
29
dashboard/src/components/ui/dialog-with-background.tsx
Normal 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'
|
||||
183
dashboard/src/components/ui/dialog.tsx
Normal file
183
dashboard/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
200
dashboard/src/components/ui/dropdown-menu.tsx
Normal file
200
dashboard/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
82
dashboard/src/components/ui/extra-params-dialog.tsx
Normal file
82
dashboard/src/components/ui/extra-params-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
dashboard/src/components/ui/help-tooltip.tsx
Normal file
63
dashboard/src/components/ui/help-tooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
dashboard/src/components/ui/input.tsx
Normal file
22
dashboard/src/components/ui/input.tsx
Normal 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 }
|
||||
64
dashboard/src/components/ui/kbd.tsx
Normal file
64
dashboard/src/components/ui/kbd.tsx
Normal 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 }
|
||||
180
dashboard/src/components/ui/key-value-editor.tsx
Normal file
180
dashboard/src/components/ui/key-value-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
dashboard/src/components/ui/label.tsx
Normal file
24
dashboard/src/components/ui/label.tsx
Normal 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 }
|
||||
28
dashboard/src/components/ui/markdown.tsx
Normal file
28
dashboard/src/components/ui/markdown.tsx
Normal 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} />
|
||||
}
|
||||
259
dashboard/src/components/ui/multi-select.tsx
Normal file
259
dashboard/src/components/ui/multi-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
486
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal file
486
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
dashboard/src/components/ui/pagination.tsx
Normal file
118
dashboard/src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
31
dashboard/src/components/ui/popover.tsx
Normal file
31
dashboard/src/components/ui/popover.tsx
Normal 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 }
|
||||
26
dashboard/src/components/ui/progress.tsx
Normal file
26
dashboard/src/components/ui/progress.tsx
Normal 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 }
|
||||
41
dashboard/src/components/ui/radio-group.tsx
Normal file
41
dashboard/src/components/ui/radio-group.tsx
Normal 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 }
|
||||
57
dashboard/src/components/ui/scroll-area.tsx
Normal file
57
dashboard/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
158
dashboard/src/components/ui/select.tsx
Normal file
158
dashboard/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
29
dashboard/src/components/ui/separator.tsx
Normal file
29
dashboard/src/components/ui/separator.tsx
Normal 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 }
|
||||
15
dashboard/src/components/ui/skeleton.tsx
Normal file
15
dashboard/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
40
dashboard/src/components/ui/skip-nav.tsx
Normal file
40
dashboard/src/components/ui/skip-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
dashboard/src/components/ui/slider.tsx
Normal file
26
dashboard/src/components/ui/slider.tsx
Normal 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 }
|
||||
27
dashboard/src/components/ui/switch.tsx
Normal file
27
dashboard/src/components/ui/switch.tsx
Normal 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 }
|
||||
120
dashboard/src/components/ui/table.tsx
Normal file
120
dashboard/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
55
dashboard/src/components/ui/tabs.tsx
Normal file
55
dashboard/src/components/ui/tabs.tsx
Normal 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 }
|
||||
110
dashboard/src/components/ui/textarea.tsx
Normal file
110
dashboard/src/components/ui/textarea.tsx
Normal 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
Reference in New Issue
Block a user