上传完整的WebUI前端仓库

This commit is contained in:
墨梓柒
2026-01-13 06:24:35 +08:00
parent a9187dc312
commit 812296590e
184 changed files with 47854 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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}
/>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View 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'

View 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>
)
}

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View 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
}

View 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',
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,378 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.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,
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,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 }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,259 @@
/**
* 多选下拉框组件
* 支持搜索、单击选择、标签展示、拖动排序
*/
import * as React from 'react'
import { X, Check, ChevronsUpDown, GripVertical } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
export interface MultiSelectOption {
label: string
value: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
emptyText?: string
className?: string
}
// 可排序的标签组件
function SortableBadge({
value,
label,
onRemove,
}: {
value: string
label: string
onRemove: (value: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// 处理删除按钮点击,阻止事件冒泡和默认行为
const handleRemoveClick = (e: React.MouseEvent) => {
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>
)
}

View 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>
)
}

View 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,
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full 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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View 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>
)
}

View 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 }

View 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)',
}
)
})
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { webuiFeedbackSurvey } from './webui-feedback'
export { maibotFeedbackSurvey } from './maibot-feedback'

View File

@@ -0,0 +1,103 @@
import type { SurveyConfig } from '@/types/survey'
export const maibotFeedbackSurvey: SurveyConfig = {
id: 'maibot-feedback-v1',
version: '1.0.0',
title: '麦麦使用体验反馈问卷',
description: '感谢您使用麦麦!您的反馈将帮助我们打造更好的 AI 伙伴。',
questions: [
{
id: 'maibot_version',
type: 'text',
title: '你正在使用的麦麦版本',
description: '此项由系统自动填写',
required: true,
readOnly: true,
placeholder: '自动检测中...',
},
{
id: 'improvement_areas',
type: 'textarea',
title: '你认为麦麦还有哪些部分可以改进?',
required: true,
placeholder: '请分享你认为可以改进的方面...',
maxLength: 1000,
},
{
id: 'problems_encountered',
type: 'multiple',
title: '你在使用麦麦时遇到过哪些问题?',
description: '可多选',
required: true,
options: [
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
{ id: 'slow_response', label: '响应速度慢', value: 'slow_response' },
{ id: 'complex', label: '操作复杂', value: 'complex' },
{ id: 'unstable', label: '运行不稳定', value: 'unstable' },
{ id: 'config_difficult', label: '配置困难', value: 'config_difficult' },
{ id: 'none', label: '没有遇到问题', value: 'none' },
{ id: 'other', label: '其他', value: 'other' },
],
},
{
id: 'problems_other',
type: 'text',
title: '如选择"其他",请说明遇到的问题',
required: false,
placeholder: '请描述你遇到的其他问题...',
maxLength: 500,
},
{
id: 'helpful_features',
type: 'textarea',
title: '你觉得麦麦的哪些功能对你最有帮助?',
required: true,
placeholder: '请分享对你最有帮助的功能...',
maxLength: 1000,
},
{
id: 'feature_requests',
type: 'textarea',
title: '你希望在未来的版本中增加哪些功能?',
required: true,
placeholder: '请告诉我们你期望的新功能...',
maxLength: 1000,
},
{
id: 'overall_satisfaction',
type: 'single',
title: '你对麦麦的整体满意度如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'would_recommend',
type: 'single',
title: '你愿意推荐麦麦给其他人使用吗?',
required: true,
options: [
{ id: 'yes', label: '是', value: 'yes' },
{ id: 'no', label: '否', value: 'no' },
],
},
{
id: 'other_suggestions',
type: 'textarea',
title: '其他建议或意见',
description: '此项为选填',
required: false,
placeholder: '如果你有任何其他想法或建议,请在此分享...',
maxLength: 2000,
},
],
settings: {
allowMultiple: false,
thankYouMessage: '感谢你的反馈!你的意见对麦麦的成长非常重要,我们会认真考虑每一条建议。',
},
}

View File

@@ -0,0 +1,107 @@
import type { SurveyConfig } from '@/types/survey'
export const webuiFeedbackSurvey: SurveyConfig = {
id: 'webui-feedback-v1',
version: '1.0.0',
title: '麦麦 WebUI 使用反馈问卷',
description: '感谢您使用麦麦 WebUI您的反馈将帮助我们不断改进产品体验。',
questions: [
{
id: 'webui_version',
type: 'text',
title: '你正在使用的 WebUI 版本',
description: '此项由系统自动填写',
required: true,
readOnly: true,
placeholder: '自动检测中...',
},
{
id: 'ui_design_satisfaction',
type: 'single',
title: '你觉得当前的 WebUI 界面设计如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'problems_encountered',
type: 'multiple',
title: '你在使用 WebUI 时遇到过哪些问题?',
description: '可多选',
required: true,
options: [
{ id: 'lag', label: '界面卡顿', value: 'lag' },
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
{ id: 'complex', label: '操作复杂', value: 'complex' },
{ id: 'bugs', label: '存在 Bug', value: 'bugs' },
{ id: 'none', label: '没有遇到问题', value: 'none' },
{ id: 'other', label: '其他', value: 'other' },
],
},
{
id: 'problems_other',
type: 'text',
title: '如选择"其他",请说明遇到的问题',
required: false,
placeholder: '请描述你遇到的其他问题...',
maxLength: 500,
},
{
id: 'useful_features',
type: 'textarea',
title: '你觉得哪些功能是最有用的?',
required: true,
placeholder: '请分享你认为最有价值的功能...',
maxLength: 1000,
},
{
id: 'feature_requests',
type: 'textarea',
title: '你希望在未来的版本中增加哪些功能?',
required: true,
placeholder: '请告诉我们你期望的新功能...',
maxLength: 1000,
},
{
id: 'overall_satisfaction',
type: 'single',
title: '你对麦麦 WebUI 的整体满意度如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'would_recommend',
type: 'single',
title: '你愿意推荐麦麦 WebUI 给其他人使用吗?',
required: true,
options: [
{ id: 'yes', label: '是', value: 'yes' },
{ id: 'no', label: '否', value: 'no' },
],
},
{
id: 'other_suggestions',
type: 'textarea',
title: '其他建议或意见',
description: '此项为选填',
required: false,
placeholder: '如果你有任何其他想法或建议,请在此分享...',
maxLength: 2000,
},
],
settings: {
allowMultiple: false,
thankYouMessage: '感谢你的反馈!你的意见对我们非常重要,我们会认真考虑每一条建议。',
},
}

View File

@@ -0,0 +1,12 @@
import { useContext } from 'react'
import { AnimationContext } from '@/lib/animation-context'
export const useAnimation = () => {
const context = useContext(AnimationContext)
if (context === undefined) {
throw new Error('useAnimation must be used within an AnimationProvider')
}
return context
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { checkAuthStatus } from '@/lib/fetch-with-auth'
export function useAuthGuard() {
const navigate = useNavigate()
const [checking, setChecking] = useState(true)
useEffect(() => {
let cancelled = false
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (!cancelled && !isAuth) {
navigate({ to: '/auth' })
}
} catch {
// 发生错误时也跳转到登录页
if (!cancelled) {
navigate({ to: '/auth' })
}
} finally {
if (!cancelled) {
setChecking(false)
}
}
}
verifyAuth()
return () => {
cancelled = true
}
}, [navigate])
return { checking }
}
/**
* 检查是否已认证(异步)
*/
export async function checkAuth(): Promise<boolean> {
return await checkAuthStatus()
}
/**
* 检查是否需要首次配置
*/
export async function checkFirstSetup(): Promise<boolean> {
try {
const response = await fetch('/api/webui/setup/status', {
method: 'GET',
credentials: 'include',
})
const data = await response.json()
if (response.ok) {
return data.is_first_setup
}
return false
} catch (error) {
console.error('检查首次配置状态失败:', error)
return false
}
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
})
useEffect(() => {
if (typeof window === 'undefined') {
return
}
const mediaQuery = window.matchMedia(query)
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
setMatches(mediaQuery.matches)
mediaQuery.addEventListener('change', handleChange)
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}, [query])
return matches
}
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)')
}

View File

@@ -0,0 +1,192 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 5
const TOAST_REMOVE_DELAY = 5000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = {
ADD_TOAST: "ADD_TOAST"
UPDATE_TOAST: "UPDATE_TOAST"
DISMISS_TOAST: "DISMISS_TOAST"
REMOVE_TOAST: "REMOVE_TOAST"
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

205
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,205 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* JetBrains Mono 字体 - 用于代码编辑器 */
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--primary-gradient: none; /* 默认无渐变 */
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--chart-1: 221.2 83.2% 53.3%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 210 40% 98%;
--primary-gradient: none; /* 默认无渐变 */
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 217.2 91.2% 59.8%;
--chart-2: 160 60% 50%;
--chart-3: 30 80% 60%;
--chart-4: 280 65% 65%;
--chart-5: 340 75% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* 隐藏数字输入框的默认上下箭头 */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
}
@layer utilities {
/* 渐变色背景工具类 */
.bg-primary-gradient {
background: var(--primary-gradient, hsl(var(--primary)));
}
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
.text-primary-gradient {
color: hsl(var(--primary));
}
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
.has-gradient .text-primary-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* 渐变色边框工具类 */
.border-primary-gradient {
border-image: var(--primary-gradient, linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary)))) 1;
}
}
/* 禁用动效时的样式 */
.no-animations *,
.no-animations *::before,
.no-animations *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* 保留基本的 hover 反馈 */
.no-animations *:hover {
transition-duration: 0.01ms !important;
}
/* View Transition API 动画 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 默认情况下(亮色→暗色),新内容在上层 */
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 999;
}
/* React Joyride Tour 样式 - 确保在 Dialog 之上 */
.__floater {
z-index: 99999 !important;
pointer-events: auto !important;
}
.react-joyride__overlay {
z-index: 99998 !important;
}
.react-joyride__spotlight {
z-index: 99998 !important;
}
/* Tour tooltip 内的按钮需要可点击 */
.react-joyride__tooltip {
pointer-events: auto !important;
}
#tour-portal-container * {
pointer-events: auto;
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}

26
dashboard/src/main.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import './index.css'
import { router } from './router'
import { ThemeProvider } from './components/theme-provider'
import { AnimationProvider } from './components/animation-provider'
import { TourProvider, TourRenderer } from './components/tour'
import { Toaster } from './components/ui/toaster'
import { ErrorBoundary } from './components/error-boundary'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<ThemeProvider defaultTheme="system">
<AnimationProvider>
<TourProvider>
<RouterProvider router={router} />
<TourRenderer />
<Toaster />
</TourProvider>
</AnimationProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
)

304
dashboard/src/router.tsx Normal file
View File

