上传完整的WebUI前端仓库
This commit is contained in:
126
dashboard/src/components/CodeEditor.tsx
Normal file
126
dashboard/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
||||
|
||||
export type Language = 'python' | 'json' | 'toml' | 'text'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languageExtensions: Record<Language, any[]> = {
|
||||
python: [python()],
|
||||
json: [json(), jsonParseLinter()],
|
||||
toml: [StreamLanguage.define(tomlMode)],
|
||||
text: [],
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'text',
|
||||
readOnly = false,
|
||||
height = '400px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder,
|
||||
theme = 'dark',
|
||||
className = '',
|
||||
}: CodeEditorProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-muted animate-pulse ${className}`}
|
||||
style={{ height, minHeight, maxHeight }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
theme={theme === '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>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
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.Provider value={value}>{children}</AnimationContext.Provider>
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
307
dashboard/src/components/error-boundary.tsx
Normal file
307
dashboard/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Component } from 'react'
|
||||
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 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" />
|
||||
已复制到剪贴板
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制错误信息
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 错误回退 UI
|
||||
function ErrorFallback({
|
||||
error,
|
||||
errorInfo,
|
||||
}: {
|
||||
error: Error
|
||||
errorInfo: ErrorInfo | null
|
||||
}) {
|
||||
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">页面出现了问题</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
应用程序遇到了意外错误。您可以尝试刷新页面或返回首页。
|
||||
</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" />
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button onClick={handleGoHome} variant="outline" className="flex-1">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<p className="text-xs text-center text-muted-foreground pt-2">
|
||||
如果问题持续存在,请将错误信息复制并反馈给开发者
|
||||
</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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1597
dashboard/src/components/expression-reviewer.tsx
Normal file
1597
dashboard/src/components/expression-reviewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
59
dashboard/src/components/http-warning-banner.tsx
Normal file
59
dashboard/src/components/http-warning-banner.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* HTTP 警告横幅组件
|
||||
* 当用户通过 HTTP 访问时显示安全警告
|
||||
*/
|
||||
export function HttpWarningBanner() {
|
||||
// 直接计算初始状态,避免 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">安全警告:</span>
|
||||
您正在使用 <strong>HTTP</strong> 访问 MaiBot WebUI
|
||||
</p>
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
|
||||
如果这是公网服务器,您的数据(包括 Token、聊天记录等)可能在传输过程中被窃取。强烈建议使用 HTTPS 访问或仅在本地网络使用。
|
||||
</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="关闭警告"
|
||||
>
|
||||
<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'
|
||||
409
dashboard/src/components/layout.tsx
Normal file
409
dashboard/src/components/layout.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
import { useTheme, toggleThemeWithTransition } from './use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatVersion } from '@/lib/version'
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
|
||||
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()
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 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 ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-muted-foreground">正在验证登录状态...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 菜单项配置 - 分块结构
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ icon: Home, label: '首页', path: '/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦配置编辑',
|
||||
items: [
|
||||
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
|
||||
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦资源管理',
|
||||
items: [
|
||||
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
|
||||
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
|
||||
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
|
||||
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
|
||||
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '扩展与监控',
|
||||
items: [
|
||||
{ icon: Package, label: '插件市场', path: '/plugins' },
|
||||
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
|
||||
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ icon: Settings, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="flex h-16 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 items-baseline gap-2",
|
||||
!sidebarOpen && "lg:hidden"
|
||||
)}>
|
||||
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
|
||||
<span className="text-xs text-primary/60 whitespace-nowrap">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 折叠时的 Logo - 仅桌面端显示 */}
|
||||
{!sidebarOpen && (
|
||||
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className={cn(
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
<nav 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">
|
||||
{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) => {
|
||||
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'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li key={item.path} 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={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
<HttpWarningBanner />
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
|
||||
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 年度总结入口 */}
|
||||
<Link to="/annual-report">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
|
||||
title="查看年度总结"
|
||||
>
|
||||
<PieChart className="h-4 w-4 text-pink-500" />
|
||||
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 年度总结</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">搜索...</span>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</Kbd>
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
|
||||
className="gap-2"
|
||||
title="查看麦麦文档"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">麦麦文档</span>
|
||||
</Button>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, setTheme, e)
|
||||
}}
|
||||
className="rounded-lg p-2 hover:bg-accent"
|
||||
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
title="登出系统"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-hidden bg-background">{children}</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
302
dashboard/src/components/plugin-stats.tsx
Normal file
302
dashboard/src/components/plugin-stats.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 插件统计组件
|
||||
* 显示点赞、点踩、评分和下载量
|
||||
*/
|
||||
|
||||
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 className="text-sm font-medium mb-2 block">评论(可选)</label>
|
||||
<Textarea
|
||||
value={userComment}
|
||||
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>
|
||||
)
|
||||
}
|
||||
412
dashboard/src/components/restart-overlay.tsx
Normal file
412
dashboard/src/components/restart-overlay.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 重启遮罩层组件
|
||||
*
|
||||
* 用于显示重启进度和状态,阻止用户操作
|
||||
*
|
||||
* 使用方式 1: 配合 RestartProvider(推荐)
|
||||
* <RestartProvider>
|
||||
* <App />
|
||||
* <RestartOverlay />
|
||||
* </RestartProvider>
|
||||
*
|
||||
* 使用方式 2: 独立使用
|
||||
* <RestartOverlay
|
||||
* visible={true}
|
||||
* onComplete={() => navigate('/auth')}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
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,
|
||||
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 ?? '准备重启',
|
||||
description: customDescription ?? '正在发送重启请求...',
|
||||
tip: '🔄 正在准备重启麦麦...',
|
||||
},
|
||||
restarting: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: customTitle ?? '正在重启麦麦',
|
||||
description: customDescription ?? '请稍候,麦麦正在重启中...',
|
||||
tip: '🔄 配置已保存,正在重启主程序...',
|
||||
},
|
||||
checking: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: '检查服务状态',
|
||||
description: `等待服务恢复... (${checkAttempts}/${maxAttempts})`,
|
||||
tip: '⏳ 正在等待服务恢复,请勿关闭页面...',
|
||||
},
|
||||
success: {
|
||||
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
|
||||
title: '重启成功',
|
||||
description: '正在跳转到登录页面...',
|
||||
tip: '✅ 配置已生效,服务运行正常',
|
||||
},
|
||||
failed: {
|
||||
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
|
||||
title: '重启超时',
|
||||
description: '服务未能在预期时间内恢复',
|
||||
tip: '⚠️ 如果长时间无响应,请尝试手动重启',
|
||||
},
|
||||
}
|
||||
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
|
||||
|
||||
// 回调处理
|
||||
useEffect(() => {
|
||||
if (status === 'success' && onComplete) {
|
||||
onComplete()
|
||||
} else if (status === 'failed' && onFailed) {
|
||||
onFailed()
|
||||
}
|
||||
}, [status, onComplete, onFailed])
|
||||
|
||||
const config = getStatusConfig(
|
||||
status,
|
||||
checkAttempts,
|
||||
maxAttempts,
|
||||
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>已用时: {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" />
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button onClick={onRetry} variant="secondary" className="flex-1">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
重试检测
|
||||
</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'
|
||||
237
dashboard/src/components/search-dialog.tsx
Normal file
237
dashboard/src/components/search-dialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
{
|
||||
icon: Home,
|
||||
title: '首页',
|
||||
description: '查看仪表板概览',
|
||||
path: '/',
|
||||
category: '概览',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '麦麦主程序配置',
|
||||
description: '配置麦麦的核心设置',
|
||||
path: '/config/bot',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: '麦麦模型提供商配置',
|
||||
description: '配置模型提供商',
|
||||
path: '/config/modelProvider',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: '麦麦模型配置',
|
||||
description: '配置模型参数',
|
||||
path: '/config/model',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Smile,
|
||||
title: '表情包管理',
|
||||
description: '管理麦麦的表情包',
|
||||
path: '/resource/emoji',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: '表达方式管理',
|
||||
description: '管理麦麦的表达方式',
|
||||
path: '/resource/expression',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: UserCircle,
|
||||
title: '人物信息管理',
|
||||
description: '管理人物信息',
|
||||
path: '/resource/person',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: Hash,
|
||||
title: '黑话管理',
|
||||
description: '管理麦麦学习到的黑话和俚语',
|
||||
path: '/resource/jargon',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: '统计信息',
|
||||
description: '查看使用统计',
|
||||
path: '/statistics',
|
||||
category: '监控',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: '插件市场',
|
||||
description: '浏览和安装插件',
|
||||
path: '/plugins',
|
||||
category: '扩展',
|
||||
},
|
||||
{
|
||||
icon: FileSearch,
|
||||
title: '日志查看器',
|
||||
description: '查看系统日志',
|
||||
path: '/logs',
|
||||
category: '监控',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: '系统设置',
|
||||
description: '配置系统参数',
|
||||
path: '/settings',
|
||||
category: '系统',
|
||||
},
|
||||
]
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredItems = searchItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// 导航到页面
|
||||
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()
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
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">
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="sr-only">搜索</DialogTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索页面..."
|
||||
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-t">
|
||||
<ScrollArea className="h-[400px]">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
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 flex-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 ? '未找到匹配的页面' : '输入关键词开始搜索'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</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">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↑</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↓</kbd>
|
||||
导航
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
|
||||
选择
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
684
dashboard/src/components/share-pack-dialog.tsx
Normal file
684
dashboard/src/components/share-pack-dialog.tsx
Normal file
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* 分享 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,
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||
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 max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
分享配置模板
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
步骤 {step} / {totalSteps}:
|
||||
{step === 1 && '选择要分享的配置'}
|
||||
{step === 2 && '填写模板信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(85vh-220px)] pr-4">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<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
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={
|
||||
loading ||
|
||||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
|
||||
}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button 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>
|
||||
)
|
||||
}
|
||||
139
dashboard/src/components/theme-provider.tsx
Normal file
139
dashboard/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
// 应用保存的主题色
|
||||
useEffect(() => {
|
||||
const savedAccentColor = localStorage.getItem('accent-color')
|
||||
if (savedAccentColor) {
|
||||
const root = document.documentElement
|
||||
const colors = {
|
||||
blue: {
|
||||
hsl: '221.2 83.2% 53.3%',
|
||||
darkHsl: '217.2 91.2% 59.8%',
|
||||
gradient: null
|
||||
},
|
||||
purple: {
|
||||
hsl: '271 91% 65%',
|
||||
darkHsl: '270 95% 75%',
|
||||
gradient: null
|
||||
},
|
||||
green: {
|
||||
hsl: '142 71% 45%',
|
||||
darkHsl: '142 76% 36%',
|
||||
gradient: null
|
||||
},
|
||||
orange: {
|
||||
hsl: '25 95% 53%',
|
||||
darkHsl: '20 90% 48%',
|
||||
gradient: null
|
||||
},
|
||||
pink: {
|
||||
hsl: '330 81% 60%',
|
||||
darkHsl: '330 85% 70%',
|
||||
gradient: null
|
||||
},
|
||||
red: {
|
||||
hsl: '0 84% 60%',
|
||||
darkHsl: '0 90% 70%',
|
||||
gradient: null
|
||||
},
|
||||
|
||||
// 渐变色
|
||||
'gradient-sunset': {
|
||||
hsl: '15 95% 60%',
|
||||
darkHsl: '15 95% 65%',
|
||||
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
|
||||
},
|
||||
'gradient-ocean': {
|
||||
hsl: '200 90% 55%',
|
||||
darkHsl: '200 90% 60%',
|
||||
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
|
||||
},
|
||||
'gradient-forest': {
|
||||
hsl: '150 70% 45%',
|
||||
darkHsl: '150 75% 40%',
|
||||
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
|
||||
},
|
||||
'gradient-aurora': {
|
||||
hsl: '310 85% 65%',
|
||||
darkHsl: '310 90% 70%',
|
||||
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
|
||||
},
|
||||
'gradient-fire': {
|
||||
hsl: '15 95% 55%',
|
||||
darkHsl: '15 95% 60%',
|
||||
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
|
||||
},
|
||||
'gradient-twilight': {
|
||||
hsl: '250 90% 60%',
|
||||
darkHsl: '250 95% 65%',
|
||||
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
|
||||
},
|
||||
}
|
||||
|
||||
const selectedColor = colors[savedAccentColor as keyof typeof colors]
|
||||
if (selectedColor) {
|
||||
root.style.setProperty('--primary', selectedColor.hsl)
|
||||
|
||||
// 设置渐变(如果有)
|
||||
if (selectedColor.gradient) {
|
||||
root.style.setProperty('--primary-gradient', selectedColor.gradient)
|
||||
root.classList.add('has-gradient')
|
||||
} else {
|
||||
root.style.removeProperty('--primary-gradient')
|
||||
root.classList.remove('has-gradient')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
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.Provider
|
||||
value={{
|
||||
state,
|
||||
tours,
|
||||
registerTour,
|
||||
unregisterTour,
|
||||
startTour,
|
||||
stopTour,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
getCurrentSteps,
|
||||
handleJoyrideCallback,
|
||||
isTourCompleted,
|
||||
markTourCompleted,
|
||||
resetTourCompleted,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TourContext.Provider>
|
||||
)
|
||||
}
|
||||
217
dashboard/src/components/tour/tour-renderer.tsx
Normal file
217
dashboard/src/components/tour/tour-renderer.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
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: {
|
||||
zIndex: 10000,
|
||||
primaryColor: 'hsl(var(--primary))',
|
||||
textColor: 'hsl(var(--foreground))',
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
arrowColor: 'hsl(var(--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(--primary))',
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
borderRadius: 'calc(var(--radius) - 2px)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.5rem 1rem',
|
||||
},
|
||||
buttonBack: {
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
fontSize: '0.875rem',
|
||||
marginRight: '0.5rem',
|
||||
},
|
||||
buttonSkip: {
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
buttonClose: {
|
||||
color: 'hsl(var(--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
|
||||
floaterProps={{
|
||||
styles: {
|
||||
floater: {
|
||||
zIndex: 99999,
|
||||
},
|
||||
},
|
||||
disableAnimation: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// 使用 Portal 渲染到高层容器
|
||||
if (portalElement) {
|
||||
return createPortal(joyrideElement, portalElement)
|
||||
}
|
||||
|
||||
return joyrideElement
|
||||
}
|
||||
244
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal file
244
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Step, Placement } from 'react-joyride'
|
||||
|
||||
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
|
||||
|
||||
// Tour 步骤定义
|
||||
export const modelAssignmentTourSteps: Step[] = [
|
||||
// Step 1: 全屏介绍
|
||||
{
|
||||
target: 'body',
|
||||
content: '本引导旨在帮助你配置模型提供商和对应的模型,并为麦麦的各个组件分配合适的模型。',
|
||||
placement: 'center' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 2: 侧边栏 - 模型提供商按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-provider"]',
|
||||
content: '第一步,你需要配置模型提供商。模型提供商决定了你要使用谁家的模型,无论是单一厂商(如 DeepSeek),还是模型平台(如 Siliconflow),都可以在这里进行配置。点击"下一步"进入配置页面。',
|
||||
placement: 'right' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 3: 添加提供商按钮
|
||||
{
|
||||
target: '[data-tour="add-provider-button"]',
|
||||
content: '点击"添加提供商"按钮,开始配置你的模型提供商。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 4: 添加提供商弹窗
|
||||
{
|
||||
target: '[data-tour="provider-dialog"]',
|
||||
content: '在这里,你可以选择你想要配置的模型提供商,填写相关信息后保存即可。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 5: 名称输入框
|
||||
{
|
||||
target: '[data-tour="provider-name-input"]',
|
||||
content: '这里的名称是你为这个模型提供商起的一个名字,方便你在后续使用时识别它。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 6: API 密钥输入框
|
||||
{
|
||||
target: '[data-tour="provider-apikey-input"]',
|
||||
content: '这里需要填写你从模型提供商那里获取的 API 密钥,用于验证和调用模型服务。对于不同的提供商,获取 API 密钥的方式可能有所不同,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 7: URL 输入框
|
||||
{
|
||||
target: '[data-tour="provider-url-input"]',
|
||||
content: '这里需要填写模型提供商的 API 访问地址,确保填写正确以便系统能够连接到模型服务。对于不同的提供商,API 地址可能有所不同,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 8: 模板选择下拉框
|
||||
{
|
||||
target: '[data-tour="provider-template-select"]',
|
||||
content: '当然,如果你不知道如何填写这些信息,很多模型提供商在这里都提供了预设的模板供你选择,选择对应的模板后,相关信息会自动填充。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 9: 保存按钮
|
||||
{
|
||||
target: '[data-tour="provider-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型提供商就配置完成了。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 10: 取消按钮
|
||||
{
|
||||
target: '[data-tour="provider-cancel-button"]',
|
||||
content: '因为这次咱们什么都没有填写,所以点击取消按钮退出吧。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 11: 侧边栏 - 模型管理与分配按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-management"]',
|
||||
content: '配置好模型提供商后,接下来我们需要为麦麦添加模型并分配功能。点击"下一步"进入模型管理页面。',
|
||||
placement: 'right' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 12: 添加模型按钮
|
||||
{
|
||||
target: '[data-tour="add-model-button"]',
|
||||
content: '在为麦麦的组件分配模型之前,首先需要添加你想要分配的模型,点击"添加模型"按钮开始添加。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 13: 添加模型弹窗
|
||||
{
|
||||
target: '[data-tour="model-dialog"]',
|
||||
content: '在这里,你可以选择你之前配置好的模型提供商,然后选择对应的模型来添加。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 14: 模型名称输入框
|
||||
{
|
||||
target: '[data-tour="model-name-input"]',
|
||||
content: '这里的模型名称是你为这个模型起的一个名字,方便你在后续使用时识别它。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 15: API 提供商下拉框
|
||||
{
|
||||
target: '[data-tour="model-provider-select"]',
|
||||
content: '在这里选择你之前配置好的模型提供商,这样系统才能知道你要添加哪个提供商的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 16: 模型标识符输入框
|
||||
{
|
||||
target: '[data-tour="model-identifier-input"]',
|
||||
content: '这里需要填写你想要添加的模型的标识符,不同的模型提供商可能有不同的标识符格式,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 17: 保存按钮
|
||||
{
|
||||
target: '[data-tour="model-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型就添加完成了。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 18: 取消按钮
|
||||
{
|
||||
target: '[data-tour="model-cancel-button"]',
|
||||
content: '当然,因为这次咱们什么都没有填写,所以直接点击取消按钮退出吧,等你准备好了再来添加模型。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 19: 为模型分配功能标签页
|
||||
{
|
||||
target: '[data-tour="tasks-tab-trigger"]',
|
||||
content: '最后一步,添加好模型后,切换到"为模型分配功能"标签页,为麦麦的各个组件分配合适的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 20: 组件模型卡片的模型选择
|
||||
{
|
||||
target: '[data-tour="task-model-select"]',
|
||||
content: '在这里,你可以为每个组件选择一个或多个合适的模型,选择完成后配置会自动保存。恭喜你完成了模型配置的学习!',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 需要用户点击才能继续的步骤索引(0-based)
|
||||
// Step 2 (index 2): 点击添加提供商按钮
|
||||
// Step 9 (index 9): 点击取消按钮关闭提供商弹窗
|
||||
// Step 11 (index 11): 点击添加模型按钮
|
||||
// Step 17 (index 17): 点击取消按钮关闭模型弹窗
|
||||
// Step 18 (index 18): 点击标签页切换
|
||||
export const CLICK_TO_CONTINUE_STEPS = new Set([2, 9, 11, 17, 18])
|
||||
|
||||
// 步骤与路由的映射
|
||||
export const STEP_ROUTE_MAP: Record<number, string> = {
|
||||
0: '/config/model', // 起始页面
|
||||
1: '/config/model', // 侧边栏可见
|
||||
2: '/config/modelProvider', // 需要在模型提供商页面
|
||||
3: '/config/modelProvider',
|
||||
4: '/config/modelProvider',
|
||||
5: '/config/modelProvider',
|
||||
6: '/config/modelProvider',
|
||||
7: '/config/modelProvider',
|
||||
8: '/config/modelProvider',
|
||||
9: '/config/modelProvider',
|
||||
10: '/config/modelProvider',
|
||||
11: '/config/model', // 需要在模型管理页面
|
||||
12: '/config/model',
|
||||
13: '/config/model',
|
||||
14: '/config/model',
|
||||
15: '/config/model',
|
||||
16: '/config/model',
|
||||
17: '/config/model',
|
||||
18: '/config/model',
|
||||
19: '/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,
|
||||
}
|
||||
59
dashboard/src/components/ui/alert.tsx
Normal file
59
dashboard/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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) => (
|
||||
<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 }
|
||||
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 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 }
|
||||
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.Provider 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.Provider>
|
||||
)
|
||||
})
|
||||
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 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-default 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,
|
||||
}
|
||||
131
dashboard/src/components/ui/dialog.tsx
Normal file
131
dashboard/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.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
|
||||
)}
|
||||
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
{...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">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
76
dashboard/src/components/ui/extra-params-dialog.tsx
Normal file
76
dashboard/src/components/ui/extra-params-dialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { 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)
|
||||
|
||||
// 当对话框打开状态改变时的处理
|
||||
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 }
|
||||
43
dashboard/src/components/ui/kbd.tsx
Normal file
43
dashboard/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
export { Kbd }
|
||||
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) => {
|
||||
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 as any)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
475
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal file
475
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } 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 syncToParent = useCallback(
|
||||
(newNodes: TreeNode[]) => {
|
||||
setNodes(newNodes)
|
||||
onChange(treeToRecord(newNodes))
|
||||
},
|
||||
[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>
|
||||
)
|
||||
}
|
||||
117
dashboard/src/components/ui/pagination.tsx
Normal file
117
dashboard/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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) => (
|
||||
<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 }
|
||||
51
dashboard/src/components/ui/scroll-area.tsx
Normal file
51
dashboard/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, viewportRef, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<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-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
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 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-default 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 }
|
||||
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 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 }
|
||||
142
dashboard/src/components/ui/toast.tsx
Normal file
142
dashboard/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
import { useIsMobile } from "@/hooks/use-media-query"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-[100] flex max-h-screen w-full gap-2 p-4",
|
||||
isMobile
|
||||
? "top-0 left-0 right-0 flex-col items-center"
|
||||
: "bottom-0 right-0 flex-col-reverse sm:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-primary/5 text-foreground backdrop-blur-sm",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive/10 text-destructive-foreground backdrop-blur-sm",
|
||||
},
|
||||
position: {
|
||||
desktop: "data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-right data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-right data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-right",
|
||||
mobile: "data-[swipe=cancel]:translate-y-0 data-[swipe=end]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-top data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-top data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-top",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
position: "desktop",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
const isMobile = useIsMobile()
|
||||
const position = isMobile ? "mobile" : "desktop"
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant, position }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
dashboard/src/components/ui/toaster.tsx
Normal file
35
dashboard/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useIsMobile } from "@/hooks/use-media-query"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<ToastProvider swipeDirection={isMobile ? "up" : "right"}>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
30
dashboard/src/components/ui/tooltip.tsx
Normal file
30
dashboard/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
47
dashboard/src/components/use-theme.tsx
Normal file
47
dashboard/src/components/use-theme.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const toggleThemeWithTransition = (
|
||||
theme: 'dark' | 'light' | 'system',
|
||||
setTheme: (theme: 'dark' | 'light' | 'system') => void,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
// 检查是否禁用动画
|
||||
const animationsDisabled = document.documentElement.classList.contains('no-animations')
|
||||
|
||||
// 检查浏览器是否支持 View Transitions API
|
||||
if (!document.startViewTransition || animationsDisabled) {
|
||||
setTheme(theme)
|
||||
return
|
||||
}
|
||||
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
setTheme(theme)
|
||||
})
|
||||
|
||||
transition.ready.then(() => {
|
||||
// 始终在新内容层应用动画(z-index: 999)
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
|
||||
},
|
||||
{
|
||||
duration: 500,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
382
dashboard/src/components/waves-background.tsx
Normal file
382
dashboard/src/components/waves-background.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 生成一个固定的随机种子(在模块加载时生成一次)
|
||||
const NOISE_SEED = (() => {
|
||||
// 使用时间戳的一部分作为种子,但在开发环境中使用固定值以保持一致性
|
||||
if (import.meta.env.DEV) {
|
||||
return 42 // 开发环境使用固定种子
|
||||
}
|
||||
return Date.now() % 1000000
|
||||
})()
|
||||
|
||||
// Perlin Noise implementation
|
||||
class Noise {
|
||||
private grad3: number[][]
|
||||
private p: number[]
|
||||
private perm: number[]
|
||||
|
||||
constructor(seed = 0) {
|
||||
// Use seed to ensure deterministic noise (seed is used implicitly in shuffle)
|
||||
void seed
|
||||
this.grad3 = [
|
||||
[1, 1, 0],
|
||||
[-1, 1, 0],
|
||||
[1, -1, 0],
|
||||
[-1, -1, 0],
|
||||
[1, 0, 1],
|
||||
[-1, 0, 1],
|
||||
[1, 0, -1],
|
||||
[-1, 0, -1],
|
||||
[0, 1, 1],
|
||||
[0, -1, 1],
|
||||
[0, 1, -1],
|
||||
[0, -1, -1],
|
||||
]
|
||||
this.p = []
|
||||
for (let i = 0; i < 256; i++) {
|
||||
this.p[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
this.perm = []
|
||||
for (let i = 0; i < 512; i++) {
|
||||
this.perm[i] = this.p[i & 255]
|
||||
}
|
||||
}
|
||||
|
||||
dot(g: number[], x: number, y: number) {
|
||||
return g[0] * x + g[1] * y
|
||||
}
|
||||
|
||||
mix(a: number, b: number, t: number) {
|
||||
return (1 - t) * a + t * b
|
||||
}
|
||||
|
||||
fade(t: number) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number) {
|
||||
const X = Math.floor(x) & 255
|
||||
const Y = Math.floor(y) & 255
|
||||
x -= Math.floor(x)
|
||||
y -= Math.floor(y)
|
||||
const u = this.fade(x)
|
||||
const v = this.fade(y)
|
||||
const A = this.perm[X] + Y
|
||||
const AA = this.perm[A]
|
||||
const AB = this.perm[A + 1]
|
||||
const B = this.perm[X + 1] + Y
|
||||
const BA = this.perm[B]
|
||||
const BB = this.perm[B + 1]
|
||||
|
||||
return this.mix(
|
||||
this.mix(
|
||||
this.dot(this.grad3[AA % 12], x, y),
|
||||
this.dot(this.grad3[BA % 12], x - 1, y),
|
||||
u
|
||||
),
|
||||
this.mix(
|
||||
this.dot(this.grad3[AB % 12], x, y - 1),
|
||||
this.dot(this.grad3[BB % 12], x - 1, y - 1),
|
||||
u
|
||||
),
|
||||
v
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
wave: { x: number; y: number }
|
||||
cursor: { x: number; y: number; vx: number; vy: number }
|
||||
}
|
||||
|
||||
export function WavesBackground() {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | undefined>(undefined)
|
||||
const [noiseInstance] = useState(() => new Noise(NOISE_SEED))
|
||||
|
||||
const dataRef = useRef<{
|
||||
mouse: {
|
||||
x: number
|
||||
y: number
|
||||
lx: number
|
||||
ly: number
|
||||
sx: number
|
||||
sy: number
|
||||
v: number
|
||||
vs: number
|
||||
a: number
|
||||
set: boolean
|
||||
}
|
||||
lines: Point[][]
|
||||
paths: SVGPathElement[]
|
||||
noise: Noise
|
||||
bounding: DOMRect | null
|
||||
}>({
|
||||
mouse: {
|
||||
x: -10,
|
||||
y: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
v: 0,
|
||||
vs: 0,
|
||||
a: 0,
|
||||
set: false,
|
||||
},
|
||||
lines: [],
|
||||
paths: [],
|
||||
noise: noiseInstance,
|
||||
bounding: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const svg = svgRef.current
|
||||
if (!container || !svg) return
|
||||
|
||||
const data = dataRef.current
|
||||
// 将 noiseInstance 赋值给 dataRef
|
||||
data.noise = noiseInstance
|
||||
|
||||
// Set size
|
||||
const setSize = () => {
|
||||
const bounding = container.getBoundingClientRect()
|
||||
data.bounding = bounding
|
||||
svg.style.width = `${bounding.width}px`
|
||||
svg.style.height = `${bounding.height}px`
|
||||
}
|
||||
|
||||
// Set lines
|
||||
const setLines = () => {
|
||||
if (!data.bounding) return
|
||||
|
||||
const { width, height } = data.bounding
|
||||
|
||||
data.lines = []
|
||||
data.paths.forEach((path) => path.remove())
|
||||
data.paths = []
|
||||
|
||||
const xGap = 10
|
||||
const yGap = 32
|
||||
|
||||
const oWidth = width + 200
|
||||
const oHeight = height + 30
|
||||
|
||||
const totalLines = Math.ceil(oWidth / xGap)
|
||||
const totalPoints = Math.ceil(oHeight / yGap)
|
||||
|
||||
const xStart = (width - xGap * totalLines) / 2
|
||||
const yStart = (height - yGap * totalPoints) / 2
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const points: Point[] = []
|
||||
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
const point: Point = {
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
|
||||
}
|
||||
points.push(point)
|
||||
}
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
svg.appendChild(path)
|
||||
data.paths.push(path)
|
||||
data.lines.push(points)
|
||||
}
|
||||
}
|
||||
|
||||
// Move points
|
||||
const movePoints = (time: number) => {
|
||||
const { lines, mouse, noise } = data
|
||||
|
||||
lines.forEach((points) => {
|
||||
points.forEach((p) => {
|
||||
// Wave movement
|
||||
const move =
|
||||
noise.perlin2((p.x + time * 0.0125) * 0.002, (p.y + time * 0.005) * 0.0015) * 12
|
||||
p.wave.x = Math.cos(move) * 32
|
||||
p.wave.y = Math.sin(move) * 16
|
||||
|
||||
// Mouse effect
|
||||
const dx = p.x - mouse.sx
|
||||
const dy = p.y - mouse.sy
|
||||
const d = Math.hypot(dx, dy)
|
||||
const l = Math.max(175, mouse.vs)
|
||||
|
||||
if (d < l) {
|
||||
const s = 1 - d / l
|
||||
const f = Math.cos(d * 0.001) * s
|
||||
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * 0.005
|
||||
p.cursor.vy += (0 - p.cursor.y) * 0.005
|
||||
|
||||
p.cursor.vx *= 0.925
|
||||
p.cursor.vy *= 0.925
|
||||
|
||||
p.cursor.x += p.cursor.vx * 2
|
||||
p.cursor.y += p.cursor.vy * 2
|
||||
|
||||
p.cursor.x = Math.min(100, Math.max(-100, p.cursor.x))
|
||||
p.cursor.y = Math.min(100, Math.max(-100, p.cursor.y))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Get moved point
|
||||
const moved = (point: Point, withCursorForce = true) => {
|
||||
const coords = {
|
||||
x: point.x + point.wave.x + (withCursorForce ? point.cursor.x : 0),
|
||||
y: point.y + point.wave.y + (withCursorForce ? point.cursor.y : 0),
|
||||
}
|
||||
coords.x = Math.round(coords.x * 10) / 10
|
||||
coords.y = Math.round(coords.y * 10) / 10
|
||||
return coords
|
||||
}
|
||||
|
||||
// Draw lines
|
||||
const drawLines = () => {
|
||||
const { lines, paths } = data
|
||||
|
||||
lines.forEach((points, lIndex) => {
|
||||
let p1 = moved(points[0], false)
|
||||
let d = `M ${p1.x} ${p1.y}`
|
||||
|
||||
points.forEach((point, pIndex) => {
|
||||
const isLast = pIndex === points.length - 1
|
||||
p1 = moved(point, !isLast)
|
||||
d += `L ${p1.x} ${p1.y}`
|
||||
})
|
||||
|
||||
paths[lIndex].setAttribute('d', d)
|
||||
})
|
||||
}
|
||||
|
||||
// Tick
|
||||
const tick = (time: number) => {
|
||||
const { mouse } = data
|
||||
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1
|
||||
|
||||
const dx = mouse.x - mouse.lx
|
||||
const dy = mouse.y - mouse.ly
|
||||
const d = Math.hypot(dx, dy)
|
||||
|
||||
mouse.v = d
|
||||
mouse.vs += (d - mouse.vs) * 0.1
|
||||
mouse.vs = Math.min(100, mouse.vs)
|
||||
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
|
||||
mouse.a = Math.atan2(dy, dx)
|
||||
|
||||
if (container) {
|
||||
container.style.setProperty('--x', `${mouse.sx}px`)
|
||||
container.style.setProperty('--y', `${mouse.sy}px`)
|
||||
}
|
||||
|
||||
movePoints(time)
|
||||
drawLines()
|
||||
|
||||
animationRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!data.bounding) return
|
||||
const { mouse } = data
|
||||
mouse.x = e.pageX - data.bounding.left
|
||||
mouse.y = e.pageY - data.bounding.top + window.scrollY
|
||||
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x
|
||||
mouse.sy = mouse.y
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
mouse.set = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
setSize()
|
||||
setLines()
|
||||
}
|
||||
|
||||
// Init
|
||||
setSize()
|
||||
setLines()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
animationRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [noiseInstance])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="waves-background"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="waves-cursor"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '0.5rem',
|
||||
height: '0.5rem',
|
||||
background: 'hsl(var(--primary) / 0.3)',
|
||||
borderRadius: '50%',
|
||||
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
||||
willChange: 'transform',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
path {
|
||||
fill: none;
|
||||
stroke: hsl(var(--primary) / 0.20);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
`}</style>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user