@@ -0,0 +1,304 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { IndexPage } from './routes/index'
import { SettingsPage } from './routes/settings'
import { AuthPage } from './routes/auth'
import { SetupPage } from './routes/setup'
import { NotFoundPage } from './routes/404'
import { BotConfigPage } from './routes/config/bot'
import { ModelProviderConfigPage } from './routes/config/modelProvider'
import { ModelConfigPage } from './routes/config/model'
import { AdapterConfigPage } from './routes/config/adapter'
import { EmojiManagementPage } from './routes/resource/emoji'
import { ExpressionManagementPage } from './routes/resource/expression'
import { JargonManagementPage } from './routes/resource/jargon'
import { PersonManagementPage } from './routes/person'
import { KnowledgeGraphPage } from './routes/resource/knowledge-graph'
import { KnowledgeBasePage } from './routes/resource/knowledge-base'
import { LogViewerPage } from './routes/logs'
import { PlannerMonitorPage } from './routes/monitor'
import { PluginsPage } from './routes/plugins'
import { ModelPresetsPage } from './routes/model-presets'
import { PluginConfigPage } from './routes/plugin-config'
import { PluginMirrorsPage } from './routes/plugin-mirrors'
import { PluginDetailPage } from './routes/plugin-detail'
import { ChatPage } from './routes/chat'
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
import { AnnualReportPage } from './routes/annual-report'
import PackMarketPage from './routes/config/pack-market'
import PackDetailPage from './routes/config/pack-detail'
import { Layout } from './components/layout'
import { checkAuth } from './hooks/use-auth'
import { RouteErrorBoundary } from './components/error-boundary'
// Root 路由
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && <TanStackRouterDevtools />}
</>
),
beforeLoad: () => {
// 如果访问根路径且未认证,重定向到认证页面
if (window.location.pathname === '/' && !checkAuth()) {
throw redirect({ to: '/auth' })
}
},
})
// 认证路由(无 Layout
const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthPage,
})
// 首次配置路由(无 Layout
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup',
component: SetupPage,
})
// 受保护的路由 Root带 Layout
const protectedRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'protected',
component: () => (
<Layout>
<Outlet />
</Layout>
),
errorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
})
// 首页路由
const indexRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/',
component: IndexPage,
})
// 配置路由 - 麦麦主程序配置
const botConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/bot',
component: BotConfigPage,
})
// 配置路由 - 麦麦模型提供商配置
const modelProviderConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/modelProvider',
component: ModelProviderConfigPage,
})
// 配置路由 - 麦麦模型配置
const modelConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/model',
component: ModelConfigPage,
})
// 配置路由 - 麦麦适配器配置
const adapterConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/adapter',
component: AdapterConfigPage,
})
// 资源管理路由 - 表情包管理
const emojiManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/emoji',
component: EmojiManagementPage,
})
// 资源管理路由 - 表达方式管理
const expressionManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/expression',
component: ExpressionManagementPage,
})
// 资源管理路由 - 人物信息管理
const personManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/person',
component: PersonManagementPage,
})
// 资源管理路由 - 黑话管理
const jargonManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/jargon',
component: JargonManagementPage,
})
// 资源管理路由 - 知识库图谱可视化
const knowledgeGraphRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/knowledge-graph',
component: KnowledgeGraphPage,
})
// 资源管理路由 - 麦麦知识库管理
const knowledgeBaseRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/knowledge-base',
component: KnowledgeBasePage,
})
// 日志查看器路由
const logsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/logs',
component: LogViewerPage,
})
// 计划器&恢复器监控路由
const plannerMonitorRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/planner-monitor',
component: PlannerMonitorPage,
})
// 本地聊天室路由
const chatRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/chat',
component: ChatPage,
})
// 插件市场路由
const pluginsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugins',
component: PluginsPage,
})
// 插件详情路由
const pluginDetailRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-detail',
component: PluginDetailPage,
})
// 模型分配预设市场路由
const modelPresetsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/model-presets',
component: ModelPresetsPage,
})
// 插件配置路由
const pluginConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-config',
component: PluginConfigPage,
})
// 插件镜像源配置路由
const pluginMirrorsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-mirrors',
component: PluginMirrorsPage,
})
// 设置页路由
const settingsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/settings',
component: SettingsPage,
})
// 配置模板市场路由
const packMarketRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/pack-market',
component: PackMarketPage,
})
// 配置模板详情路由
export const packDetailRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/pack-market/$packId',
component: PackDetailPage,
})
// 问卷调查路由 - WebUI 反馈
const webuiFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/survey/webui-feedback',
component: WebUIFeedbackSurveyPage,
})
// 问卷调查路由 - 麦麦体验反馈
const maibotFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/survey/maibot-feedback',
component: MaiBotFeedbackSurveyPage,
})
// 年度报告路由
const annualReportRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/annual-report',
component: AnnualReportPage,
})
// 404 路由
const notFoundRoute = createRoute({
getParentRoute: () => rootRoute,
path: '*',
component: NotFoundPage,
})
// 路由树
const routeTree = rootRoute.addChildren([
authRoute,
setupRoute,
protectedRoute.addChildren([
indexRoute,
botConfigRoute,
modelProviderConfigRoute,
modelConfigRoute,
adapterConfigRoute,
emojiManagementRoute,
expressionManagementRoute,
jargonManagementRoute,
personManagementRoute,
knowledgeGraphRoute,
knowledgeBaseRoute,
pluginsRoute,
pluginDetailRoute,
modelPresetsRoute,
pluginConfigRoute,
pluginMirrorsRoute,
logsRoute,
plannerMonitorRoute,
chatRoute,
settingsRoute,
packMarketRoute,
packDetailRoute,
webuiFeedbackSurveyRoute,
maibotFeedbackSurveyRoute,
annualReportRoute,
]),
notFoundRoute,
])
// 创建路由器
export const router = createRouter({
routeTree,
defaultNotFoundComponent: NotFoundPage,
defaultErrorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
})
// 类型声明
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

View File

@@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router'
import { Home, Search, ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl text-center">
{/* 404 大标题 */}
<div className="relative mb-8">
<h1 className="text-[150px] font-black leading-none text-primary/10 select-none sm:text-[200px]">
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 text-primary/30 sm:h-24 sm:w-24" />
</div>
</div>
{/* 错误信息 */}
<div className="space-y-4 mb-8">
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
</h2>
<p className="text-base text-muted-foreground sm:text-lg max-w-md mx-auto">
访 URL
</p>
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button
size="lg"
onClick={() => navigate({ to: '/' })}
className="gap-2 w-full sm:w-auto"
>
<Home className="h-4 w-4" />
</Button>
<Button
size="lg"
variant="outline"
onClick={() => window.history.back()}
className="gap-2 w-full sm:w-auto"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
{/* 提示信息 */}
<div className="mt-12 pt-8 border-t border-border">
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,883 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { getAnnualReport, type AnnualReportData } from '@/lib/annual-report-api'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { toPng } from 'html-to-image'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import {
Clock,
Users,
Brain,
Smile,
Trophy,
Calendar,
MessageSquare,
Zap,
Moon,
Sun,
AtSign,
Heart,
Image as ImageIcon,
Bot,
Download,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// 颜色常量
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
// 动态比喻生成函数
function getOnlineHoursMetaphor(hours: number): string {
if (hours >= 8760) return "相当于全年无休7x24小时在线"
if (hours >= 5000) return "相当于一位全职员工的年工作时长"
if (hours >= 2000) return "相当于看完了 1000 部电影"
if (hours >= 1000) return "相当于环球飞行 80 次"
if (hours >= 500) return "相当于读完了 100 本书"
if (hours >= 100) return "相当于马拉松跑了 25 次"
return "虽然不多,但每一刻都很珍贵"
}
function getMidnightMetaphor(count: number): string {
if (count >= 1000) return "夜深人静时的知心好友"
if (count >= 500) return "午夜场的常客"
if (count >= 100) return "偶尔熬夜的小伙伴"
if (count >= 50) return "深夜有时也会陪你聊聊"
return "早睡早起,健康作息"
}
function getTokenMetaphor(tokens: number): string {
const millions = tokens / 1000000
if (millions >= 100) return "思考量堪比一座图书馆"
if (millions >= 50) return "相当于写了一部百科全书"
if (millions >= 10) return "脑细胞估计消耗了不少"
if (millions >= 1) return "也算是费了一番脑筋"
return "轻轻松松,游刃有余"
}
function getCostMetaphor(cost: number): string {
if (cost >= 1000) return "这钱够吃一年的泡面了"
if (cost >= 500) return "相当于买了一台游戏机"
if (cost >= 100) return "够请大家喝几杯奶茶"
if (cost >= 50) return "一顿火锅的钱"
if (cost >= 10) return "几杯咖啡的价格"
return "省钱小能手"
}
function getSilenceMetaphor(rate: number): string {
if (rate >= 80) return "沉默是金,惜字如金"
if (rate >= 60) return "话不多但句句到位"
if (rate >= 40) return "该说的时候才开口"
if (rate >= 20) return "能聊的都聊了"
return "话痨本痨,有问必答"
}
function getImageMetaphor(count: number): string {
if (count >= 10000) return "眼睛都快看花了"
if (count >= 5000) return "堪比专业摄影师的阅片量"
if (count >= 1000) return "看图小达人"
if (count >= 500) return "图片鉴赏家"
if (count >= 100) return "偶尔欣赏一下美图"
return "图片?有空再看"
}
function getRejectedMetaphor(count: number): string {
if (count >= 500) return "在不断的纠正中成长"
if (count >= 200) return "学习永无止境"
if (count >= 100) return "虚心接受,积极改正"
if (count >= 50) return "偶尔也会犯错"
if (count >= 10) return "表现还算不错"
return "完美表达,无需纠正"
}
function getExpensiveThinkingMetaphor(cost: number): string {
if (cost >= 1) return "这次思考的价值堪比一顿大餐!"
if (cost >= 0.5) return "为了这个问题,我可是认真思考了!"
if (cost >= 0.1) return "下了点功夫,值得的!"
if (cost >= 0.01) return "花了点小钱,但很值得"
return "小小思考,不足挂齿"
}
function getFavoriteReplyMetaphor(count: number, botName: string): string {
if (count >= 100) return "这句话简直是万能钥匙!"
if (count >= 50) return "百试不爽的经典回复"
if (count >= 20) return `${botName}的口头禅`
if (count >= 10) return "常用语录之一"
return "偶尔用用的小确幸"
}
function getNightOwlMetaphor(isNightOwl: boolean, midnightCount: number): string {
if (isNightOwl) {
if (midnightCount >= 1000) return "深夜的守护者,黑暗中的光芒"
if (midnightCount >= 500) return "月亮是我的好朋友"
if (midnightCount >= 100) return "越夜越精神,夜晚才是主场"
return "偶尔熬夜,享受宁静时光"
} else {
if (midnightCount <= 10) return "作息规律,健康生活的典范"
if (midnightCount <= 50) return "早睡早起,偶尔也会熬个夜"
return "虽然是早起鸟,但也会守候深夜"
}
}
function getBusiestDayMetaphor(count: number): string {
if (count >= 1000) return "忙到飞起,键盘都要冒烟了"
if (count >= 500) return "这天简直是话痨附体"
if (count >= 200) return "社交达人上线"
if (count >= 100) return "比平时活跃不少"
if (count >= 50) return "小忙一下"
return "还算轻松的一天"
}
export function AnnualReportPage() {
const [year] = useState(2025)
const [data, setData] = useState<AnnualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [error, setError] = useState<Error | null>(null)
const reportRef = useRef<HTMLDivElement>(null)
const { toast } = useToast()
const loadReport = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const result = await getAnnualReport(year)
setData(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('获取年度报告失败'))
} finally {
setIsLoading(false)
}
}, [year])
// 导出为图片
const handleExport = useCallback(async () => {
if (!reportRef.current || !data) return
setIsExporting(true)
toast({
title: '正在生成图片',
description: '请稍候...',
})
try {
const element = reportRef.current
// 获取当前主题的背景色
const computedStyle = getComputedStyle(document.documentElement)
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
? `hsl(${computedStyle.getPropertyValue('--background').trim()})`
: (document.documentElement.classList.contains('dark') ? '#0a0a0a' : '#ffffff')
// 保存原始样式
const originalWidth = element.style.width
const originalMaxWidth = element.style.maxWidth
// 临时设置固定宽度以去除左右空白
element.style.width = '1024px'
element.style.maxWidth = '1024px'
const dataUrl = await toPng(element, {
quality: 1,
pixelRatio: 2,
backgroundColor,
cacheBust: true,
filter: (node) => {
// 过滤掉导出按钮
if (node instanceof HTMLElement && node.hasAttribute('data-export-btn')) {
return false
}
return true
},
})
// 恢复原始样式
element.style.width = originalWidth
element.style.maxWidth = originalMaxWidth
// 创建下载链接
const link = document.createElement('a')
link.download = `${data.bot_name}_${data.year}_年度总结.png`
link.href = dataUrl
link.click()
toast({
title: '导出成功',
description: '年度报告已保存为图片',
})
} catch (err) {
console.error('导出图片失败:', err)
toast({
title: '导出失败',
description: '请重试',
variant: 'destructive',
})
} finally {
setIsExporting(false)
}
}, [data, toast])
useEffect(() => {
loadReport()
}, [loadReport])
if (isLoading) {
return <LoadingSkeleton />
}
if (error) {
return (
<div className="flex h-screen items-center justify-center text-red-500">
: {error.message}
</div>
)
}
if (!data) return null
return (
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50 p-4 md:p-8 print:p-0" ref={reportRef}>
<div className="mx-auto max-w-5xl space-y-8 print:space-y-4">
{/* 头部 Hero */}
<header className="relative overflow-hidden rounded-3xl bg-primary p-8 text-primary-foreground shadow-2xl print:rounded-none print:shadow-none">
{/* 导出按钮 */}
<div className="absolute right-4 top-4 z-20 print:hidden" data-export-btn>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
disabled={isExporting}
className="gap-2 bg-white/20 hover:bg-white/30 text-white border-white/30"
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Download className="h-4 w-4" />
</>
)}
</Button>
</div>
<div className="relative z-10 flex flex-col items-center text-center">
<Bot className="mb-4 h-16 w-16 animate-bounce" />
<h1 className="text-4xl font-bold tracking-tighter sm:text-6xl">
{data.bot_name} {data.year}
</h1>
<p className="mt-4 max-w-2xl text-lg opacity-90">
· Connection & Growth
</p>
<div className="mt-6 flex items-center gap-2 text-sm opacity-75">
<Calendar className="h-4 w-4" />
<span>: {data.generated_at}</span>
</div>
</div>
{/* 背景装饰 */}
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
</header>
{/* 维度一:时光足迹 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Clock className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度在线时长"
value={`${data.time_footprint.total_online_hours} 小时`}
description={getOnlineHoursMetaphor(data.time_footprint.total_online_hours)}
icon={<Clock className="h-4 w-4" />}
/>
<StatCard
title="最忙碌的一天"
value={data.time_footprint.busiest_day || 'N/A'}
description={getBusiestDayMetaphor(data.time_footprint.busiest_day_count)}
icon={<Calendar className="h-4 w-4" />}
/>
<StatCard
title="深夜互动 (0-4点)"
value={`${data.time_footprint.midnight_chat_count}`}
description={getMidnightMetaphor(data.time_footprint.midnight_chat_count)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="作息属性"
value={data.time_footprint.is_night_owl ? '夜猫子' : '早起鸟'}
description={getNightOwlMetaphor(data.time_footprint.is_night_owl, data.time_footprint.midnight_chat_count)}
icon={data.time_footprint.is_night_owl ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
/>
</div>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>24</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.time_footprint.hourly_distribution.map((count: number, hour: number) => ({ hour: `${hour}`, count }))}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
cursor={{ fill: 'transparent' }}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{data.time_footprint.first_message_time && (
<Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<p className="text-muted-foreground mb-2">2025</p>
<div className="text-xl font-bold text-primary mb-1">{data.time_footprint.first_message_time}</div>
<p className="text-lg">
<span className="font-semibold text-foreground">{data.time_footprint.first_message_user}</span>
<span className="italic text-muted-foreground">"{data.time_footprint.first_message_content}"</span>
</p>
</CardContent>
</Card>
)}
</section>
{/* 维度二:社交网络 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Users className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="社交圈子"
value={`${data.social_network.total_groups} 个群组`}
description={`${data.bot_name}加入的群组总数`}
icon={<Users className="h-4 w-4" />}
/>
<StatCard
title="被呼叫次数"
value={`${data.social_network.at_count + data.social_network.mentioned_count}`}
description="我的名字被大家频繁提起"
icon={<AtSign className="h-4 w-4" />}
/>
<StatCard
title="最长情陪伴"
value={data.social_network.longest_companion_user || 'N/A'}
description={`始终都在,已陪伴 ${data.social_network.longest_companion_days}`}
icon={<Heart className="h-4 w-4 text-red-500" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_groups.length > 0 ? (
data.social_network.top_groups.map((group: { group_id: string; group_name: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={group.group_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{group.group_name}</span>
{group.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{group.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_users.length > 0 ? (
data.social_network.top_users.map((user: { user_id: string; user_nickname: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={user.user_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{user.user_nickname}</span>
{user.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{user.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 维度三:最强大脑 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Brain className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度 Token 消耗"
value={(data.brain_power.total_tokens / 1000000).toFixed(2) + ' M'}
description={getTokenMetaphor(data.brain_power.total_tokens)}
icon={<Zap className="h-4 w-4" />}
/>
<StatCard
title="年度总花费"
value={`$${data.brain_power.total_cost.toFixed(2)}`}
description={getCostMetaphor(data.brain_power.total_cost)}
icon={<span className="font-bold">$</span>}
/>
<StatCard
title="高冷指数"
value={`${data.brain_power.silence_rate}%`}
description={getSilenceMetaphor(data.brain_power.silence_rate)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="最高兴趣值"
value={data.brain_power.max_interest_value ?? 'N/A'}
description={data.brain_power.max_interest_time ? `出现在 ${data.brain_power.max_interest_time}` : '暂无数据'}
icon={<Heart className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.model_distribution.slice(0, 5).map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.model_distribution[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* 最喜欢的回复模型 TOP5 */}
{data.brain_power.top_reply_models && data.brain_power.top_reply_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.top_reply_models.map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.top_reply_models[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* 烧钱大户 - 只有有有效用户数据时才显示 */}
{data.brain_power.top_token_consumers && data.brain_power.top_token_consumers.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP3</CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{data.brain_power.top_token_consumers.map((consumer: { user_id: string; cost: number; tokens: number }) => (
<div key={consumer.user_id} className="space-y-2">
<div className="flex justify-between text-sm font-medium">
<span> {consumer.user_id}</span>
<span>${consumer.cost.toFixed(2)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(consumer.cost / (data.brain_power.top_token_consumers[0]?.cost || 1)) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* 最昂贵的思考 & 思考深度 */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💰</span>
</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-4xl font-bold text-amber-600 dark:text-amber-400">
${data.brain_power.most_expensive_cost.toFixed(4)}
</div>
{data.brain_power.most_expensive_time && (
<p className="mt-2 text-sm text-muted-foreground">
{data.brain_power.most_expensive_time}
</p>
)}
<p className="mt-4 text-sm text-muted-foreground">
{getExpensiveThinkingMetaphor(data.brain_power.most_expensive_cost)}
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/20 dark:to-blue-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🧠</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{data.brain_power.avg_reasoning_length?.toFixed(0) || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{data.brain_power.max_reasoning_length?.toLocaleString() || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{data.brain_power.max_reasoning_time && (
<p className="mt-4 text-center text-xs text-muted-foreground">
{data.brain_power.max_reasoning_time}
</p>
)}
</CardContent>
</Card>
</div>
</section>
{/* 维度四:个性与表达 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Smile className="h-8 w-8" />
<h2></h2>
</div>
{/* 深夜回复 & 最喜欢的回复 */}
{(data.expression_vibe.late_night_reply || data.expression_vibe.favorite_reply) && (
<div className="grid gap-4 md:grid-cols-2">
{data.expression_vibe.late_night_reply && (
<Card className="bg-gradient-to-br from-indigo-50 to-violet-50 dark:from-indigo-950/20 dark:to-violet-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🌙</span>
</CardTitle>
<CardDescription> {data.expression_vibe.late_night_reply.time}{data.bot_name}...</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg italic text-muted-foreground">
"{data.expression_vibe.late_night_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
)}
{data.expression_vibe.favorite_reply && (
<Card className="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-rose-950/20 dark:to-pink-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💬</span>
</CardTitle>
<CardDescription>使 {data.expression_vibe.favorite_reply.count} </CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg font-medium text-primary">
"{data.expression_vibe.favorite_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
{getFavoriteReplyMetaphor(data.expression_vibe.favorite_reply.count, data.bot_name)}
</p>
</CardContent>
</Card>
)}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* 使用最多的表情包 TOP3 */}
<Card className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-950/20 dark:to-purple-950/20">
<CardHeader>
<CardTitle>使 TOP3</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{data.expression_vibe.top_emojis && data.expression_vibe.top_emojis.length > 0 ? (
<div className="flex justify-center gap-4">
{data.expression_vibe.top_emojis.slice(0, 3).map((emoji: { id: number; usage_count: number }, index: number) => (
<div key={emoji.id} className="flex flex-col items-center">
<div className="relative">
<img
src={`/api/webui/emoji/${emoji.id}/thumbnail?original=true`}
alt={`TOP ${index + 1}`}
className="h-24 w-24 rounded-lg object-cover shadow-md transition-transform hover:scale-105"
/>
<Badge
className={cn(
"absolute -top-2 -right-2",
index === 0 ? "bg-yellow-500" : index === 1 ? "bg-gray-400" : "bg-amber-700"
)}
>
{index + 1}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{emoji.usage_count} </p>
</div>
))}
</div>
) : (
<div className="flex h-32 items-center justify-center text-muted-foreground"></div>
)}
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{data.bot_name}使</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.expression_vibe.top_expressions.map((exp: { style: string; count: number }, index: number) => (
<Badge
key={exp.style}
variant="outline"
className={cn(
"px-3 py-1 text-sm",
index === 0 && "border-primary bg-primary/10 text-primary text-base px-4 py-2"
)}
>
{exp.style} ({exp.count})
</Badge>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<StatCard
title="图片鉴赏"
value={`${data.expression_vibe.image_processed_count}`}
description={getImageMetaphor(data.expression_vibe.image_processed_count)}
icon={<ImageIcon className="h-4 w-4" />}
/>
<StatCard
title="成长的足迹"
value={`${data.expression_vibe.rejected_expression_count}`}
description={getRejectedMetaphor(data.expression_vibe.rejected_expression_count)}
icon={<Zap className="h-4 w-4" />}
/>
</div>
</div>
</div>
{/* 行动派 */}
{data.expression_vibe.action_types.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.expression_vibe.action_types.map((action: { action: string; count: number }) => (
<div
key={action.action}
className="flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2"
>
<span className="font-medium text-primary">{action.action}</span>
<Badge variant="secondary">{action.count} </Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</section>
{/* 维度五:趣味成就 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Trophy className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="col-span-1 md:col-span-2">
<CardHeader>
<CardTitle>"黑话"</CardTitle>
<CardDescription> {data.achievements.new_jargon_count} </CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.achievements.sample_jargons.map((jargon: { content: string; meaning: string; count: number }) => (
<div key={jargon.content} className="group relative rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
<div className="font-bold text-primary">{jargon.content}</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2 max-w-[200px]">
{jargon.meaning || '暂无解释'}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="flex flex-col justify-center items-center bg-primary text-primary-foreground">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<MessageSquare className="h-12 w-12 mb-4 opacity-80" />
<div className="text-4xl font-bold mb-2">{data.achievements.total_messages.toLocaleString()}</div>
<div className="text-sm opacity-80"></div>
<div className="mt-4 text-xs opacity-60">
{data.achievements.total_replies.toLocaleString()}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 底部 */}
<footer className="mt-12 text-center text-muted-foreground">
<p>MaiBot 2025 Annual Report</p>
<p className="text-sm">Generated with by MaiBot Team</p>
</footer>
</div>
</div>
</ScrollArea>
)
}
function StatCard({
title,
value,
description,
icon,
}: {
title: string
value: string | number
description: string
icon: React.ReactNode
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function LoadingSkeleton() {
return (
<div className="container mx-auto space-y-8 p-8">
<Skeleton className="h-64 w-full rounded-3xl" />
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Key, Lock, AlertCircle, Moon, Sun, HelpCircle, FileText, Terminal, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { WavesBackground } from '@/components/waves-background'
import { useAnimation } from '@/hooks/use-animation'
import { useTheme } from '@/components/use-theme'
import { checkAuthStatus } from '@/lib/fetch-with-auth'
import { cn } from '@/lib/utils'
import { APP_FULL_NAME } from '@/lib/version'
export function AuthPage() {
const [token, setToken] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [error, setError] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
const navigate = useNavigate()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
// 如果已经认证,直接跳转到首页
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
navigate({ to: '/' })
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
}
}
verifyAuth()
}, [navigate])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
// 主题切换(无动画)
const toggleTheme = () => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!token.trim()) {
setError('请输入 Access Token')
return
}
setIsValidating(true)
console.log('开始验证 token...')
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: token.trim() }),
})
console.log('Token 验证响应状态:', response.status)
const data = await response.json()
console.log('Token 验证响应数据:', data)
if (response.ok && data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise(resolve => setTimeout(resolve, 100))
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
// 需要首次配置,跳转到配置向导
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
// 不需要配置或配置已完成,跳转到首页
navigate({ to: '/' })
}
} else {
console.error('Token 验证失败:', data.message)
setError(data.message || 'Token 验证失败,请检查后重试')
}
} catch (err) {
console.error('Token 验证错误:', err)
setError('连接服务器失败,请检查网络连接')
} finally {
setIsValidating(false)
}
}
// 正在检查认证状态时显示加载
if (checkingAuth) {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{enableWavesBackground && <WavesBackground />}
<div className="text-muted-foreground">...</div>
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{/* 波浪背景 - 独立控制 */}
{enableWavesBackground && <WavesBackground />}
{/* 认证卡片 - 磨砂玻璃效果 */}
<Card className="relative z-10 w-full max-w-md shadow-2xl backdrop-blur-xl bg-card/80 border-border/50">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-accent transition-colors z-10 text-foreground"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
>
{actualTheme === 'dark' ? (
<Sun className="h-5 w-5" strokeWidth={2.5} fill="none" />
) : (
<Moon className="h-5 w-5" strokeWidth={2.5} fill="none" />
)}
</button>
<CardHeader className="space-y-4 text-center">
{/* Logo/Icon */}
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Lock className="h-8 w-8 text-primary" strokeWidth={2} fill="none" />
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-bold">使 MaiBot</CardTitle>
<CardDescription className="text-base">
Access Token 访
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Token 输入框 */}
<div className="space-y-2">
<Label htmlFor="token" className="text-sm font-medium">
Access Token
</Label>
<div className="relative">
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" strokeWidth={2} fill="none" />
<Input
id="token"
type="password"
placeholder="请输入您的 Access Token"
value={token}
onChange={(e) => setToken(e.target.value)}
className={cn('pl-10', error && 'border-red-500 focus-visible:ring-red-500')}
disabled={isValidating}
autoFocus
autoComplete="off"
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span>{error}</span>
</div>
)}
{/* 提交按钮 */}
<Button type="submit" className="w-full" disabled={isValidating}>
{isValidating ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
'验证并进入'
)}
</Button>
{/* 帮助文本 */}
<Dialog>
<DialogTrigger asChild>
<button className="w-full text-center text-sm text-primary hover:text-primary/80 transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<HelpCircle className="h-4 w-4" strokeWidth={2} fill="none" />
Token Token
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
Access Token
</DialogTitle>
<DialogDescription>
Access Token 访 MaiBot WebUI
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 方式一:查看控制台 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<Terminal className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
MaiBot WebUI Access Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs">
<p className="text-muted-foreground">🔑 WebUI Access Token: abc123...</p>
<p className="text-muted-foreground">💡 使 Token WebUI</p>
</div>
</div>
</div>
</div>
{/* 方式二:查看配置文件 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs break-all">
<code className="text-primary">data/webui.json</code>
</div>
<p className="text-xs text-muted-foreground">
<code className="px-1 py-0.5 bg-background rounded">access_token</code>
</p>
</div>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token</li>
<li> Token</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 性能优化选项 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<Zap className="h-4 w-4" strokeWidth={2} fill="none" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => setEnableWavesBackground(false)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
</CardContent>
</Card>
{/* 页脚信息 */}
<div className="absolute bottom-4 left-0 right-0 text-center text-xs text-muted-foreground">
<p>{APP_FULL_NAME}</p>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
/**
* 适配器配置模块
*
* 模块结构:
* - types.ts: 类型定义和默认配置
* - utils.ts: TOML 解析和验证工具函数
*/
export * from './types'
export * from './utils'

View File

@@ -0,0 +1,105 @@
/**
* 适配器配置类型定义
*/
import { Package, Container } from 'lucide-react'
/**
* 完整的适配器配置接口
*/
export interface AdapterConfig {
inner: {
version: string
}
nickname: {
nickname: string
}
napcat_server: {
host: string
port: number
token: string
heartbeat_interval: number
}
maibot_server: {
host: string
port: number
}
chat: {
group_list_type: 'whitelist' | 'blacklist'
group_list: number[]
private_list_type: 'whitelist' | 'blacklist'
private_list: number[]
ban_user_id: number[]
ban_qq_bot: boolean
enable_poke: boolean
}
voice: {
use_tts: boolean
}
forward: {
image_threshold: number
}
debug: {
level: string
}
}
/**
* 默认配置
*/
export const DEFAULT_CONFIG: AdapterConfig = {
inner: {
version: '0.1.2',
},
nickname: {
nickname: '',
},
napcat_server: {
host: 'localhost',
port: 8095,
token: '',
heartbeat_interval: 30,
},
maibot_server: {
host: 'localhost',
port: 8000,
},
chat: {
group_list_type: 'whitelist',
group_list: [],
private_list_type: 'whitelist',
private_list: [],
ban_user_id: [],
ban_qq_bot: false,
enable_poke: true,
},
voice: {
use_tts: false,
},
forward: {
image_threshold: 30,
},
debug: {
level: 'INFO',
},
}
/**
* 预设配置定义
*/
export const PRESETS = {
oneclick: {
name: '一键包',
description: '使用一键包部署的适配器配置',
path: '../MaiBot-Napcat-Adapter/config.toml',
icon: Package,
},
docker: {
name: 'Docker',
description: 'Docker Compose 部署的适配器配置',
path: '/MaiMBot/adapters-config/config.toml',
icon: Container,
},
} as const
export type PresetKey = keyof typeof PRESETS

View File

@@ -0,0 +1,285 @@
/**
* 适配器配置 TOML 处理工具
* 使用 smol-toml 库进行可靠的 TOML 解析和生成
*/
import { parse, stringify } from 'smol-toml'
import type { AdapterConfig } from './types'
import { DEFAULT_CONFIG } from './types'
/**
* 解析 TOML 内容为配置对象
* @param content TOML 格式的字符串
* @returns 解析后的配置对象
* @throws 如果 TOML 格式无效
*/
export function parseTOML(content: string): AdapterConfig {
try {
const parsed = parse(content) as unknown as AdapterConfig
// 合并默认配置,确保所有必需字段都存在
return {
inner: { ...DEFAULT_CONFIG.inner, ...parsed.inner },
nickname: { ...DEFAULT_CONFIG.nickname, ...parsed.nickname },
napcat_server: { ...DEFAULT_CONFIG.napcat_server, ...parsed.napcat_server },
maibot_server: { ...DEFAULT_CONFIG.maibot_server, ...parsed.maibot_server },
chat: { ...DEFAULT_CONFIG.chat, ...parsed.chat },
voice: { ...DEFAULT_CONFIG.voice, ...parsed.voice },
forward: { ...DEFAULT_CONFIG.forward, ...parsed.forward },
debug: { ...DEFAULT_CONFIG.debug, ...parsed.debug },
}
} catch (error) {
console.error('TOML 解析失败:', error)
throw new Error(`无法解析 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 将配置对象转换为 TOML 格式字符串
* @param config 配置对象
* @returns TOML 格式的字符串
*/
export function generateTOML(config: AdapterConfig): string {
try {
// 填充默认值的辅助函数
const fillDefaults = <T>(value: T, defaultValue: T): T => {
if (value === '' || value === null || value === undefined) {
return defaultValue
}
return value
}
// 创建填充了默认值的配置副本
const filledConfig: AdapterConfig = {
inner: {
version: fillDefaults(config.inner.version, DEFAULT_CONFIG.inner.version),
},
nickname: {
nickname: fillDefaults(config.nickname.nickname, DEFAULT_CONFIG.nickname.nickname),
},
napcat_server: {
host: fillDefaults(config.napcat_server.host, DEFAULT_CONFIG.napcat_server.host),
port: fillDefaults(config.napcat_server.port || 0, DEFAULT_CONFIG.napcat_server.port),
token: fillDefaults(config.napcat_server.token, DEFAULT_CONFIG.napcat_server.token),
heartbeat_interval: fillDefaults(
config.napcat_server.heartbeat_interval || 0,
DEFAULT_CONFIG.napcat_server.heartbeat_interval
),
},
maibot_server: {
host: fillDefaults(config.maibot_server.host, DEFAULT_CONFIG.maibot_server.host),
port: fillDefaults(config.maibot_server.port || 0, DEFAULT_CONFIG.maibot_server.port),
},
chat: {
group_list_type: fillDefaults(config.chat.group_list_type, DEFAULT_CONFIG.chat.group_list_type),
group_list: config.chat.group_list || [],
private_list_type: fillDefaults(config.chat.private_list_type, DEFAULT_CONFIG.chat.private_list_type),
private_list: config.chat.private_list || [],
ban_user_id: config.chat.ban_user_id || [],
ban_qq_bot: config.chat.ban_qq_bot ?? DEFAULT_CONFIG.chat.ban_qq_bot,
enable_poke: config.chat.enable_poke ?? DEFAULT_CONFIG.chat.enable_poke,
},
voice: {
use_tts: config.voice.use_tts ?? DEFAULT_CONFIG.voice.use_tts,
},
forward: {
image_threshold: fillDefaults(
config.forward.image_threshold || 0,
DEFAULT_CONFIG.forward.image_threshold
),
},
debug: {
level: fillDefaults(config.debug.level, DEFAULT_CONFIG.debug.level),
},
}
// 使用 smol-toml 生成基础 TOML
let toml = stringify(filledConfig)
// 添加注释smol-toml 不支持注释,需要手动添加)
toml = addComments(toml)
return toml
} catch (error) {
console.error('TOML 生成失败:', error)
throw new Error(`无法生成 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 为生成的 TOML 添加注释
* @param toml 基础 TOML 字符串
* @returns 添加了注释的 TOML 字符串
*/
function addComments(toml: string): string {
const lines = toml.split('\n')
const result: string[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// [inner] section
if (line === '[inner]') {
result.push(line)
continue
}
if (line.startsWith('version = ')) {
result.push(`${line} # 版本号`)
result.push('# 请勿修改版本号,除非你知道自己在做什么')
continue
}
// [nickname] section
if (line === '[nickname]') {
result.push('[nickname] # 现在没用')
continue
}
// [napcat_server] section
if (line === '[napcat_server]') {
result.push('[napcat_server] # Napcat连接的ws服务设置')
continue
}
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[napcat_server]')) {
result.push(`${line} # Napcat设定的主机地址`)
continue
}
if (line.startsWith('port = ') && lines[i - 1]?.includes('host')) {
result.push(`${line} # Napcat设定的端口`)
continue
}
if (line.startsWith('token = ')) {
result.push(`${line} # Napcat设定的访问令牌若无则留空`)
continue
}
if (line.startsWith('heartbeat_interval = ')) {
result.push(`${line} # 与Napcat设置的心跳相同按秒计`)
continue
}
// [maibot_server] section
if (line === '[maibot_server]') {
result.push('[maibot_server] # 连接麦麦的ws服务设置')
continue
}
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[maibot_server]')) {
result.push(`${line} # 麦麦在.env文件中设置的主机地址即HOST字段`)
continue
}
if (line.startsWith('port = ') && result[result.length - 1]?.includes('麦麦在.env')) {
result.push(`${line} # 麦麦在.env文件中设置的端口即PORT字段`)
continue
}
// [chat] section
if (line === '[chat]') {
result.push('[chat] # 黑白名单功能')
continue
}
if (line.startsWith('group_list_type = ')) {
result.push(`${line} # 群组名单类型可选为whitelist, blacklist`)
continue
}
if (line.startsWith('group_list = ')) {
result.push(`${line} # 群组名单`)
result.push('# 当group_list_type为whitelist时只有群组名单中的群组可以聊天')
result.push('# 当group_list_type为blacklist时群组名单中的任何群组无法聊天')
continue
}
if (line.startsWith('private_list_type = ')) {
result.push(`${line} # 私聊名单类型可选为whitelist, blacklist`)
continue
}
if (line.startsWith('private_list = ')) {
result.push(`${line} # 私聊名单`)
result.push('# 当private_list_type为whitelist时只有私聊名单中的用户可以聊天')
result.push('# 当private_list_type为blacklist时私聊名单中的任何用户无法聊天')
continue
}
if (line.startsWith('ban_user_id = ')) {
result.push(`${line} # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)`)
continue
}
if (line.startsWith('ban_qq_bot = ')) {
result.push(`${line} # 是否屏蔽QQ官方机器人`)
continue
}
if (line.startsWith('enable_poke = ')) {
result.push(`${line} # 是否启用戳一戳功能`)
continue
}
// [voice] section
if (line === '[voice]') {
result.push('[voice] # 发送语音设置')
continue
}
if (line.startsWith('use_tts = ')) {
result.push(`${line} # 是否使用tts语音请确保你配置了tts并有对应的adapter`)
continue
}
// [forward] section
if (line === '[forward]') {
result.push('[forward] # 转发消息处理设置')
continue
}
if (line.startsWith('image_threshold = ')) {
result.push(`${line} # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)`)
continue
}
// [debug] section
if (line.startsWith('level = ') && result[result.length - 1] === '[debug]') {
result.push(`${line} # 日志等级DEBUG, INFO, WARNING, ERROR, CRITICAL`)
continue
}
result.push(line)
}
return result.join('\n')
}
/**
* 验证配置路径格式
* @param path 文件路径
* @returns 验证结果
*/
export function validatePath(path: string): { valid: boolean; error: string } {
if (!path.trim()) {
return { valid: false, error: '路径不能为空' }
}
if (!path.toLowerCase().endsWith('.toml')) {
return { valid: false, error: '文件必须是 .toml 格式' }
}
// 支持相对路径和绝对路径
// Windows 绝对路径: C:\path\to\file.toml 或 \\server\share\file.toml
const windowsPathRegex = /^([a-zA-Z]:\\|\\\\[^\\]+\\[^\\]+\\).+\.toml$/i
// Linux/Unix 绝对路径: /path/to/file.toml 或 ~/path/to/file.toml
const unixPathRegex = /^(\/|~\/).+\.toml$/i
// 相对路径: ./path/to/file.toml 或 ../path/to/file.toml 或 path/to/file.toml
const relativePathRegex = /^(\.{1,2}[\\/]|[^:\\/]).+\.toml$/i
const isWindows = windowsPathRegex.test(path)
const isUnix = unixPathRegex.test(path)
const isRelative = relativePathRegex.test(path)
if (!isWindows && !isUnix && !isRelative) {
return {
valid: false,
error: '路径格式错误',
}
}
// 检查路径中是否包含非法字符
// eslint-disable-next-line no-control-regex
const illegalChars = /[<>"|?*\x00-\x1F]/
if (illegalChars.test(path)) {
return { valid: false, error: '路径包含非法字符' }
}
return { valid: true, error: '' }
}

View File

@@ -0,0 +1,735 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
BotInfoSection,
PersonalitySection,
ChatSection,
DreamSection,
LPMMSection,
LogSection,
DebugSection,
ExperimentalSection,
MaimMessageSection,
TelemetrySection,
FeaturesSection,
ExpressionSection,
ProcessingSection,
MessageReceiveSection,
WebUISection,
} from './bot/sections'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Save, Power, Code2, Layout } from 'lucide-react'
import { getBotConfig, updateBotConfig, getBotConfigRaw, updateBotConfigRaw } from '@/lib/config-api'
import { useToast } from '@/hooks/use-toast'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Info } from 'lucide-react'
import { RestartOverlay } from '@/components/restart-overlay'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { CodeEditor } from '@/components'
import { parse as parseToml } from 'smol-toml'
// 导入模块化的类型定义
import type {
BotConfig,
PersonalityConfig,
ChatConfig,
ExpressionConfig,
EmojiConfig,
MemoryConfig,
ToolConfig,
VoiceConfig,
MessageReceiveConfig,
DreamConfig,
LPMMKnowledgeConfig,
KeywordReactionConfig,
ResponsePostProcessConfig,
ChineseTypoConfig,
ResponseSplitterConfig,
LogConfig,
DebugConfig,
ExperimentalConfig,
MaimMessageConfig,
TelemetryConfig,
WebUIConfig,
} from './bot/types'
// 导入 useAutoSave hook
import { useAutoSave, useConfigAutoSave } from './bot/hooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
// ==================== 常量定义 ====================
/** Toast 显示前的延迟时间 (毫秒) */
const TOAST_DISPLAY_DELAY = 500
// 主导出组件:包装 RestartProvider
export function BotConfigPage() {
return (
<RestartProvider>
<BotConfigPageContent />
</RestartProvider>
)
}
// 内部实现组件
function BotConfigPageContent() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
const [sourceCode, setSourceCode] = useState<string>('')
const [hasTomlError, setHasTomlError] = useState(false)
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
// 配置状态
const [botConfig, setBotConfig] = useState<BotConfig | null>(null)
const [personalityConfig, setPersonalityConfig] = useState<PersonalityConfig | null>(null)
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
const [expressionConfig, setExpressionConfig] = useState<ExpressionConfig | null>(null)
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig | null>(null)
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig | null>(null)
const [toolConfig, setToolConfig] = useState<ToolConfig | null>(null)
const [voiceConfig, setVoiceConfig] = useState<VoiceConfig | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<MessageReceiveConfig | null>(null)
const [dreamConfig, setDreamConfig] = useState<DreamConfig | null>(null)
const [lpmmConfig, setLpmmConfig] = useState<LPMMKnowledgeConfig | null>(null)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<KeywordReactionConfig | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ResponsePostProcessConfig | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ChineseTypoConfig | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ResponseSplitterConfig | null>(null)
const [logConfig, setLogConfig] = useState<LogConfig | null>(null)
const [debugConfig, setDebugConfig] = useState<DebugConfig | null>(null)
const [experimentalConfig, setExperimentalConfig] = useState<ExperimentalConfig | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<MaimMessageConfig | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<TelemetryConfig | null>(null)
const [webuiConfig, setWebuiConfig] = useState<WebUIConfig | null>(null)
// 用于标记初始加载和配置缓存
const initialLoadRef = useRef(true)
const configRef = useRef<Record<string, unknown>>({})
// ==================== 辅助函数 ====================
/**
* 翻译 TOML 错误信息为中文
*/
const translateTomlError = (errorMessage: string): string => {
// 分行处理,保留多行格式
const lines = errorMessage.split('\n')
// 翻译第一行(主要错误信息)
let firstLine = lines[0]
// 移除 "Error: " 前缀(如果有)
firstLine = firstLine.replace(/^Error:\s*/, '')
// 常见 TOML 错误模式匹配和翻译
const translations: Array<[RegExp, string | ((match: RegExpMatchArray) => string)]> = [
// Invalid TOML document 系列
[/Invalid TOML document: unrecognized escape sequence/, 'TOML 文档错误:无法识别的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠,或使用单引号字符串)'],
[/Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys/, 'TOML 文档错误:键名只能包含字母、数字、短横线和下划线'],
[/Invalid TOML document: (.+)/, 'TOML 文档错误:$1'],
// 位置错误系列
[/Unexpected character.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:意外的字符'],
[/Expected.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:缺少必要的字符'],
[/Invalid.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:无效的语法'],
[/Unterminated string at line (\d+)/, '第 $1 行:字符串未正常结束(缺少引号)'],
[/Duplicate key.*at line (\d+)/, '第 $1 行:重复的键名'],
[/Invalid escape sequence at line (\d+)/, '第 $1 行:无效的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠)'],
[/Expected.*but got.*at line (\d+)/, '第 $1 行:类型不匹配'],
[/line (\d+), column (\d+)/, '第 $1 行第 $2 列'],
// 通用错误系列
[/Unexpected end of input/, '意外的文件结束(可能缺少闭合符号)'],
[/Unexpected token/, '意外的标记'],
[/Invalid number/, '无效的数字'],
[/Invalid date/, '无效的日期格式'],
[/Invalid boolean/, '无效的布尔值(应为 true 或 false'],
[/Unexpected character/, '意外的字符'],
[/unrecognized escape sequence/, '无法识别的转义序列'],
]
// 尝试翻译第一行
for (const [pattern, replacement] of translations) {
if (pattern.test(firstLine)) {
firstLine = firstLine.replace(pattern, replacement as string)
break
}
}
// 重组多行错误信息
if (lines.length > 1) {
lines[0] = firstLine
return lines.join('\n')
}
return firstLine
}
/**
* 解析并设置所有配置状态
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
*/
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
configRef.current = config
setBotConfig(config.bot as BotConfig)
setPersonalityConfig(config.personality as PersonalityConfig)
// 确保 talk_value_rules 有默认值
const chatConfigData = config.chat as ChatConfig
if (!chatConfigData.talk_value_rules) {
chatConfigData.talk_value_rules = []
}
setChatConfig(chatConfigData)
setExpressionConfig(config.expression as ExpressionConfig)
setEmojiConfig(config.emoji as EmojiConfig)
setMemoryConfig(config.memory as MemoryConfig)
setToolConfig(config.tool as ToolConfig)
setVoiceConfig(config.voice as VoiceConfig)
setMessageReceiveConfig(config.message_receive as MessageReceiveConfig)
setDreamConfig(config.dream as DreamConfig)
setLpmmConfig(config.lpmm_knowledge as LPMMKnowledgeConfig)
setKeywordReactionConfig(config.keyword_reaction as KeywordReactionConfig)
setResponsePostProcessConfig(config.response_post_process as ResponsePostProcessConfig)
setChineseTypoConfig(config.chinese_typo as ChineseTypoConfig)
setResponseSplitterConfig(config.response_splitter as ResponseSplitterConfig)
setLogConfig(config.log as LogConfig)
setDebugConfig(config.debug as DebugConfig)
setExperimentalConfig(config.experimental as ExperimentalConfig)
setMaimMessageConfig(config.maim_message as MaimMessageConfig)
setTelemetryConfig(config.telemetry as TelemetryConfig)
setWebuiConfig(config.webui as WebUIConfig)
}, [])
/**
* 构建完整的配置对象用于保存
* 抽取自 saveConfig 和 handleSaveAndRestart 中的重复逻辑
*/
const buildFullConfig = useCallback(() => {
return {
...configRef.current,
bot: botConfig,
personality: personalityConfig,
chat: chatConfig,
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
tool: toolConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
dream: dreamConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
}
}, [
botConfig, personalityConfig, chatConfig, expressionConfig,
emojiConfig, memoryConfig, toolConfig,
voiceConfig, messageReceiveConfig, dreamConfig, lpmmConfig, keywordReactionConfig, responsePostProcessConfig,
chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, experimentalConfig,
maimMessageConfig, telemetryConfig, webuiConfig
])
// 加载源代码
const loadSourceCode = useCallback(async () => {
try {
const raw = await getBotConfigRaw()
// 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示
// 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串
const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => {
const decoded = content
.replace(/\\n/g, '\n') // 换行符
.replace(/\\t/g, '\t') // 制表符
.replace(/\\r/g, '\r') // 回车符
.replace(/\\"/g, '"') // 双引号
.replace(/\\\\/g, '\\') // 反斜杠(必须放在最后)
return `"${decoded}"`
})
setSourceCode(unescaped)
setHasTomlError(false)
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: error instanceof Error ? error.message : '加载源代码失败',
})
}
}, [toast])
// 加载配置
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const config = await getBotConfig()
parseAndSetConfig(config)
setHasUnsavedChanges(false)
initialLoadRef.current = false
// 同时加载源代码
await loadSourceCode()
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [toast, loadSourceCode, parseAndSetConfig])
useEffect(() => {
loadConfig()
}, [loadConfig])
// 使用模块化的 useAutoSave hook
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
initialLoadRef.current,
setAutoSaving,
setHasUnsavedChanges
)
// 使用 useConfigAutoSave hook 简化配置变化监听
// 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用
// 因此我们仍然需要逐个调用,但代码更简洁
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(toolConfig, 'tool', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(dreamConfig, 'dream', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
// 保存源代码
const saveSourceCode = async () => {
try {
setSaving(true)
// 前端验证 TOML 格式
try {
parseToml(sourceCode)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
const translatedMsg = translateTomlError(errorMsg)
setHasTomlError(true)
setTomlErrorMessage(translatedMsg)
toast({
variant: 'destructive',
title: 'TOML 格式错误',
description: translatedMsg,
})
setSaving(false)
return
}
// 将双引号字符串中的实际字符转换回 TOML 转义序列
// 使用正则表达式只处理双引号字符串内的内容,不影响单引号字符串
const escaped = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
const encoded = content
.replace(/\\/g, '\\\\') // 反斜杠(必须放在最前)
.replace(/"/g, '\\"') // 双引号
.replace(/\n/g, '\\n') // 换行符
.replace(/\t/g, '\\t') // 制表符
.replace(/\r/g, '\\r') // 回车符
return `"${encoded}"`
})
await updateBotConfigRaw(escaped)
setHasUnsavedChanges(false)
setHasTomlError(false)
setTomlErrorMessage('')
toast({
title: '保存成功',
description: '配置已保存',
})
// 重新加载可视化配置
await loadConfig()
} catch (error) {
setHasTomlError(true)
const errorMsg = error instanceof Error ? error.message : '保存配置失败'
setTomlErrorMessage(errorMsg)
toast({
variant: 'destructive',
title: '保存失败',
description: errorMsg,
})
} finally {
setSaving(false)
}
}
// 处理模式切换
const handleModeChange = async (mode: 'visual' | 'source') => {
if (hasUnsavedChanges) {
toast({
variant: 'destructive',
title: '切换失败',
description: '请先保存当前更改',
})
return
}
setEditMode(mode)
if (mode === 'source') {
await loadSourceCode()
} else {
// 切换回可视化时,直接重新加载配置但不显示全局 loading
try {
const config = await getBotConfig()
parseAndSetConfig(config)
setHasUnsavedChanges(false)
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
}
}
}
// 手动保存
const saveConfig = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
await updateBotConfig(buildFullConfig())
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '麦麦主程序配置已保存',
})
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
// 重启麦麦
const handleRestart = async () => {
await triggerRestart()
}
// 保存并重启
const handleSaveAndRestart = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
await updateBotConfig(buildFullConfig())
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '配置已保存,即将重启麦麦...',
})
// 等待一下让用户看到保存成功的提示
await new Promise(resolve => setTimeout(resolve, TOAST_DISPLAY_DELAY))
await handleRestart()
} catch (error) {
console.error('保存失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
if (loading) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
</div>
</ScrollArea>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div>
{/* 按钮组 - 桌面端靠右 */}
<div className="flex gap-2 flex-shrink-0">
<Button
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
>
<Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span className="ml-1 truncate text-xs sm:text-sm">
{saving ? '保存中' : autoSaving ? '自动' : hasUnsavedChanges ? '保存' : '已保存'}
</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-20 sm:w-28"
>
<Power className="h-4 w-4 flex-shrink-0" />
<span className="ml-1 truncate text-xs sm:text-sm">
{isRestarting ? '重启中' : hasUnsavedChanges ? '保存重启' : '重启'}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 模式切换 - 单独一行 */}
<div className="flex">
<Tabs value={editMode} onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full">
<TabsList className="h-8 sm:h-9 w-full grid grid-cols-2">
<TabsTrigger value="visual" className="text-xs sm:text-sm">
<Layout className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="source" className="text-xs sm:text-sm">
<Code2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{/* 源代码模式 */}
{editMode === 'source' && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong> TOML TOML
{hasTomlError && tomlErrorMessage && (
<div className="text-destructive font-semibold mt-3 p-3 bg-destructive/10 rounded-md">
<div className="font-bold mb-2"> TOML </div>
<pre className="text-sm font-mono whitespace-pre-wrap break-words">
{tomlErrorMessage}
</pre>
</div>
)}
</AlertDescription>
</Alert>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
setHasUnsavedChanges(true)
// 清除之前的错误状态
if (hasTomlError) {
setHasTomlError(false)
setTomlErrorMessage('')
}
}}
language="toml"
theme="dark"
height="calc(100vh - 280px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
</div>
)}
{/* 可视化模式 */}
{editMode === 'visual' && (
<>
{/* 标签页 */}
<Tabs defaultValue="bot" className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1 sm:grid sm:grid-cols-5 lg:grid-cols-10">
<TabsTrigger value="bot" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="personality" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="chat" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="expression" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="features" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="processing" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="dream" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="lpmm" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="webui" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">WebUI</TabsTrigger>
<TabsTrigger value="other" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="bot" className="space-y-4">
{botConfig && <BotInfoSection config={botConfig} onChange={setBotConfig} />}
</TabsContent>
{/* 人格配置 */}
<TabsContent value="personality" className="space-y-4">
{personalityConfig && (
<PersonalitySection config={personalityConfig} onChange={setPersonalityConfig} />
)}
</TabsContent>
{/* 聊天配置 */}
<TabsContent value="chat" className="space-y-4">
{chatConfig && <ChatSection config={chatConfig} onChange={setChatConfig} />}
</TabsContent>
{/* 表达配置 */}
<TabsContent value="expression" className="space-y-4">
{expressionConfig && (
<ExpressionSection config={expressionConfig} onChange={setExpressionConfig} />
)}
</TabsContent>
{/* 功能配置(合并表情、记忆、工具) */}
<TabsContent value="features" className="space-y-4">
{emojiConfig && memoryConfig && toolConfig && voiceConfig && (
<FeaturesSection
emojiConfig={emojiConfig}
memoryConfig={memoryConfig}
toolConfig={toolConfig}
voiceConfig={voiceConfig}
onEmojiChange={setEmojiConfig}
onMemoryChange={setMemoryConfig}
onToolChange={setToolConfig}
onVoiceChange={setVoiceConfig}
/>
)}
</TabsContent>
{/* 处理配置(关键词反应和回复后处理) */}
<TabsContent value="processing" className="space-y-4">
{keywordReactionConfig && responsePostProcessConfig && chineseTypoConfig && responseSplitterConfig && (
<ProcessingSection
keywordReactionConfig={keywordReactionConfig}
responsePostProcessConfig={responsePostProcessConfig}
chineseTypoConfig={chineseTypoConfig}
responseSplitterConfig={responseSplitterConfig}
onKeywordReactionChange={setKeywordReactionConfig}
onResponsePostProcessChange={setResponsePostProcessConfig}
onChineseTypoChange={setChineseTypoConfig}
onResponseSplitterChange={setResponseSplitterConfig}
/>
)}
{messageReceiveConfig && (
<MessageReceiveSection
config={messageReceiveConfig}
onChange={setMessageReceiveConfig}
/>
)}
</TabsContent>
{/* 做梦配置 */}
<TabsContent value="dream" className="space-y-4">
{dreamConfig && <DreamSection config={dreamConfig} onChange={setDreamConfig} />}
</TabsContent>
{/* 知识库配置 */}
<TabsContent value="lpmm" className="space-y-4">
{lpmmConfig && <LPMMSection config={lpmmConfig} onChange={setLpmmConfig} />}
</TabsContent>
{/* WebUI 配置 */}
<TabsContent value="webui" className="space-y-4">
{webuiConfig && <WebUISection config={webuiConfig} onChange={setWebuiConfig} />}
</TabsContent>
{/* 其他配置 */}
<TabsContent value="other" className="space-y-4">
{logConfig && <LogSection config={logConfig} onChange={setLogConfig} />}
{debugConfig && <DebugSection config={debugConfig} onChange={setDebugConfig} />}
{experimentalConfig && <ExperimentalSection config={experimentalConfig} onChange={setExperimentalConfig} />}
{maimMessageConfig && <MaimMessageSection config={maimMessageConfig} onChange={setMaimMessageConfig} />}
{telemetryConfig && <TelemetrySection config={telemetryConfig} onChange={setTelemetryConfig} />}
</TabsContent>
</Tabs>
</>
)}
{/* 重启遮罩层 */}
<RestartOverlay />
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,6 @@
/**
* Bot 配置页面相关 hooks
*/
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useCallback } from 'react'
import { updateBotConfigSection } from '@/lib/config-api'
import type { ConfigSectionName } from '../types'
export interface UseAutoSaveOptions {
/** 防抖延迟时间(毫秒),默认 2000ms */
debounceMs?: number
/** 保存成功回调 */
onSaveSuccess?: () => void
/** 保存失败回调 */
onSaveError?: (error: Error) => void
}
export interface UseAutoSaveReturn {
/** 触发自动保存 */
triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void
/** 立即保存(不防抖) */
saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise<void>
/** 取消待处理的自动保存 */
cancelPendingAutoSave: () => void
}
export interface AutoSaveState {
/** 是否正在保存中 */
isAutoSaving: boolean
/** 是否有未保存的更改 */
hasUnsavedChanges: boolean
}
/**
* 自动保存 hook
*
* 用于监听配置变化并自动防抖保存到后端
*
* @example
* ```tsx
* const { triggerAutoSave } = useAutoSave({
* isInitialLoad,
* setAutoSaving,
* setHasUnsavedChanges,
* })
*
* // 配置变化时触发
* useEffect(() => {
* if (config) triggerAutoSave('bot', config)
* }, [config])
* ```
*/
export function useAutoSave(
isInitialLoad: boolean,
setAutoSaving: (saving: boolean) => void,
setHasUnsavedChanges: (hasChanges: boolean) => void,
options: UseAutoSaveOptions = {}
): UseAutoSaveReturn {
const { debounceMs = 2000, onSaveSuccess, onSaveError } = options
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 执行保存操作
const saveSection = useCallback(
async (sectionName: ConfigSectionName, sectionData: unknown) => {
try {
setAutoSaving(true)
await updateBotConfigSection(sectionName, sectionData)
setHasUnsavedChanges(false)
onSaveSuccess?.()
} catch (error) {
console.error(`自动保存 ${sectionName} 失败:`, error)
setHasUnsavedChanges(true)
onSaveError?.(error instanceof Error ? error : new Error(String(error)))
} finally {
setAutoSaving(false)
}
},
[setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError]
)
// 触发自动保存(带防抖)
const triggerAutoSave = useCallback(
(sectionName: ConfigSectionName, sectionData: unknown) => {
if (isInitialLoad) return
setHasUnsavedChanges(true)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
autoSaveTimerRef.current = setTimeout(() => {
saveSection(sectionName, sectionData)
}, debounceMs)
},
[isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs]
)
// 立即保存(不防抖)
const saveNow = useCallback(
async (sectionName: ConfigSectionName, sectionData: unknown) => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = null
}
await saveSection(sectionName, sectionData)
},
[saveSection]
)
// 取消待处理的自动保存
const cancelPendingAutoSave = useCallback(() => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = null
}
}, [])
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [])
return {
triggerAutoSave,
saveNow,
cancelPendingAutoSave,
}
}
/**
* 创建配置自动保存 effect
*
* 这是一个工厂函数,用于创建监听特定配置变化并触发自动保存的 effect
* 简化重复的 useEffect 代码
*
* @example
* ```tsx
* // 使用方式 1: 直接在组件中调用
* useConfigAutoSave(botConfig, 'bot', isInitialLoad, triggerAutoSave)
* useConfigAutoSave(chatConfig, 'chat', isInitialLoad, triggerAutoSave)
*
* // 使用方式 2: 批量配置
* const configs = [
* { config: botConfig, section: 'bot' },
* { config: chatConfig, section: 'chat' },
* ] as const
*
* configs.forEach(({ config, section }) => {
* useConfigAutoSave(config, section, isInitialLoad, triggerAutoSave)
* })
* ```
*/
export function useConfigAutoSave<T>(
config: T | null,
sectionName: ConfigSectionName,
isInitialLoad: boolean,
triggerAutoSave: (sectionName: ConfigSectionName, data: unknown) => void
): void {
useEffect(() => {
if (config && !isInitialLoad) {
triggerAutoSave(sectionName, config)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config])
}

View File

@@ -0,0 +1,24 @@
/**
* Bot 配置模块
*
* 这个模块包含麦麦主程序配置页面的所有组件和类型
*
* 目录结构:
* - types.ts: 类型定义
* - hooks/: 自定义 hooks
* - useAutoSave.ts: 自动保存 hook
* - sections/: 各个配置区块组件
* - BotInfoSection.tsx
* - PersonalitySection.tsx
* - ChatSection.tsx
* - ...等
*/
// 类型导出
export * from './types'
// Hooks 导出
export * from './hooks'
// Section 组件导出
export * from './sections'

View File

@@ -0,0 +1,192 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2 } from 'lucide-react'
import type { BotConfig } from '../types'
interface BotInfoSectionProps {
config: BotConfig
onChange: (config: BotConfig) => void
}
export const BotInfoSection = React.memo(function BotInfoSection({ config, onChange }: BotInfoSectionProps) {
// 确保 platforms 和 alias_names 始终是数组
const platforms = config.platforms || []
const aliasNames = config.alias_names || []
const addPlatform = () => {
onChange({ ...config, platforms: [...platforms, ''] })
}
const removePlatform = (index: number) => {
onChange({
...config,
platforms: platforms.filter((_, i) => i !== index),
})
}
const updatePlatform = (index: number, value: string) => {
const newPlatforms = [...platforms]
newPlatforms[index] = value
onChange({ ...config, platforms: newPlatforms })
}
const addAlias = () => {
onChange({ ...config, alias_names: [...aliasNames, ''] })
}
const removeAlias = (index: number) => {
onChange({
...config,
alias_names: aliasNames.filter((_, i) => i !== index),
})
}
const updateAlias = (index: number, value: string) => {
const newAliases = [...aliasNames]
newAliases[index] = value
onChange({ ...config, alias_names: newAliases })
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="platform"></Label>
<Input
id="platform"
value={config.platform}
onChange={(e) => onChange({ ...config, platform: e.target.value })}
placeholder="qq"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="qq_account">QQ账号</Label>
<Input
id="qq_account"
value={config.qq_account}
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
placeholder="123456789"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname"></Label>
<Input
id="nickname"
value={config.nickname}
onChange={(e) => onChange({ ...config, nickname: e.target.value })}
placeholder="麦麦"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button onClick={addAlias} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{aliasNames.map((alias, index) => (
<div key={index} className="flex gap-2">
<Input
value={alias}
onChange={(e) => updateAlias(index, e.target.value)}
placeholder="小麦"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{alias || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeAlias(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{aliasNames.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button onClick={addPlatform} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{platforms.map((platform, index) => (
<div key={index} className="flex gap-2">
<Input
value={platform}
onChange={(e) => updatePlatform(index, e.target.value)}
placeholder="wx:114514"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{platform || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removePlatform(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{platforms.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,610 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, Eye, Clock } from 'lucide-react'
import type { ChatConfig } from '../types'
interface ChatSectionProps {
config: ChatConfig
onChange: (config: ChatConfig) => void
}
// 时间选择组件
const TimeRangePicker = React.memo(function TimeRangePicker({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) {
// 解析初始值
const parsedValue = useMemo(() => {
const parts = value.split('-')
if (parts.length === 2) {
const [start, end] = parts
const [sh, sm] = start.split(':')
const [eh, em] = end.split(':')
return {
startHour: sh ? sh.padStart(2, '0') : '00',
startMinute: sm ? sm.padStart(2, '0') : '00',
endHour: eh ? eh.padStart(2, '0') : '23',
endMinute: em ? em.padStart(2, '0') : '59',
}
}
return {
startHour: '00',
startMinute: '00',
endHour: '23',
endMinute: '59',
}
}, [value])
const [startHour, setStartHour] = useState(parsedValue.startHour)
const [startMinute, setStartMinute] = useState(parsedValue.startMinute)
const [endHour, setEndHour] = useState(parsedValue.endHour)
const [endMinute, setEndMinute] = useState(parsedValue.endMinute)
// 当value变化时同步状态
useEffect(() => {
setStartHour(parsedValue.startHour)
setStartMinute(parsedValue.startMinute)
setEndHour(parsedValue.endHour)
setEndMinute(parsedValue.endMinute)
}, [parsedValue])
const updateTime = (
newStartHour: string,
newStartMinute: string,
newEndHour: string,
newEndMinute: string
) => {
const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}`
onChange(newValue)
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start font-mono text-sm">
<Clock className="h-4 w-4 mr-2" />
{value || '选择时间段'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 sm:w-80">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-3"></h4>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div>
<Label className="text-xs"></Label>
<Select
value={startHour}
onValueChange={(v) => {
setStartHour(v)
updateTime(v, startMinute, endHour, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
{h.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={startMinute}
onValueChange={(v) => {
setStartMinute(v)
updateTime(startHour, v, endHour, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
{m.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-3"></h4>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div>
<Label className="text-xs"></Label>
<Select
value={endHour}
onValueChange={(v) => {
setEndHour(v)
updateTime(startHour, startMinute, v, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
{h.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={endMinute}
onValueChange={(v) => {
setEndMinute(v)
updateTime(startHour, startMinute, endHour, v)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
{m.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
})
// 预览窗口组件
const RulePreview = React.memo(function RulePreview({ rule }: { rule: { target: string; time: string; value: number } }) {
const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }`
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
{previewText}
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
})
export const ChatSection = React.memo(function ChatSection({ config, onChange }: ChatSectionProps) {
// 添加发言频率规则
const addTalkValueRule = () => {
onChange({
...config,
talk_value_rules: [
...config.talk_value_rules,
{ target: '', time: '00:00-23:59', value: 1.0 },
],
})
}
// 删除发言频率规则
const removeTalkValueRule = (index: number) => {
onChange({
...config,
talk_value_rules: config.talk_value_rules.filter((_, i) => i !== index),
})
}
// 更新发言频率规则
const updateTalkValueRule = (
index: number,
field: 'target' | 'time' | 'value',
value: string | number
) => {
const newRules = [...config.talk_value_rules]
newRules[index] = {
...newRules[index],
[field]: value,
}
onChange({
...config,
talk_value_rules: newRules,
})
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="talk_value"></Label>
<Input
id="talk_value"
type="number"
step="0.1"
min="0"
max="1"
value={config.talk_value}
onChange={(e) => onChange({ ...config, talk_value: parseFloat(e.target.value) })}
/>
<p className="text-xs text-muted-foreground"> 0-1</p>
</div>
<div className="grid gap-2">
<Label htmlFor="think_mode"></Label>
<Select
value={config.think_mode || 'classic'}
onValueChange={(value) => onChange({ ...config, think_mode: value as 'classic' | 'deep' | 'dynamic' })}
>
<SelectTrigger id="think_mode">
<SelectValue placeholder="选择思考模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic"> - </SelectItem>
<SelectItem value="deep"> - </SelectItem>
<SelectItem value="dynamic"> - </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="mentioned_bot_reply"
checked={config.mentioned_bot_reply}
onCheckedChange={(checked) =>
onChange({ ...config, mentioned_bot_reply: checked })
}
/>
<Label htmlFor="mentioned_bot_reply" className="cursor-pointer">
</Label>
</div>
<div className="grid gap-2">
<Label htmlFor="max_context_size"></Label>
<Input
id="max_context_size"
type="number"
min="1"
value={config.max_context_size}
onChange={(e) =>
onChange({ ...config, max_context_size: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="planner_smooth"></Label>
<Input
id="planner_smooth"
type="number"
step="1"
min="0"
value={config.planner_smooth}
onChange={(e) =>
onChange({ ...config, planner_smooth: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
planner 1-50
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="plan_reply_log_max_per_chat"></Label>
<Input
id="plan_reply_log_max_per_chat"
type="number"
step="1"
min="100"
value={config.plan_reply_log_max_per_chat ?? 1024}
onChange={(e) =>
onChange({ ...config, plan_reply_log_max_per_chat: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
Plan/Reply
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="llm_quote"
checked={config.llm_quote ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, llm_quote: checked })
}
/>
<Label htmlFor="llm_quote" className="cursor-pointer">
LLM
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2 ml-10">
LLM
</p>
<div className="flex items-center space-x-2">
<Switch
id="enable_talk_value_rules"
checked={config.enable_talk_value_rules}
onCheckedChange={(checked) =>
onChange({ ...config, enable_talk_value_rules: checked })
}
/>
<Label htmlFor="enable_talk_value_rules" className="cursor-pointer">
</Label>
</div>
</div>
</div>
{/* 动态发言频率规则配置 */}
{config.enable_talk_value_rules && (
<div className="border-t pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="text-base font-semibold"></h4>
<p className="text-xs text-muted-foreground mt-1">
ID调整发言频率
</p>
</div>
<Button onClick={addTalkValueRule} size="sm">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{config.talk_value_rules && config.talk_value_rules.length > 0 ? (
<div className="space-y-4">
{config.talk_value_rules.map((rule, index) => (
<div key={index} className="rounded-lg border p-4 bg-muted/50 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
#{index + 1}
</span>
<div className="flex items-center gap-2">
<RulePreview rule={rule} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
#{index + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeTalkValueRule(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-4">
{/* 配置类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={rule.target === '' ? 'global' : 'specific'}
onValueChange={(value) => {
if (value === 'global') {
updateTalkValueRule(index, 'target', '')
} else {
updateTalkValueRule(index, 'target', 'qq::group')
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
<SelectItem value="specific"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 详细配置选项 - 只在非全局时显示 */}
{rule.target !== '' && (() => {
const parts = rule.target.split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
updateTalkValueRule(index, 'target', `${value}:${chatId}:${chatType}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={chatId}
onChange={(e) => {
updateTalkValueRule(index, 'target', `${platform}:${e.target.value}:${chatType}`)
}}
placeholder="输入群 ID"
className="font-mono text-sm"
/>
</div>
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
updateTalkValueRule(index, 'target', `${platform}:${chatId}:${value}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group">group</SelectItem>
<SelectItem value="private">private</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{rule.target || '(未设置)'}
</p>
</div>
)
})()}
{/* 时间段选择器 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"> (Time)</Label>
<TimeRangePicker
value={rule.time}
onChange={(v) => updateTalkValueRule(index, 'time', v)}
/>
<p className="text-xs text-muted-foreground">
23:00-02:00
</p>
</div>
{/* 发言频率滑块 */}
<div className="grid gap-3">
<div className="flex items-center justify-between">
<Label htmlFor={`rule-value-${index}`} className="text-xs font-medium">
(Value)
</Label>
<Input
id={`rule-value-${index}`}
type="number"
step="0.01"
min="0.01"
max="1"
value={rule.value}
onChange={(e) => {
const val = parseFloat(e.target.value)
if (!isNaN(val)) {
updateTalkValueRule(index, 'value', Math.max(0.01, Math.min(1, val)))
}
}}
className="w-20 h-8 text-xs"
/>
</div>
<Slider
value={[rule.value]}
onValueChange={(values) =>
updateTalkValueRule(index, 'value', values[0])
}
min={0.01}
max={1}
step={0.01}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0.01 ()</span>
<span>0.5</span>
<span>1.0 ()</span>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">"添加规则"</p>
</div>
)}
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h5 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
📝
</h5>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> <strong>Target </strong></li>
<li> <strong>Target </strong>platform:id:type</li>
<li> <strong></strong></li>
<li> <strong></strong> 23:00-02:00 112</li>
<li> <strong></strong> 0-10 1 </li>
</ul>
</div>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,97 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { DebugConfig } from '../types'
interface DebugSectionProps {
config: DebugConfig
onChange: (config: DebugConfig) => void
}
export const DebugSection = React.memo(function DebugSection({ config, onChange }: DebugSectionProps) {
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_replyer_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_replyer_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_replyer_reasoning}
onCheckedChange={(checked) =>
onChange({ ...config, show_replyer_reasoning: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Jargon Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_jargon_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_jargon_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_memory_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_memory_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Planner Prompt</Label>
<p className="text-sm text-muted-foreground"> Planner </p>
</div>
<Switch
checked={config.show_planner_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_planner_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> LPMM </Label>
<p className="text-sm text-muted-foreground"> LPMM </p>
</div>
<Switch
checked={config.show_lpmm_paragraph}
onCheckedChange={(checked) => onChange({ ...config, show_lpmm_paragraph: checked })}
/>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { X } from 'lucide-react'
import type { DreamConfig } from '../types'
interface DreamSectionProps {
config: DreamConfig
onChange: (config: DreamConfig) => void
}
interface TimeRange {
startTime: string
endTime: string
}
export const DreamSection = React.memo(function DreamSection({ config, onChange }: DreamSectionProps) {
// 解析 dream_send 为 platform 和 userId
const parseDreamSend = (dreamSend: string): { platform: string; userId: string } => {
if (!dreamSend || !dreamSend.includes(':')) {
return { platform: 'qq', userId: '' }
}
const [platform, userId] = dreamSend.split(':')
return { platform, userId }
}
const { platform: initialPlatform, userId: initialUserId } = parseDreamSend(config.dream_send)
const [platform, setPlatform] = useState(initialPlatform)
const [userId, setUserId] = useState(initialUserId)
// 解析时间段字符串为开始和结束时间
const parseTimeRange = (range: string): TimeRange => {
const [start, end] = range.split('-')
return { startTime: start || '09:00', endTime: end || '22:00' }
}
// 更新 dream_send
const updateDreamSend = (newPlatform: string, newUserId: string) => {
const dreamSend = newUserId ? `${newPlatform}:${newUserId}` : ''
onChange({ ...config, dream_send: dreamSend })
}
const handlePlatformChange = (value: string) => {
setPlatform(value)
updateDreamSend(value, userId)
}
const handleUserIdChange = (value: string) => {
setUserId(value)
updateDreamSend(platform, value)
}
const handleAddTimeRange = () => {
onChange({
...config,
dream_time_ranges: [...config.dream_time_ranges, '09:00-22:00']
})
}
const handleRemoveTimeRange = (index: number) => {
onChange({
...config,
dream_time_ranges: config.dream_time_ranges.filter((_, i) => i !== index)
})
}
const handleTimeRangeChange = (index: number, field: 'startTime' | 'endTime', value: string) => {
const newRanges = [...config.dream_time_ranges]
const currentRange = parseTimeRange(newRanges[index])
if (field === 'startTime') {
currentRange.startTime = value
} else {
currentRange.endTime = value
}
newRanges[index] = `${currentRange.startTime}-${currentRange.endTime}`
onChange({
...config,
dream_time_ranges: newRanges
})
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-2">
<Label htmlFor="interval_minutes"></Label>
<Input
id="interval_minutes"
type="number"
min="1"
value={config.interval_minutes}
onChange={(e) => onChange({ ...config, interval_minutes: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">30</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_iterations"></Label>
<Input
id="max_iterations"
type="number"
min="1"
value={config.max_iterations}
onChange={(e) => onChange({ ...config, max_iterations: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">20</p>
</div>
<div className="space-y-2">
<Label htmlFor="first_delay_seconds"></Label>
<Input
id="first_delay_seconds"
type="number"
min="0"
value={config.first_delay_seconds}
onChange={(e) => onChange({ ...config, first_delay_seconds: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">60</p>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Select value={platform} onValueChange={handlePlatformChange}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="选择平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
<SelectItem value="webui">WebUI</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="输入用户ID (例如: 123456)"
value={userId}
onChange={(e) => handleUserIdChange(e.target.value)}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
IDID为空则不推送
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button type="button" size="sm" onClick={handleAddTimeRange}>
</Button>
</div>
<p className="text-xs text-muted-foreground">
23:00 02:00
</p>
<div className="space-y-2">
{config.dream_time_ranges.map((range, index) => {
const { startTime, endTime } = parseTimeRange(range)
return (
<div key={index} className="flex items-center gap-2">
<Input
type="time"
value={startTime}
onChange={(e) => handleTimeRangeChange(index, 'startTime', e.target.value)}
className="w-[140px]"
/>
<span className="text-muted-foreground"></span>
<Input
type="time"
value={endTime}
onChange={(e) => handleTimeRangeChange(index, 'endTime', e.target.value)}
className="w-[140px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveTimeRange(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
)
})}
{config.dream_time_ranges.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="dream_visible"
checked={config.dream_visible}
onCheckedChange={(checked) => onChange({ ...config, dream_visible: checked })}
/>
<Label htmlFor="dream_visible" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)
})

View File

@@ -0,0 +1,311 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, AlertTriangle, Eye, Code2 } from 'lucide-react'
import type { ExperimentalConfig } from '../types'
interface ChatPromptData {
platform: string
id: string
type: 'group' | 'private'
prompt: string
}
interface ExperimentalSectionProps {
config: ExperimentalConfig
onChange: (config: ExperimentalConfig) => void
}
export const ExperimentalSection = React.memo(function ExperimentalSection({ config, onChange }: ExperimentalSectionProps) {
// 解析 chat_prompt 字符串为结构化数据
const parseChatPrompt = (promptStr: string): ChatPromptData => {
const parts = promptStr.split(':')
if (parts.length >= 4) {
const platform = parts[0]
const id = parts[1]
const type = parts[2] as 'group' | 'private'
const prompt = parts.slice(3).join(':') // 处理 prompt 中可能包含的冒号
return { platform, id, type, prompt }
}
return { platform: 'qq', id: '', type: 'group', prompt: '' }
}
// 将结构化数据转换为字符串
const stringifyChatPrompt = (data: ChatPromptData): string => {
return `${data.platform}:${data.id}:${data.type}:${data.prompt}`
}
const addChatPrompt = () => {
onChange({ ...config, chat_prompts: [...config.chat_prompts, 'qq::group:'] })
}
const removeChatPrompt = (index: number) => {
onChange({
...config,
chat_prompts: config.chat_prompts.filter((_, i) => i !== index),
})
}
const updateChatPrompt = (index: number, data: Partial<ChatPromptData>) => {
const currentData = parseChatPrompt(config.chat_prompts[index])
const newData = { ...currentData, ...data }
const newPrompts = [...config.chat_prompts]
newPrompts[index] = stringifyChatPrompt(newData)
onChange({ ...config, chat_prompts: newPrompts })
}
// 预览组件
const ChatPromptPreview = ({ promptStr }: { promptStr: string }) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
"{promptStr}"
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div className="flex items-start gap-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-medium text-orange-500"></h4>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-6">
<div className="flex items-center space-x-2">
<Switch
id="lpmm_memory"
checked={config.lpmm_memory ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, lpmm_memory: checked })
}
/>
<Label htmlFor="lpmm_memory" className="cursor-pointer">
LPMM
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-4">
chat_history_summarizer
</p>
<div className="grid gap-2">
<Label htmlFor="private_plan_style"></Label>
<Textarea
id="private_plan_style"
value={config.private_plan_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, private_plan_style: e.target.value })}
placeholder="私聊的说话规则和行为风格(不推荐修改)"
rows={4}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-4">
<div className="flex items-center justify-between">
<div>
<Label> Prompt </Label>
<p className="text-xs text-muted-foreground mt-1">
prompt
</p>
</div>
<Button onClick={addChatPrompt} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.chat_prompts.map((promptStr, index) => {
const data = parseChatPrompt(promptStr)
return (
<div key={index} className="rounded-lg border p-4 space-y-4 bg-card">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Prompt {index + 1}
</span>
<div className="flex items-center gap-2">
<ChatPromptPreview promptStr={promptStr} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
prompt
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeChatPrompt(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="grid gap-4">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.platform}
onValueChange={(value) => updateChatPrompt(index, { platform: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
<SelectItem value="webui">WebUI</SelectItem>
</SelectContent>
</Select>
</div>
{/* ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">
{data.type === 'group' ? '群号' : '用户ID'}
</Label>
<Input
value={data.id}
onChange={(e) => updateChatPrompt(index, { id: e.target.value })}
placeholder={data.type === 'group' ? '输入群号' : '输入用户ID'}
className="font-mono"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.type}
onValueChange={(value: 'group' | 'private') => updateChatPrompt(index, { type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"> (group)</SelectItem>
<SelectItem value="private"> (private)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Prompt 内容 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">Prompt </Label>
<Textarea
value={data.prompt}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateChatPrompt(index, { prompt: e.target.value })}
placeholder="输入额外的 prompt 内容,例如:这是一个摄影群,你精通摄影知识"
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 原始格式显示 */}
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 mb-2">
<Code2 className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{promptStr || '(未配置)'}
</code>
</div>
</div>
</div>
)
})}
{config.chat_prompts.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm"> prompt </p>
<p className="text-xs mt-1">"添加配置"</p>
</div>
)}
</div>
{/* 使用说明 */}
<div className="text-xs text-muted-foreground space-y-2 p-4 rounded-lg bg-muted/30 border">
<p className="font-medium text-foreground">💡 使</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li></li>
<li>QQWebUI</li>
<li></li>
<li>Prompt </li>
</ul>
<p className="font-medium text-foreground mt-3">📝 </p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,996 @@
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, Eye } from 'lucide-react'
import type { ExpressionConfig } from '../types'
interface ExpressionGroupMemberInputProps {
member: string
groupIndex: number
memberIndex: number
availableChatIds: string[]
onUpdate: (groupIndex: number, memberIndex: number, value: string) => void
onRemove: (groupIndex: number, memberIndex: number) => void
}
const ExpressionGroupMemberInput = React.memo(function ExpressionGroupMemberInput({
member,
groupIndex,
memberIndex,
availableChatIds,
onUpdate,
onRemove,
}: ExpressionGroupMemberInputProps) {
// 判断当前成员是否在可选列表中
const isFromList = availableChatIds.includes(member) || member === '*'
const [inputMode, setInputMode] = useState(!isFromList)
return (
<div className="flex gap-2">
{/* 输入模式切换 */}
<div className="flex-1 flex gap-2">
{inputMode ? (
// 手动输入模式
<>
<Input
value={member}
onChange={(e) => onUpdate(groupIndex, memberIndex, e.target.value)}
placeholder='输入 "*" 或 "qq:123456:group"'
className="flex-1"
/>
{availableChatIds.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => setInputMode(false)}
title="切换到下拉选择"
>
</Button>
)}
</>
) : (
// 下拉选择模式
<>
<Select
value={member}
onValueChange={(value) => onUpdate(groupIndex, memberIndex, value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择聊天流" />
</SelectTrigger>
<SelectContent>
<SelectItem value="*">* ()</SelectItem>
{availableChatIds.map((chatId, idx) => (
<SelectItem key={idx} value={chatId}>
{chatId}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={() => setInputMode(true)}
title="切换到手动输入"
>
</Button>
</>
)}
</div>
{/* 删除按钮 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{member || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => onRemove(groupIndex, memberIndex)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
})
interface ExpressionSectionProps {
config: ExpressionConfig
onChange: (config: ExpressionConfig) => void
}
export const ExpressionSection = React.memo(function ExpressionSection({
config,
onChange,
}: ExpressionSectionProps) {
// 添加学习规则
const addLearningRule = () => {
onChange({
...config,
learning_list: [...config.learning_list, ['', 'enable', 'enable', '1.0']],
})
}
// 删除学习规则
const removeLearningRule = (index: number) => {
onChange({
...config,
learning_list: config.learning_list.filter((_, i) => i !== index),
})
}
// 更新学习规则
const updateLearningRule = (
index: number,
field: 0 | 1 | 2 | 3,
value: string
) => {
const newList = [...config.learning_list]
newList[index][field] = value
onChange({
...config,
learning_list: newList,
})
}
// 预览组件
const LearningRulePreview = ({ rule }: { rule: [string, string, string, string] }) => {
const previewText = `["${rule[0]}", "${rule[1]}", "${rule[2]}", "${rule[3]}"]`
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
{previewText}
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
}
// 添加表达组
const addExpressionGroup = () => {
onChange({
...config,
expression_groups: [...config.expression_groups, []],
})
}
// 删除表达组
const removeExpressionGroup = (index: number) => {
onChange({
...config,
expression_groups: config.expression_groups.filter((_, i) => i !== index),
})
}
// 添加组成员
const addGroupMember = (groupIndex: number) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex] = [...newGroups[groupIndex], '']
onChange({
...config,
expression_groups: newGroups,
})
}
// 删除组成员
const removeGroupMember = (groupIndex: number, memberIndex: number) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex] = newGroups[groupIndex].filter((_, i) => i !== memberIndex)
onChange({
...config,
expression_groups: newGroups,
})
}
// 更新组成员
const updateGroupMember = (groupIndex: number, memberIndex: number, value: string) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex][memberIndex] = value
onChange({
...config,
expression_groups: newGroups,
})
}
return (
<div className="space-y-6">
{/* 黑话设置 - 移到顶部 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold mb-4"></h3>
<div>
<div className="flex items-center space-x-2">
<Switch
id="all_global_jargon"
checked={config.all_global_jargon ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, all_global_jargon: checked })
}
/>
<Label htmlFor="all_global_jargon" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
<div>
<div className="flex items-center space-x-2">
<Switch
id="enable_jargon_explanation"
checked={config.enable_jargon_explanation ?? true}
onCheckedChange={(checked) =>
onChange({ ...config, enable_jargon_explanation: checked })
}
/>
<Label htmlFor="enable_jargon_explanation" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground mt-2">
LLM调用
</p>
</div>
<div>
<Label htmlFor="jargon_mode"></Label>
<Select
value={config.jargon_mode ?? 'context'}
onValueChange={(value) => onChange({ ...config, jargon_mode: value })}
>
<SelectTrigger id="jargon_mode" className="mt-2">
<SelectValue placeholder="选择黑话解释来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="context"></SelectItem>
<SelectItem value="planner">Planner模式使unknown_words列表</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-2">
使<br />
Planner模式使Planner在reply动作中给出的unknown_words列表进行黑话检索
</p>
</div>
</div>
{/* 表达学习配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
使
</p>
</div>
<Button onClick={addLearningRule} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.learning_list.map((rule, index) => {
// 检查是否已有全局配置rule[0] === ''
const hasGlobalConfig = config.learning_list.some((r, i) => i !== index && r[0] === '')
const isGlobal = rule[0] === ''
// 解析聊天流 ID格式platform:id:type
const parts = rule[0].split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div key={index} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{index + 1} {isGlobal && '(全局配置)'}
</span>
<div className="flex items-center gap-2">
<LearningRulePreview rule={rule} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{index + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeLearningRule(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-4">
{/* 配置类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={isGlobal ? 'global' : 'specific'}
onValueChange={(value) => {
if (value === 'global') {
updateLearningRule(index, 0, '')
} else {
// 切换到详细配置时,设置默认值
updateLearningRule(index, 0, 'qq::group')
}
}}
disabled={hasGlobalConfig && !isGlobal}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
<SelectItem value="specific" disabled={hasGlobalConfig && !isGlobal}>
</SelectItem>
</SelectContent>
</Select>
{hasGlobalConfig && !isGlobal && (
<p className="text-xs text-amber-600">
</p>
)}
</div>
{/* 详细配置选项 - 只在非全局时显示 */}
{!isGlobal && (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
updateLearningRule(index, 0, `${value}:${chatId}:${chatType}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 群 ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={chatId}
onChange={(e) => {
updateLearningRule(index, 0, `${platform}:${e.target.value}:${chatType}`)
}}
placeholder="输入群 ID"
className="font-mono text-sm"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
updateLearningRule(index, 0, `${platform}:${chatId}:${value}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group">group</SelectItem>
<SelectItem value="private">private</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{rule[0] || '(未设置)'}
</p>
</div>
)}
{/* 使用学到的表达 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium">使</Label>
<p className="text-xs text-muted-foreground mt-1">
使
</p>
</div>
<Switch
checked={rule[1] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 1, checked ? 'enable' : 'disable')
}
/>
</div>
</div>
{/* 学习表达 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
checked={rule[2] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 2, checked ? 'enable' : 'disable')
}
/>
</div>
</div>
{/* 启用黑话学习 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
checked={rule[3] === 'true' || rule[3] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 3, checked ? 'true' : 'false')
}
/>
</div>
</div>
</div>
</div>
)
})}
{config.learning_list.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
"添加规则"
</div>
)}
</div>
</div>
</div>
{/* 表达反思配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
{/* 自动表达优化 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_self_reflect" className="cursor-pointer font-medium">
</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="expression_self_reflect"
checked={config.expression_self_reflect ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_self_reflect: checked })
}
/>
</div>
{config.expression_self_reflect && (
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
{/* 自动检查间隔 */}
<div className="space-y-2">
<Label htmlFor="expression_auto_check_interval">
</Label>
<Input
id="expression_auto_check_interval"
type="number"
min="60"
value={config.expression_auto_check_interval ?? 3600}
onChange={(e) =>
onChange({
...config,
expression_auto_check_interval: parseInt(e.target.value) || 3600,
})
}
/>
<p className="text-xs text-muted-foreground">
36001
</p>
</div>
{/* 每次检查数量 */}
<div className="space-y-2">
<Label htmlFor="expression_auto_check_count">
</Label>
<Input
id="expression_auto_check_count"
type="number"
min="1"
max="100"
value={config.expression_auto_check_count ?? 10}
onChange={(e) =>
onChange({
...config,
expression_auto_check_count: parseInt(e.target.value) || 10,
})
}
/>
<p className="text-xs text-muted-foreground">
10
</p>
</div>
{/* 自定义评估标准 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label></Label>
<Button
onClick={() => {
onChange({
...config,
expression_auto_check_custom_criteria: [
...(config.expression_auto_check_custom_criteria || []),
'',
],
})
}}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(config.expression_auto_check_custom_criteria || []).map((criterion, index) => (
<div key={index} className="flex gap-2">
<Input
value={criterion}
onChange={(e) => {
const newCriteria = [...(config.expression_auto_check_custom_criteria || [])]
newCriteria[index] = e.target.value
onChange({ ...config, expression_auto_check_custom_criteria: newCriteria })
}}
placeholder="输入评估标准,例如:是否符合角色人设"
className="flex-1"
/>
<Button
onClick={() => {
onChange({
...config,
expression_auto_check_custom_criteria: (config.expression_auto_check_custom_criteria || []).filter((_, i) => i !== index),
})
}}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{(!config.expression_auto_check_custom_criteria || config.expression_auto_check_custom_criteria.length === 0) && (
<div className="text-center py-4 text-muted-foreground text-sm">
"添加标准"
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)}
</div>
{/* 仅使用已检查的表达方式 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_checked_only" className="cursor-pointer font-medium">
使
</Label>
<p className="text-xs text-muted-foreground">
使使使
</p>
</div>
<Switch
id="expression_checked_only"
checked={config.expression_checked_only ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_checked_only: checked })
}
/>
</div>
</div>
{/* 手动表达优化 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_manual_reflect" className="cursor-pointer font-medium">
</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="expression_manual_reflect"
checked={config.expression_manual_reflect ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_manual_reflect: checked })
}
/>
</div>
{config.expression_manual_reflect && (
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
{/* 表达反思操作员 ID */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
</div>
<div className="space-y-4">
{(() => {
const operatorId = config.manual_reflect_operator_id || ''
const parts = operatorId.split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'private'
return (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
onChange({ ...config, manual_reflect_operator_id: `${value}:${chatId}:${chatType}` })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
{/* ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">/ ID</Label>
<Input
value={chatId}
onChange={(e) => {
onChange({ ...config, manual_reflect_operator_id: `${platform}:${e.target.value}:${chatType}` })
}}
placeholder="输入 ID"
className="font-mono text-sm"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
onChange({ ...config, manual_reflect_operator_id: `${platform}:${chatId}:${value}` })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">private</SelectItem>
<SelectItem value="group">group</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{config.manual_reflect_operator_id || '(未设置)'}
</p>
<p className="text-xs text-muted-foreground">
IDplatform:id:type ( "qq:123456:private" "qq:654321:group")
</p>
</div>
)
})()}
</div>
</div>
{/* 允许反思的聊天流列表 */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium"></span>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Button
onClick={() => {
onChange({
...config,
allow_reflect: [...(config.allow_reflect || []), 'qq::group'],
})
}}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(config.allow_reflect || []).map((chatId, index) => {
const parts = chatId.split(':')
const platform = parts[0] || 'qq'
const id = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Select
value={platform}
onValueChange={(value) => {
const newList = [...config.allow_reflect]
newList[index] = `${value}:${id}:${chatType}`
onChange({ ...config, allow_reflect: newList })
}}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
<Input
value={id}
onChange={(e) => {
const newList = [...config.allow_reflect]
newList[index] = `${platform}:${e.target.value}:${chatType}`
onChange({ ...config, allow_reflect: newList })
}}
placeholder="ID"
className="flex-1 font-mono text-sm"
/>
<Select
value={chatType}
onValueChange={(value) => {
const newList = [...config.allow_reflect]
newList[index] = `${platform}:${id}:${value}`
onChange({ ...config, allow_reflect: newList })
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"></SelectItem>
<SelectItem value="private"></SelectItem>
</SelectContent>
</Select>
<Button
onClick={() => {
onChange({
...config,
allow_reflect: config.allow_reflect.filter((_, i) => i !== index),
})
}}
size="sm"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
{(!config.allow_reflect || config.allow_reflect.length === 0) && (
<div className="text-center py-4 text-muted-foreground text-sm">
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* 表达共享组配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<Button onClick={addExpressionGroup} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.expression_groups.map((group, groupIndex) => {
// 获取所有已配置的聊天流 ID用于下拉框选项
const availableChatIds = config.learning_list
.map(rule => rule[0])
.filter(id => id !== '') // 过滤掉全局配置
return (
<div key={groupIndex} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{groupIndex + 1}
{group.length === 1 && group[0] === '*' && '(全局共享)'}
</span>
<div className="flex gap-2">
<Button
onClick={() => addGroupMember(groupIndex)}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{groupIndex + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeExpressionGroup(groupIndex)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-2">
{group.map((member, memberIndex) => (
<ExpressionGroupMemberInput
key={`${groupIndex}-${memberIndex}`}
member={member}
groupIndex={groupIndex}
memberIndex={memberIndex}
availableChatIds={availableChatIds}
onUpdate={updateGroupMember}
onRemove={removeGroupMember}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
"*"
</p>
</div>
)
})}
{config.expression_groups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
"添加共享组"
</div>
)}
</div>
</div>
</div>
</div>
)
})

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