Merge branch 'r-dev' of github.com:Mai-with-u/MaiBot into r-dev
This commit is contained in:
@@ -28,7 +28,7 @@ export default tseslint.config(
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
// 关闭或降级其他规则
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
135
dashboard/package-lock.json
generated
135
dashboard/package-lock.json
generated
@@ -55,7 +55,7 @@
|
||||
"dagre": "^0.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jotai": "^2.16.0",
|
||||
"idb": "^8.0.3",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.1",
|
||||
@@ -73,6 +73,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -3501,6 +3502,43 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"dev": true,
|
||||
@@ -3561,6 +3599,13 @@
|
||||
"version": "0.3.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
@@ -4564,6 +4609,16 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"dev": true,
|
||||
@@ -6275,6 +6330,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
@@ -6470,33 +6531,6 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jotai": {
|
||||
"version": "2.17.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": ">=7.0.0",
|
||||
"@babel/template": ">=7.0.0",
|
||||
"@types/react": ">=17.0.0",
|
||||
"react": ">=17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@@ -6694,6 +6728,16 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"dev": true,
|
||||
@@ -8130,6 +8174,41 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"jotai": "^2.16.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.1",
|
||||
|
||||
@@ -50,5 +50,5 @@ export function AnimationProvider({
|
||||
setEnableWavesBackground,
|
||||
}
|
||||
|
||||
return <AnimationContext.Provider value={value}>{children}</AnimationContext.Provider>
|
||||
return <AnimationContext value={value}>{children}</AnimationContext>
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function AssetStoreProvider({ children }: AssetStoreProviderProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <AssetStoreContext.Provider value={value}>{children}</AssetStoreContext.Provider>
|
||||
return <AssetStoreContext value={value}>{children}</AssetStoreContext>
|
||||
}
|
||||
|
||||
export function useAssetStore() {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
const renderIcon = () => {
|
||||
if (!schema['x-icon']) return null
|
||||
|
||||
const IconComponent = (LucideIcons as any)[schema['x-icon']]
|
||||
const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined
|
||||
if (!IconComponent) return null
|
||||
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
|
||||
@@ -105,27 +105,43 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
setStatsLoading(true)
|
||||
const data = await getReviewStats()
|
||||
setStats(data)
|
||||
const result = await getReviewStats()
|
||||
if (result.success) {
|
||||
setStats(result.data)
|
||||
} else {
|
||||
toast({
|
||||
title: '错误',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
} finally {
|
||||
setStatsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [toast])
|
||||
|
||||
// 加载列表
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getReviewList({
|
||||
const result = await getReviewList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
filter_type: filterType,
|
||||
search: search || undefined,
|
||||
})
|
||||
setExpressions(response.data)
|
||||
setTotal(response.total)
|
||||
if (result.success) {
|
||||
setExpressions(result.data.data)
|
||||
setTotal(result.data.total)
|
||||
} else {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
@@ -137,19 +153,19 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
}
|
||||
}, [page, pageSize, filterType, search, toast])
|
||||
|
||||
// 加载聊天名称映射
|
||||
// 加载聚天名称映射
|
||||
const loadChatNames = useCallback(async () => {
|
||||
try {
|
||||
const response = await getChatList()
|
||||
if (response?.data) {
|
||||
const result = await getChatList()
|
||||
if (result.success) {
|
||||
const nameMap = new Map<string, string>()
|
||||
response.data.forEach((chat: ChatInfo) => {
|
||||
result.data.forEach((chat: ChatInfo) => {
|
||||
nameMap.set(chat.chat_id, chat.chat_name)
|
||||
})
|
||||
setChatNameMap(nameMap)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天名称失败:', error)
|
||||
console.error('加载聚天名称失败:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -158,24 +174,32 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
try {
|
||||
setQuickLoading(true)
|
||||
const pageToLoad = append ? quickPage + 1 : quickPage
|
||||
const response = await getReviewList({
|
||||
const result = await getReviewList({
|
||||
page: pageToLoad,
|
||||
page_size: 20,
|
||||
filter_type: quickFilterType,
|
||||
})
|
||||
|
||||
if (append) {
|
||||
// 追加模式:拼接数据
|
||||
setQuickExpressions(prev => [...prev, ...response.data])
|
||||
setQuickPage(pageToLoad)
|
||||
if (result.success) {
|
||||
if (append) {
|
||||
// 追加模式:拼接数据
|
||||
setQuickExpressions(prev => [...prev, ...result.data.data])
|
||||
setQuickPage(pageToLoad)
|
||||
} else {
|
||||
// 替换模式
|
||||
setQuickExpressions(result.data.data)
|
||||
}
|
||||
|
||||
setQuickTotal(result.data.total)
|
||||
if (resetIndex) {
|
||||
setQuickCurrentIndex(0)
|
||||
}
|
||||
} else {
|
||||
// 替换模式
|
||||
setQuickExpressions(response.data)
|
||||
}
|
||||
|
||||
setQuickTotal(response.total)
|
||||
if (resetIndex) {
|
||||
setQuickCurrentIndex(0)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -247,13 +271,22 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
setSwipeOffset(rejected ? -400 : 400)
|
||||
|
||||
try {
|
||||
const response = await batchReviewExpressions([{
|
||||
const result = await batchReviewExpressions([{
|
||||
id: currentExpr.id,
|
||||
rejected,
|
||||
require_unchecked: quickFilterType === 'unchecked',
|
||||
}])
|
||||
|
||||
if (response.results[0]?.success) {
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data.results[0]?.success) {
|
||||
toast({
|
||||
title: rejected ? '已拒绝' : '已通过',
|
||||
description: `表达方式 #${currentExpr.id} ${rejected ? '已拒绝' : '已通过'}`,
|
||||
@@ -514,11 +547,20 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
try {
|
||||
setProcessingIds((prev) => new Set(prev).add(id))
|
||||
|
||||
const response = await batchReviewExpressions([
|
||||
const result = await batchReviewExpressions([
|
||||
{ id, rejected, require_unchecked: filterType === 'unchecked' }
|
||||
])
|
||||
|
||||
if (response.results[0]?.success) {
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data.results[0]?.success) {
|
||||
toast({
|
||||
title: rejected ? '已拒绝' : '已通过',
|
||||
description: `表达方式 #${id} ${rejected ? '已拒绝' : '已通过'}`,
|
||||
@@ -529,7 +571,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
} else {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: response.results[0]?.message || '未知错误',
|
||||
description: result.data.results[0]?.message || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -568,12 +610,21 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
require_unchecked: filterType === 'unchecked',
|
||||
}))
|
||||
|
||||
const response = await batchReviewExpressions(items)
|
||||
const result = await batchReviewExpressions(items)
|
||||
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '批量审核失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '批量审核完成',
|
||||
description: `成功 ${response.succeeded} 条,失败 ${response.failed} 条`,
|
||||
variant: response.failed > 0 ? 'destructive' : 'default',
|
||||
description: `成功 ${result.data.succeeded} 条,失败 ${result.data.failed} 条`,
|
||||
variant: result.data.failed > 0 ? 'destructive' : 'default',
|
||||
})
|
||||
|
||||
// 清空选择并刷新
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
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'
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
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 pageBg = useBackground('page')
|
||||
const sidebarBg = useBackground('sidebar')
|
||||
const headerBg = useBackground('header')
|
||||
|
||||
// 登出处理
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||
{/* 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">
|
||||
<BackgroundLayer config={headerBg} layerId="header" />
|
||||
<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="relative flex-1 overflow-hidden bg-background">
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
134
dashboard/src/components/layout/Header.tsx
Normal file
134
dashboard/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Sun } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { toggleThemeWithTransition } from '@/components/use-theme'
|
||||
|
||||
interface HeaderProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
searchOpen: boolean
|
||||
actualTheme: 'light' | 'dark'
|
||||
onSidebarToggle: () => void
|
||||
onMobileMenuToggle: () => void
|
||||
onSearchOpenChange: (open: boolean) => void
|
||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||||
}
|
||||
|
||||
export function Header({
|
||||
sidebarOpen,
|
||||
|
||||
searchOpen,
|
||||
actualTheme,
|
||||
onSidebarToggle,
|
||||
onMobileMenuToggle,
|
||||
onSearchOpenChange,
|
||||
onThemeChange,
|
||||
}: HeaderProps) {
|
||||
const headerBg = useBackground('header')
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<BackgroundLayer config={headerBg} layerId="header" />
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMobileMenuToggle}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={onSidebarToggle}
|
||||
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={() => onSearchOpenChange(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={onSearchOpenChange} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<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, onThemeChange, 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>
|
||||
)
|
||||
}
|
||||
119
dashboard/src/components/layout/Layout.tsx
Normal file
119
dashboard/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { LayoutProps } from './types'
|
||||
|
||||
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()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 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>
|
||||
)
|
||||
}
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
const pageBg = useBackground('page')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
searchOpen={searchOpen}
|
||||
actualTheme={actualTheme}
|
||||
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
onThemeChange={setTheme}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="relative flex-1 overflow-hidden bg-background">
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
36
dashboard/src/components/layout/LogoArea.tsx
Normal file
36
dashboard/src/components/layout/LogoArea.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatVersion } from '@/lib/version'
|
||||
|
||||
interface LogoAreaProps {
|
||||
sidebarOpen: boolean
|
||||
}
|
||||
|
||||
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
79
dashboard/src/components/layout/NavItem.tsx
Normal file
79
dashboard/src/components/layout/NavItem.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { MenuItem } from './types'
|
||||
|
||||
interface NavItemProps {
|
||||
item: MenuItem
|
||||
sidebarOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
|
||||
const matchRoute = useMatchRoute()
|
||||
const isActive = matchRoute({ to: item.path })
|
||||
const Icon = item.icon
|
||||
|
||||
const menuItemContent = (
|
||||
<>
|
||||
{/* 左侧高亮条 */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center transition-all duration-300',
|
||||
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
|
||||
)}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive && 'text-primary'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium whitespace-nowrap transition-all duration-300',
|
||||
isActive && 'font-semibold',
|
||||
sidebarOpen
|
||||
? 'opacity-100 max-w-[200px]'
|
||||
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.path}
|
||||
data-tour={item.tourId}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg py-2 transition-all duration-300',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
|
||||
)}
|
||||
onClick={onMobileMenuClose}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
91
dashboard/src/components/layout/Sidebar.tsx
Normal file
91
dashboard/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
|
||||
import { LogoArea } from './LogoArea'
|
||||
import { NavItem } from './NavItem'
|
||||
import { menuSections } from './constants'
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sidebarOpen,
|
||||
mobileMenuOpen,
|
||||
tooltipsEnabled,
|
||||
onMobileMenuClose
|
||||
}: SidebarProps) {
|
||||
const sidebarBg = useBackground('sidebar')
|
||||
|
||||
return (
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||
|
||||
{/* Logo 区域 */}
|
||||
<LogoArea sidebarOpen={sidebarOpen} />
|
||||
|
||||
<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) => (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
sidebarOpen={sidebarOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={onMobileMenuClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
49
dashboard/src/components/layout/constants.ts
Normal file
49
dashboard/src/components/layout/constants.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react'
|
||||
|
||||
import type { MenuSection } from './types'
|
||||
|
||||
export 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
2
dashboard/src/components/layout/index.ts
Normal file
2
dashboard/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Layout } from './Layout'
|
||||
export type { LayoutProps, MenuItem, MenuSection } from './types'
|
||||
18
dashboard/src/components/layout/types.ts
Normal file
18
dashboard/src/components/layout/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
@@ -90,8 +90,8 @@ export function ThemeProvider({
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
<ThemeProviderContext value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
</ThemeProviderContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TourContext.Provider
|
||||
<TourContext
|
||||
value={{
|
||||
state,
|
||||
tours,
|
||||
@@ -172,6 +172,6 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TourContext.Provider>
|
||||
</TourContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const ChartContainer = React.forwardRef<
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<ChartContext value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
@@ -60,7 +60,7 @@ const ChartContainer = React.forwardRef<
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
</ChartContext>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
@@ -79,7 +79,7 @@ function SortableBadge({
|
||||
}
|
||||
|
||||
// 处理删除按钮点击,阻止事件冒泡和默认行为
|
||||
const handleRemoveClick = (e: React.MouseEvent) => {
|
||||
const handleRemoveClick = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onRemove(value)
|
||||
@@ -121,7 +121,7 @@ function SortableBadge({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRemoveClick(e as any)
|
||||
handleRemoveClick(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -191,9 +191,8 @@
|
||||
--chart-4: var(--color-chart-4);
|
||||
--chart-5: var(--color-chart-5);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
55
dashboard/src/lib/api-helpers.ts
Normal file
55
dashboard/src/lib/api-helpers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* API response parsing and error handling helpers
|
||||
* Provides unified error handling across API modules
|
||||
*/
|
||||
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Parse an HTTP response into a typed ApiResponse
|
||||
* Handles JSON parsing, error extraction, and HTTP status codes
|
||||
*/
|
||||
export async function parseResponse<T>(response: Response): Promise<ApiResponse<T>> {
|
||||
if (response.ok) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return { success: true, data }
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response body',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
const errorMessage =
|
||||
errorData.error?.detail ??
|
||||
errorData.error?.message ??
|
||||
errorData.detail ??
|
||||
errorData.message ??
|
||||
response.statusText
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: String(errorMessage),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from successful ApiResponse or throw error
|
||||
* Simplifies error handling in async functions
|
||||
*/
|
||||
export function throwIfError<T>(result: ApiResponse<T>): T {
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
throw new Error(result.error)
|
||||
}
|
||||
@@ -2,146 +2,96 @@
|
||||
* 配置API客户端
|
||||
*/
|
||||
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import type {
|
||||
ConfigSchema,
|
||||
ConfigSchemaResponse,
|
||||
ConfigDataResponse,
|
||||
ConfigUpdateResponse,
|
||||
} from '@/types/config-schema'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type { ConfigSchema } from '@/types/config-schema'
|
||||
|
||||
const API_BASE = '/api/webui/config'
|
||||
|
||||
/**
|
||||
* 获取麦麦主程序配置架构
|
||||
*/
|
||||
export async function getBotConfigSchema(): Promise<ConfigSchema> {
|
||||
export async function getBotConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
|
||||
const data: ConfigSchemaResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取配置架构失败')
|
||||
}
|
||||
|
||||
return data.schema
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型配置架构
|
||||
*/
|
||||
export async function getModelConfigSchema(): Promise<ConfigSchema> {
|
||||
export async function getModelConfigSchema(): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
|
||||
const data: ConfigSchemaResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取模型配置架构失败')
|
||||
}
|
||||
|
||||
return data.schema
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定配置节的架构
|
||||
*/
|
||||
export async function getConfigSectionSchema(sectionName: string): Promise<ConfigSchema> {
|
||||
export async function getConfigSectionSchema(sectionName: string): Promise<ApiResponse<ConfigSchema>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
|
||||
const data: ConfigSchemaResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`获取配置节 ${sectionName} 架构失败`)
|
||||
}
|
||||
|
||||
return data.schema
|
||||
return parseResponse<ConfigSchema>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取麦麦主程序配置数据
|
||||
*/
|
||||
export async function getBotConfig(): Promise<Record<string, unknown>> {
|
||||
export async function getBotConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`)
|
||||
const data: ConfigDataResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取配置数据失败')
|
||||
}
|
||||
|
||||
return data.config
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型配置数据
|
||||
*/
|
||||
export async function getModelConfig(): Promise<Record<string, unknown>> {
|
||||
export async function getModelConfig(): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`)
|
||||
const data: ConfigDataResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取模型配置数据失败')
|
||||
}
|
||||
|
||||
return data.config
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新麦麦主程序配置
|
||||
*/
|
||||
export async function updateBotConfig(config: Record<string, unknown>): Promise<void> {
|
||||
export async function updateBotConfig(
|
||||
config: Record<string, unknown>
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const data: ConfigUpdateResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || '保存配置失败')
|
||||
}
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取麦麦主程序配置的原始 TOML 内容
|
||||
*/
|
||||
export async function getBotConfigRaw(): Promise<string> {
|
||||
export async function getBotConfigRaw(): Promise<ApiResponse<string>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
|
||||
const data: { success: boolean; content: string } = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取配置源代码失败')
|
||||
}
|
||||
|
||||
return data.content
|
||||
return parseResponse<string>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新麦麦主程序配置(原始 TOML 内容)
|
||||
*/
|
||||
export async function updateBotConfigRaw(rawContent: string): Promise<void> {
|
||||
export async function updateBotConfigRaw(rawContent: string): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ raw_content: rawContent }),
|
||||
})
|
||||
|
||||
const data: ConfigUpdateResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || '保存配置失败')
|
||||
}
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型配置
|
||||
*/
|
||||
export async function updateModelConfig(config: Record<string, unknown>): Promise<void> {
|
||||
export async function updateModelConfig(
|
||||
config: Record<string, unknown>
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const data: ConfigUpdateResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || '保存配置失败')
|
||||
}
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,17 +100,12 @@ export async function updateModelConfig(config: Record<string, unknown>): Promis
|
||||
export async function updateBotConfigSection(
|
||||
sectionName: string,
|
||||
sectionData: unknown
|
||||
): Promise<void> {
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sectionData),
|
||||
})
|
||||
|
||||
const data: ConfigUpdateResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||
}
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,17 +114,12 @@ export async function updateBotConfigSection(
|
||||
export async function updateModelConfigSection(
|
||||
sectionName: string,
|
||||
sectionData: unknown
|
||||
): Promise<void> {
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sectionData),
|
||||
})
|
||||
|
||||
const data: ConfigUpdateResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||
}
|
||||
return parseResponse<Record<string, unknown>>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,28 +151,14 @@ export async function fetchProviderModels(
|
||||
providerName: string,
|
||||
parser: 'openai' | 'gemini' = 'openai',
|
||||
endpoint: string = '/models'
|
||||
): Promise<ModelListItem[]> {
|
||||
): Promise<ApiResponse<ModelListItem[]>> {
|
||||
const params = new URLSearchParams({
|
||||
provider_name: providerName,
|
||||
parser,
|
||||
endpoint,
|
||||
})
|
||||
|
||||
const response = await fetchWithAuth(`/api/webui/models/list?${params}`)
|
||||
|
||||
// 处理非 2xx 响应
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `获取模型列表失败 (${response.status})`)
|
||||
}
|
||||
|
||||
const data: FetchModelsResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error('获取模型列表失败')
|
||||
}
|
||||
|
||||
return data.models
|
||||
return parseResponse<ModelListItem[]>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,20 +176,14 @@ export interface TestConnectionResult {
|
||||
* 测试提供商连接状态(通过提供商名称)
|
||||
* @param providerName 提供商名称
|
||||
*/
|
||||
export async function testProviderConnection(providerName: string): Promise<TestConnectionResult> {
|
||||
export async function testProviderConnection(
|
||||
providerName: string
|
||||
): Promise<ApiResponse<TestConnectionResult>> {
|
||||
const params = new URLSearchParams({
|
||||
provider_name: providerName,
|
||||
})
|
||||
|
||||
const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
// 处理非 2xx 响应
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `测试连接失败 (${response.status})`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
return parseResponse<TestConnectionResult>(response)
|
||||
}
|
||||
|
||||
@@ -12,28 +12,58 @@ import type {
|
||||
ExpressionDeleteResponse,
|
||||
ExpressionStatsResponse,
|
||||
ChatListResponse,
|
||||
ChatInfo,
|
||||
ReviewStats,
|
||||
ReviewListResponse,
|
||||
BatchReviewItem,
|
||||
BatchReviewResponse,
|
||||
} from '@/types/expression'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
const API_BASE = '/api/webui/expression'
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
*/
|
||||
export async function getChatList(): Promise<ChatListResponse> {
|
||||
export async function getChatList(): Promise<ApiResponse<ChatInfo[]>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/chats`, {
|
||||
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取聊天列表失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取聊天列表失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取聊天列表失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ChatListResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取聊天列表失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析聊天列表响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,40 +74,96 @@ export async function getExpressionList(params: {
|
||||
page_size?: number
|
||||
search?: string
|
||||
chat_id?: string
|
||||
}): Promise<ExpressionListResponse> {
|
||||
}): Promise<ApiResponse<ExpressionListResponse>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||
if (params.search) queryParams.append('search', params.search)
|
||||
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||
|
||||
|
||||
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {
|
||||
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取表达方式列表失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取表达方式列表失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取表达方式列表失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ExpressionListResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取表达方式列表失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析表达方式列表响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表达方式详细信息
|
||||
*/
|
||||
export async function getExpressionDetail(expressionId: number): Promise<ExpressionDetailResponse> {
|
||||
export async function getExpressionDetail(expressionId: number): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取表达方式详情失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取表达方式详情失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取表达方式详情失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ExpressionDetailResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取表达方式详情失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析表达方式详情响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,19 +171,47 @@ export async function getExpressionDetail(expressionId: number): Promise<Express
|
||||
*/
|
||||
export async function createExpression(
|
||||
data: ExpressionCreateRequest
|
||||
): Promise<ExpressionCreateResponse> {
|
||||
): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/`, {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '创建表达方式失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '创建表达方式失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '创建表达方式失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const responseData: ExpressionCreateResponse = await response.json()
|
||||
if (responseData.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: responseData.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: responseData.message || '创建表达方式失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析创建表达方式响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,70 +220,182 @@ export async function createExpression(
|
||||
export async function updateExpression(
|
||||
expressionId: number,
|
||||
data: ExpressionUpdateRequest
|
||||
): Promise<ExpressionUpdateResponse> {
|
||||
): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '更新表达方式失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '更新表达方式失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '更新表达方式失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const responseData: ExpressionUpdateResponse = await response.json()
|
||||
if (responseData.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: responseData.data || {},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: responseData.message || '更新表达方式失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析更新表达方式响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表达方式
|
||||
*/
|
||||
export async function deleteExpression(expressionId: number): Promise<ExpressionDeleteResponse> {
|
||||
export async function deleteExpression(expressionId: number): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '删除表达方式失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '删除表达方式失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '删除表达方式失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ExpressionDeleteResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || '删除表达方式失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析删除表达方式响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除表达方式
|
||||
*/
|
||||
export async function batchDeleteExpressions(expressionIds: number[]): Promise<ExpressionDeleteResponse> {
|
||||
export async function batchDeleteExpressions(expressionIds: number[]): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({ ids: expressionIds }),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '批量删除表达方式失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '批量删除表达方式失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '批量删除表达方式失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ExpressionDeleteResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || '批量删除表达方式失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析批量删除表达方式响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表达方式统计数据
|
||||
*/
|
||||
export async function getExpressionStats(): Promise<ExpressionStatsResponse> {
|
||||
export async function getExpressionStats(): Promise<ApiResponse<any>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取统计数据失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取统计数据失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取统计数据失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ExpressionStatsResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取统计数据失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析统计数据响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============ 审核相关 API ============
|
||||
@@ -177,15 +403,36 @@ export async function getExpressionStats(): Promise<ExpressionStatsResponse> {
|
||||
/**
|
||||
* 获取审核统计数据
|
||||
*/
|
||||
export async function getReviewStats(): Promise<ReviewStats> {
|
||||
export async function getReviewStats(): Promise<ApiResponse<ReviewStats>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/review/stats`)
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取审核统计失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取审核统计失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取审核统计失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json() as ReviewStats
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析审核统计响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,23 +444,51 @@ export async function getReviewList(params: {
|
||||
filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all'
|
||||
search?: string
|
||||
chat_id?: string
|
||||
}): Promise<ReviewListResponse> {
|
||||
}): Promise<ApiResponse<ReviewListResponse>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||
if (params.filter_type) queryParams.append('filter_type', params.filter_type)
|
||||
if (params.search) queryParams.append('search', params.search)
|
||||
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||
|
||||
|
||||
const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取审核列表失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取审核列表失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取审核列表失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ReviewListResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取审核列表失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析审核列表响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,16 +496,44 @@ export async function getReviewList(params: {
|
||||
*/
|
||||
export async function batchReviewExpressions(
|
||||
items: BatchReviewItem[]
|
||||
): Promise<BatchReviewResponse> {
|
||||
): Promise<ApiResponse<BatchReviewResponse>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/review/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '批量审核失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '批量审核失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '批量审核失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: BatchReviewResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '批量审核失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '无法解析批量审核响应',
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* 确保整个应用只有一个 WebSocket 连接
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, checkAuthStatus } from './fetch-with-auth'
|
||||
import { checkAuthStatus } from './fetch-with-auth'
|
||||
import { getSetting } from './settings-manager'
|
||||
import { createReconnectingWebSocket } from './ws-utils'
|
||||
|
||||
export interface LogEntry {
|
||||
id: string
|
||||
@@ -18,10 +19,7 @@ type LogCallback = (log: LogEntry) => void
|
||||
type ConnectionCallback = (connected: boolean) => void
|
||||
|
||||
class LogWebSocketManager {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectTimeout: number | null = null
|
||||
private reconnectAttempts = 0
|
||||
private heartbeatInterval: number | null = null
|
||||
private wsControl: ReturnType<typeof createReconnectingWebSocket> | null = null
|
||||
|
||||
// 订阅者
|
||||
private logCallbacks: Set<LogCallback> = new Set()
|
||||
@@ -54,9 +52,9 @@ class LogWebSocketManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket URL
|
||||
* 获取 WebSocket URL(不含 token 参数)
|
||||
*/
|
||||
private getWebSocketUrl(token?: string): string {
|
||||
private getWebSocketUrl(): string {
|
||||
let baseUrl: string
|
||||
if (import.meta.env.DEV) {
|
||||
// 开发模式:连接到 WebUI 后端服务器
|
||||
@@ -67,49 +65,13 @@ class LogWebSocketManager {
|
||||
const host = window.location.host
|
||||
baseUrl = `${protocol}//${host}/ws/logs`
|
||||
}
|
||||
|
||||
// 如果有 token,添加到 URL 参数
|
||||
if (token) {
|
||||
return `${baseUrl}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 临时认证 token
|
||||
*/
|
||||
private async getWsToken(): Promise<string | null> {
|
||||
try {
|
||||
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
|
||||
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include', // 携带 Cookie
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取 WebSocket token 失败:', response.status)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.token) {
|
||||
return data.token
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取 WebSocket token 失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket(会先检查登录状态)
|
||||
*/
|
||||
async connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否在登录页面
|
||||
if (window.location.pathname === '/auth') {
|
||||
console.log('📡 在登录页面,跳过 WebSocket 连接')
|
||||
@@ -123,114 +85,51 @@ class LogWebSocketManager {
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取临时认证 token
|
||||
const wsToken = await this.getWsToken()
|
||||
if (!wsToken) {
|
||||
console.log('📡 无法获取 WebSocket token,跳过连接')
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = this.getWebSocketUrl(wsToken)
|
||||
const wsUrl = this.getWebSocketUrl()
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true
|
||||
this.reconnectAttempts = 0
|
||||
this.notifyConnection(true)
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
// 使用 ws-utils 创建 WebSocket
|
||||
this.wsControl = createReconnectingWebSocket(wsUrl, {
|
||||
onMessage: (data: string) => {
|
||||
try {
|
||||
// 忽略心跳响应
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
const log: LogEntry = JSON.parse(event.data)
|
||||
const log: LogEntry = JSON.parse(data)
|
||||
this.notifyLog(log)
|
||||
} catch (error) {
|
||||
console.error('解析日志消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
},
|
||||
onOpen: () => {
|
||||
this.isConnected = true
|
||||
this.notifyConnection(true)
|
||||
},
|
||||
onClose: () => {
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ WebSocket 错误:', error)
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
}
|
||||
},
|
||||
heartbeatInterval: 30000,
|
||||
maxRetries: this.getMaxReconnectAttempts(),
|
||||
backoffBase: this.getReconnectInterval(),
|
||||
maxBackoff: 30000,
|
||||
})
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
this.stopHeartbeat()
|
||||
this.attemptReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建 WebSocket 连接失败:', error)
|
||||
this.attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连
|
||||
*/
|
||||
private attemptReconnect() {
|
||||
const maxAttempts = this.getMaxReconnectAttempts()
|
||||
if (this.reconnectAttempts >= maxAttempts) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts += 1
|
||||
const baseInterval = this.getReconnectInterval()
|
||||
const delay = Math.min(baseInterval * this.reconnectAttempts, 30000)
|
||||
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
this.connect() // connect 是 async 但这里不需要 await,它内部会处理错误
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
private startHeartbeat() {
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send('ping')
|
||||
}
|
||||
}, 30000) // 每30秒发送一次心跳
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatInterval !== null) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
// 启动连接
|
||||
await this.wsControl.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
if (this.wsControl) {
|
||||
this.wsControl.disconnect()
|
||||
this.wsControl = null
|
||||
}
|
||||
|
||||
this.isConnected = false
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 人物信息管理 API
|
||||
*/
|
||||
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
PersonListResponse,
|
||||
PersonDetailResponse,
|
||||
@@ -9,10 +10,22 @@ import type {
|
||||
PersonUpdateResponse,
|
||||
PersonDeleteResponse,
|
||||
PersonStatsResponse,
|
||||
PersonInfo,
|
||||
PersonStats,
|
||||
} from '@/types/person'
|
||||
|
||||
const API_BASE = '/api/webui/person'
|
||||
|
||||
/**
|
||||
* Person list response with pagination info
|
||||
*/
|
||||
export interface PersonListData {
|
||||
data: PersonInfo[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取人物信息列表
|
||||
*/
|
||||
@@ -22,7 +35,7 @@ export async function getPersonList(params: {
|
||||
search?: string
|
||||
is_known?: boolean
|
||||
platform?: string
|
||||
}): Promise<PersonListResponse> {
|
||||
}): Promise<ApiResponse<PersonListData>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
@@ -36,27 +49,88 @@ export async function getPersonList(params: {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取人物列表失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取人物列表失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取人物列表失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data: PersonListResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
data: data.data,
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
page_size: data.page_size,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取人物列表失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取人物详细信息
|
||||
*/
|
||||
export async function getPersonDetail(personId: string): Promise<PersonDetailResponse> {
|
||||
export async function getPersonDetail(personId: string): Promise<ApiResponse<PersonInfo>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取人物详情失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取人物详情失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取人物详情失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data: PersonDetailResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取人物详情失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +139,7 @@ export async function getPersonDetail(personId: string): Promise<PersonDetailRes
|
||||
export async function updatePerson(
|
||||
personId: string,
|
||||
data: PersonUpdateRequest
|
||||
): Promise<PersonUpdateResponse> {
|
||||
): Promise<ApiResponse<PersonInfo>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
@@ -73,56 +147,141 @@ export async function updatePerson(
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '更新人物信息失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '更新人物信息失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '更新人物信息失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data: PersonUpdateResponse = await response.json()
|
||||
if (data.success && data.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || '更新人物信息失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除人物信息
|
||||
*/
|
||||
export async function deletePerson(personId: string): Promise<PersonDeleteResponse> {
|
||||
export async function deletePerson(personId: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '删除人物信息失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '删除人物信息失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '删除人物信息失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data: PersonDeleteResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as void,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || '删除人物信息失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取人物统计数据
|
||||
*/
|
||||
export async function getPersonStats(): Promise<PersonStatsResponse> {
|
||||
export async function getPersonStats(): Promise<ApiResponse<PersonStats>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '获取统计数据失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '获取统计数据失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '获取统计数据失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data: PersonStatsResponse = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '获取统计数据失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除人物信息
|
||||
*/
|
||||
export async function batchDeletePersons(personIds: string[]): Promise<{
|
||||
success: boolean
|
||||
export async function batchDeletePersons(
|
||||
personIds: string[]
|
||||
): Promise<ApiResponse<{
|
||||
message: string
|
||||
deleted_count: number
|
||||
failed_count: number
|
||||
failed_ids: string[]
|
||||
}> {
|
||||
}>> {
|
||||
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
@@ -130,9 +289,42 @@ export async function batchDeletePersons(personIds: string[]): Promise<{
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '批量删除失败')
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || errorData.message || '批量删除失败',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.statusText || '批量删除失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: data.message,
|
||||
deleted_count: data.deleted_count,
|
||||
failed_count: data.failed_count,
|
||||
failed_ids: data.failed_ids,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || '批量删除失败',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse response',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,722 +0,0 @@
|
||||
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||
import type { PluginInfo } from '@/types/plugin'
|
||||
|
||||
/**
|
||||
* Git 安装状态
|
||||
*/
|
||||
export interface GitStatus {
|
||||
installed: boolean
|
||||
version?: string
|
||||
path?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦麦版本信息
|
||||
*/
|
||||
export interface MaimaiVersion {
|
||||
version: string
|
||||
version_major: number
|
||||
version_minor: number
|
||||
version_patch: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 已安装插件信息
|
||||
*/
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
manifest: {
|
||||
manifest_version: number
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
license: string
|
||||
host_application: {
|
||||
min_version: string
|
||||
max_version?: string
|
||||
}
|
||||
homepage_url?: string
|
||||
repository_url?: string
|
||||
keywords?: string[]
|
||||
categories?: string[]
|
||||
[key: string]: unknown // 允许其他字段
|
||||
}
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件加载进度
|
||||
*/
|
||||
export interface PluginLoadProgress {
|
||||
operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update'
|
||||
stage: 'idle' | 'loading' | 'success' | 'error'
|
||||
progress: number // 0-100
|
||||
message: string
|
||||
error?: string
|
||||
plugin_id?: string
|
||||
total_plugins: number
|
||||
loaded_plugins: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件仓库配置
|
||||
*/
|
||||
const PLUGIN_REPO_OWNER = 'Mai-with-u'
|
||||
const PLUGIN_REPO_NAME = 'plugin-repo'
|
||||
const PLUGIN_REPO_BRANCH = 'main'
|
||||
const PLUGIN_DETAILS_FILE = 'plugin_details.json'
|
||||
|
||||
/**
|
||||
* 插件列表 API 响应类型(只包含我们需要的字段)
|
||||
*/
|
||||
interface PluginApiResponse {
|
||||
id: string
|
||||
manifest: {
|
||||
manifest_version: number
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
license: string
|
||||
host_application: {
|
||||
min_version: string
|
||||
max_version?: string
|
||||
}
|
||||
homepage_url?: string
|
||||
repository_url?: string
|
||||
keywords: string[]
|
||||
categories?: string[]
|
||||
default_locale: string
|
||||
locales_path?: string
|
||||
}
|
||||
// 可能还有其他字段,但我们不关心
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取插件列表(通过后端代理避免 CORS)
|
||||
*/
|
||||
export async function fetchPluginList(): Promise<PluginInfo[]> {
|
||||
try {
|
||||
// 通过后端 API 获取 Raw 文件
|
||||
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({
|
||||
owner: PLUGIN_REPO_OWNER,
|
||||
repo: PLUGIN_REPO_NAME,
|
||||
branch: PLUGIN_REPO_BRANCH,
|
||||
file_path: PLUGIN_DETAILS_FILE
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// 检查后端返回的结果
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || '获取插件列表失败')
|
||||
}
|
||||
|
||||
const data: PluginApiResponse[] = JSON.parse(result.data)
|
||||
|
||||
// 转换为 PluginInfo 格式,并过滤掉无效数据
|
||||
const pluginList = data
|
||||
.filter(item => {
|
||||
// 验证必需字段
|
||||
if (!item?.id || !item?.manifest) {
|
||||
console.warn('跳过无效插件数据:', item)
|
||||
return false
|
||||
}
|
||||
if (!item.manifest.name || !item.manifest.version) {
|
||||
console.warn('跳过缺少必需字段的插件:', item.id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
manifest: {
|
||||
manifest_version: item.manifest.manifest_version || 1,
|
||||
name: item.manifest.name,
|
||||
version: item.manifest.version,
|
||||
description: item.manifest.description || '',
|
||||
author: item.manifest.author || { name: 'Unknown' },
|
||||
license: item.manifest.license || 'Unknown',
|
||||
host_application: item.manifest.host_application || { min_version: '0.0.0' },
|
||||
homepage_url: item.manifest.homepage_url,
|
||||
repository_url: item.manifest.repository_url,
|
||||
keywords: item.manifest.keywords || [],
|
||||
categories: item.manifest.categories || [],
|
||||
default_locale: item.manifest.default_locale || 'zh-CN',
|
||||
locales_path: item.manifest.locales_path,
|
||||
},
|
||||
// 默认值,这些信息可能需要从其他 API 获取
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
review_count: 0,
|
||||
installed: false,
|
||||
published_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
return pluginList
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin list:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查本机 Git 安装状态
|
||||
*/
|
||||
export async function checkGitStatus(): Promise<GitStatus> {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/git-status')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to check Git status:', error)
|
||||
// 返回未安装状态
|
||||
return {
|
||||
installed: false,
|
||||
error: '无法检测 Git 安装状态'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取麦麦版本信息
|
||||
*/
|
||||
export async function getMaimaiVersion(): Promise<MaimaiVersion> {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/version')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to get Maimai version:', error)
|
||||
// 返回默认版本
|
||||
return {
|
||||
version: '0.0.0',
|
||||
version_major: 0,
|
||||
version_minor: 0,
|
||||
version_patch: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
*
|
||||
* @param pluginMinVersion 插件要求的最小版本
|
||||
* @param pluginMaxVersion 插件要求的最大版本(可选)
|
||||
* @param maimaiVersion 麦麦当前版本
|
||||
* @returns true 表示兼容,false 表示不兼容
|
||||
*/
|
||||
export function isPluginCompatible(
|
||||
pluginMinVersion: string,
|
||||
pluginMaxVersion: string | undefined,
|
||||
maimaiVersion: MaimaiVersion
|
||||
): boolean {
|
||||
// 解析插件最小版本
|
||||
const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0)
|
||||
const minMajor = minParts[0] || 0
|
||||
const minMinor = minParts[1] || 0
|
||||
const minPatch = minParts[2] || 0
|
||||
|
||||
// 检查最小版本
|
||||
if (maimaiVersion.version_major < minMajor) return false
|
||||
if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false
|
||||
if (maimaiVersion.version_major === minMajor &&
|
||||
maimaiVersion.version_minor === minMinor &&
|
||||
maimaiVersion.version_patch < minPatch) return false
|
||||
|
||||
// 检查最大版本(如果有)
|
||||
if (pluginMaxVersion) {
|
||||
const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0)
|
||||
const maxMajor = maxParts[0] || 0
|
||||
const maxMinor = maxParts[1] || 0
|
||||
const maxPatch = maxParts[2] || 0
|
||||
|
||||
if (maimaiVersion.version_major > maxMajor) return false
|
||||
if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false
|
||||
if (maimaiVersion.version_major === maxMajor &&
|
||||
maimaiVersion.version_minor === maxMinor &&
|
||||
maimaiVersion.version_patch > maxPatch) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 临时认证 token
|
||||
*/
|
||||
async function getWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/webui/ws-token')
|
||||
if (!response.ok) {
|
||||
console.error('获取 WebSocket token 失败:', response.status)
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data.success && data.token) {
|
||||
return data.token
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取 WebSocket token 失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接插件加载进度 WebSocket
|
||||
*
|
||||
* 使用临时 token 进行认证,异步获取 token 后连接
|
||||
*/
|
||||
export async function connectPluginProgressWebSocket(
|
||||
onProgress: (progress: PluginLoadProgress) => void,
|
||||
onError?: (error: Event) => void
|
||||
): Promise<WebSocket | null> {
|
||||
// 先获取临时 token
|
||||
const wsToken = await getWsToken()
|
||||
if (!wsToken) {
|
||||
console.warn('无法获取 WebSocket token,可能未登录')
|
||||
return null
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress?token=${encodeURIComponent(wsToken)}`
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Plugin progress WebSocket connected')
|
||||
// 发送心跳
|
||||
const heartbeat = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping')
|
||||
} else {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
// 忽略心跳响应
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(event.data) as PluginLoadProgress
|
||||
onProgress(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse progress data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Plugin progress WebSocket error:', error)
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Plugin progress WebSocket disconnected')
|
||||
}
|
||||
|
||||
return ws
|
||||
} catch (error) {
|
||||
console.error('创建 WebSocket 连接失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装插件列表
|
||||
*/
|
||||
export async function getInstalledPlugins(): Promise<InstalledPlugin[]> {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/installed', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取已安装插件列表失败')
|
||||
}
|
||||
|
||||
return result.plugins || []
|
||||
} catch (error) {
|
||||
console.error('Failed to get installed plugins:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件是否已安装
|
||||
*/
|
||||
export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean {
|
||||
return installedPlugins.some(p => p.id === pluginId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装插件的版本
|
||||
*/
|
||||
export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined {
|
||||
const plugin = installedPlugins.find(p => p.id === pluginId)
|
||||
if (!plugin) return undefined
|
||||
|
||||
// 兼容两种格式:新格式有 manifest,旧格式直接有 version
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return plugin.manifest?.version || (plugin as any).version
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/install', {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId,
|
||||
repository_url: repositoryUrl,
|
||||
branch: branch
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '安装失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
export async function uninstallPlugin(pluginId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/uninstall', {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '卸载失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件
|
||||
*/
|
||||
export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string; old_version: string; new_version: string }> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/update', {
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId,
|
||||
repository_url: repositoryUrl,
|
||||
branch: branch
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '更新失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
|
||||
// ============ 插件配置管理 ============
|
||||
|
||||
/**
|
||||
* 列表项字段定义(用于 object 类型的数组项)
|
||||
*/
|
||||
export interface ItemFieldDefinition {
|
||||
type: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置字段定义
|
||||
*/
|
||||
export interface ConfigFieldSchema {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
example?: string
|
||||
required: boolean
|
||||
choices?: unknown[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
pattern?: string
|
||||
max_length?: number
|
||||
label: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
icon?: string
|
||||
hidden: boolean
|
||||
disabled: boolean
|
||||
order: number
|
||||
input_type?: string
|
||||
ui_type: string
|
||||
rows?: number
|
||||
group?: string
|
||||
depends_on?: string
|
||||
depends_value?: unknown
|
||||
// 列表类型专用
|
||||
item_type?: string // "string" | "number" | "object"
|
||||
item_fields?: Record<string, ItemFieldDefinition>
|
||||
min_items?: number
|
||||
max_items?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置节定义
|
||||
*/
|
||||
export interface ConfigSectionSchema {
|
||||
name: string
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
collapsed: boolean
|
||||
order: number
|
||||
fields: Record<string, ConfigFieldSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置标签页定义
|
||||
*/
|
||||
export interface ConfigTabSchema {
|
||||
id: string
|
||||
title: string
|
||||
sections: string[]
|
||||
icon?: string
|
||||
order: number
|
||||
badge?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置布局定义
|
||||
*/
|
||||
export interface ConfigLayoutSchema {
|
||||
type: 'auto' | 'tabs' | 'pages'
|
||||
tabs: ConfigTabSchema[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件配置 Schema
|
||||
*/
|
||||
export interface PluginConfigSchema {
|
||||
plugin_id: string
|
||||
plugin_info: {
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
}
|
||||
sections: Record<string, ConfigSectionSchema>
|
||||
layout: ConfigLayoutSchema
|
||||
_note?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置 Schema
|
||||
*/
|
||||
export async function getPluginConfigSchema(pluginId: string): Promise<PluginConfigSchema> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
try {
|
||||
const error = JSON.parse(text)
|
||||
throw new Error(error.detail || '获取配置 Schema 失败')
|
||||
} catch {
|
||||
throw new Error(`获取配置 Schema 失败 (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取配置 Schema 失败')
|
||||
}
|
||||
|
||||
return result.schema
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件当前配置值
|
||||
*/
|
||||
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
try {
|
||||
const error = JSON.parse(text)
|
||||
throw new Error(error.detail || '获取配置失败')
|
||||
} catch {
|
||||
throw new Error(`获取配置失败 (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取配置失败')
|
||||
}
|
||||
|
||||
return result.config
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件原始 TOML 配置
|
||||
*/
|
||||
export async function getPluginConfigRaw(pluginId: string): Promise<string> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
try {
|
||||
const error = JSON.parse(text)
|
||||
throw new Error(error.detail || '获取配置失败')
|
||||
} catch {
|
||||
throw new Error(`获取配置失败 (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '获取配置失败')
|
||||
}
|
||||
|
||||
return result.config
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件配置
|
||||
*/
|
||||
export async function updatePluginConfig(
|
||||
pluginId: string,
|
||||
config: Record<string, unknown>
|
||||
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '保存配置失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件原始 TOML 配置
|
||||
*/
|
||||
export async function updatePluginConfigRaw(
|
||||
pluginId: string,
|
||||
configToml: string
|
||||
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config: configToml })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '保存配置失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置插件配置为默认值
|
||||
*/
|
||||
export async function resetPluginConfig(
|
||||
pluginId: string
|
||||
): Promise<{ success: boolean; message: string; backup?: string }> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '重置配置失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换插件启用状态
|
||||
*/
|
||||
export async function togglePlugin(
|
||||
pluginId: string
|
||||
): Promise<{ success: boolean; enabled: boolean; message: string; note?: string }> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || '切换状态失败')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
150
dashboard/src/lib/plugin-api/config.ts
Normal file
150
dashboard/src/lib/plugin-api/config.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
|
||||
import type { PluginConfigSchema } from './types'
|
||||
|
||||
/**
|
||||
* 获取插件配置 Schema
|
||||
*/
|
||||
export async function getPluginConfigSchema(pluginId: string): Promise<ApiResponse<PluginConfigSchema>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
const apiResult = await parseResponse<{ success: boolean; schema?: PluginConfigSchema; message?: string }>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
const result = apiResult.data
|
||||
if (!result.success || !result.schema) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '获取配置 Schema 失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.schema
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件当前配置值
|
||||
*/
|
||||
export async function getPluginConfig(pluginId: string): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
const apiResult = await parseResponse<{ success: boolean; config?: Record<string, unknown>; message?: string }>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
const result = apiResult.data
|
||||
if (!result.success || !result.config) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '获取配置失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件原始 TOML 配置
|
||||
*/
|
||||
export async function getPluginConfigRaw(pluginId: string): Promise<ApiResponse<string>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
const apiResult = await parseResponse<{ success: boolean; config?: string; message?: string }>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
const result = apiResult.data
|
||||
if (!result.success || !result.config) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '获取配置失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件配置
|
||||
*/
|
||||
export async function updatePluginConfig(
|
||||
pluginId: string,
|
||||
config: Record<string, unknown>
|
||||
): Promise<ApiResponse<{ success: boolean; message: string; note?: string }>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config })
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string; note?: string }>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件原始 TOML 配置
|
||||
*/
|
||||
export async function updatePluginConfigRaw(
|
||||
pluginId: string,
|
||||
configToml: string
|
||||
): Promise<ApiResponse<{ success: boolean; message: string; note?: string }>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config: configToml })
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string; note?: string }>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置插件配置为默认值
|
||||
*/
|
||||
export async function resetPluginConfig(
|
||||
pluginId: string
|
||||
): Promise<ApiResponse<{ success: boolean; message: string; backup?: string }>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string; backup?: string }>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换插件启用状态
|
||||
*/
|
||||
export async function togglePlugin(
|
||||
pluginId: string
|
||||
): Promise<ApiResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>> {
|
||||
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>(response)
|
||||
}
|
||||
5
dashboard/src/lib/plugin-api/index.ts
Normal file
5
dashboard/src/lib/plugin-api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './types'
|
||||
export * from './marketplace'
|
||||
export * from './installed'
|
||||
export * from './install-flow'
|
||||
export * from './config'
|
||||
50
dashboard/src/lib/plugin-api/install-flow.ts
Normal file
50
dashboard/src/lib/plugin-api/install-flow.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId,
|
||||
repository_url: repositoryUrl,
|
||||
branch: branch
|
||||
})
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string }>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
export async function uninstallPlugin(pluginId: string): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/uninstall', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId
|
||||
})
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string }>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件
|
||||
*/
|
||||
export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<ApiResponse<{ success: boolean; message: string; old_version: string; new_version: string }>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plugin_id: pluginId,
|
||||
repository_url: repositoryUrl,
|
||||
branch: branch
|
||||
})
|
||||
})
|
||||
|
||||
return await parseResponse<{ success: boolean; message: string; old_version: string; new_version: string }>(response)
|
||||
}
|
||||
62
dashboard/src/lib/plugin-api/installed.ts
Normal file
62
dashboard/src/lib/plugin-api/installed.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
|
||||
import type { InstalledPlugin, LegacyInstalledPlugin } from './types'
|
||||
|
||||
/**
|
||||
* 获取已安装插件列表
|
||||
*/
|
||||
export async function getInstalledPlugins(): Promise<ApiResponse<InstalledPlugin[]>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/installed', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
const apiResult = await parseResponse<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = apiResult.data
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.plugins || []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件是否已安装
|
||||
*/
|
||||
export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean {
|
||||
return installedPlugins.some(p => p.id === pluginId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装插件的版本
|
||||
*/
|
||||
export function getInstalledPluginVersion(pluginId: string, installedPlugins: (InstalledPlugin | LegacyInstalledPlugin)[]): string | undefined {
|
||||
const plugin = installedPlugins.find(p => p.id === pluginId)
|
||||
if (!plugin) return undefined
|
||||
|
||||
// 兼容两种格式:新格式有 manifest,旧格式直接有 version
|
||||
if ('manifest' in plugin && plugin.manifest) {
|
||||
return plugin.manifest.version
|
||||
}
|
||||
// 旧版本格式
|
||||
if ('version' in plugin) {
|
||||
return plugin.version
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
252
dashboard/src/lib/plugin-api/marketplace.ts
Normal file
252
dashboard/src/lib/plugin-api/marketplace.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type { PluginInfo } from '@/types/plugin'
|
||||
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
|
||||
import type { GitStatus, MaimaiVersion } from './types'
|
||||
|
||||
/**
|
||||
* 插件仓库配置
|
||||
*/
|
||||
const PLUGIN_REPO_OWNER = 'Mai-with-u'
|
||||
const PLUGIN_REPO_NAME = 'plugin-repo'
|
||||
const PLUGIN_REPO_BRANCH = 'main'
|
||||
const PLUGIN_DETAILS_FILE = 'plugin_details.json'
|
||||
|
||||
/**
|
||||
* 插件列表 API 响应类型(只包含我们需要的字段)
|
||||
*/
|
||||
interface PluginApiResponse {
|
||||
id: string
|
||||
manifest: {
|
||||
manifest_version: number
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
license: string
|
||||
host_application: {
|
||||
min_version: string
|
||||
max_version?: string
|
||||
}
|
||||
homepage_url?: string
|
||||
repository_url?: string
|
||||
keywords: string[]
|
||||
categories?: string[]
|
||||
default_locale: string
|
||||
locales_path?: string
|
||||
}
|
||||
// 可能还有其他字段,但我们不关心
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取插件列表(通过后端代理避免 CORS)
|
||||
*/
|
||||
export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
owner: PLUGIN_REPO_OWNER,
|
||||
repo: PLUGIN_REPO_NAME,
|
||||
branch: PLUGIN_REPO_BRANCH,
|
||||
file_path: PLUGIN_DETAILS_FILE
|
||||
})
|
||||
})
|
||||
|
||||
const apiResult = await parseResponse<{ success: boolean; data: string; error?: string }>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
const result = apiResult.data
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || '获取插件列表失败'
|
||||
}
|
||||
}
|
||||
|
||||
const data: PluginApiResponse[] = JSON.parse(result.data)
|
||||
|
||||
const pluginList = data
|
||||
.filter(item => {
|
||||
if (!item?.id || !item?.manifest) {
|
||||
console.warn('跳过无效插件数据:', item)
|
||||
return false
|
||||
}
|
||||
if (!item.manifest.name || !item.manifest.version) {
|
||||
console.warn('跳过缺少必需字段的插件:', item.id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
manifest: {
|
||||
manifest_version: item.manifest.manifest_version || 1,
|
||||
name: item.manifest.name,
|
||||
version: item.manifest.version,
|
||||
description: item.manifest.description || '',
|
||||
author: item.manifest.author || { name: 'Unknown' },
|
||||
license: item.manifest.license || 'Unknown',
|
||||
host_application: item.manifest.host_application || { min_version: '0.0.0' },
|
||||
homepage_url: item.manifest.homepage_url,
|
||||
repository_url: item.manifest.repository_url,
|
||||
keywords: item.manifest.keywords || [],
|
||||
categories: item.manifest.categories || [],
|
||||
default_locale: item.manifest.default_locale || 'zh-CN',
|
||||
locales_path: item.manifest.locales_path,
|
||||
},
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
review_count: 0,
|
||||
installed: false,
|
||||
published_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: pluginList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查本机 Git 安装状态
|
||||
*/
|
||||
export async function checkGitStatus(): Promise<ApiResponse<GitStatus>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/git-status')
|
||||
|
||||
const apiResult = await parseResponse<GitStatus>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
installed: false,
|
||||
error: '无法检测 Git 安装状态'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取麦麦版本信息
|
||||
*/
|
||||
export async function getMaimaiVersion(): Promise<ApiResponse<MaimaiVersion>> {
|
||||
const response = await fetchWithAuth('/api/webui/plugins/version')
|
||||
|
||||
const apiResult = await parseResponse<MaimaiVersion>(response)
|
||||
|
||||
if (!apiResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
version: '0.0.0',
|
||||
version_major: 0,
|
||||
version_minor: 0,
|
||||
version_patch: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
*
|
||||
* @param pluginMinVersion 插件要求的最小版本
|
||||
* @param pluginMaxVersion 插件要求的最大版本(可选)
|
||||
* @param maimaiVersion 麦麦当前版本
|
||||
* @returns true 表示兼容,false 表示不兼容
|
||||
*/
|
||||
export function isPluginCompatible(
|
||||
pluginMinVersion: string,
|
||||
pluginMaxVersion: string | undefined,
|
||||
maimaiVersion: MaimaiVersion
|
||||
): boolean {
|
||||
// 解析插件最小版本
|
||||
const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0)
|
||||
const minMajor = minParts[0] || 0
|
||||
const minMinor = minParts[1] || 0
|
||||
const minPatch = minParts[2] || 0
|
||||
|
||||
// 检查最小版本
|
||||
if (maimaiVersion.version_major < minMajor) return false
|
||||
if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false
|
||||
if (maimaiVersion.version_major === minMajor &&
|
||||
maimaiVersion.version_minor === minMinor &&
|
||||
maimaiVersion.version_patch < minPatch) return false
|
||||
|
||||
// 检查最大版本(如果有)
|
||||
if (pluginMaxVersion) {
|
||||
const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0)
|
||||
const maxMajor = maxParts[0] || 0
|
||||
const maxMinor = maxParts[1] || 0
|
||||
const maxPatch = maxParts[2] || 0
|
||||
|
||||
if (maimaiVersion.version_major > maxMajor) return false
|
||||
if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false
|
||||
if (maimaiVersion.version_major === maxMajor &&
|
||||
maimaiVersion.version_minor === maxMinor &&
|
||||
maimaiVersion.version_patch > maxPatch) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接插件加载进度 WebSocket
|
||||
*
|
||||
* 使用临时 token 进行认证,异步获取 token 后连接
|
||||
*/
|
||||
export async function connectPluginProgressWebSocket(
|
||||
onProgress: (progress: import('./types').PluginLoadProgress) => void,
|
||||
onError?: (error: Event) => void
|
||||
): Promise<WebSocket | null> {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress`
|
||||
|
||||
// 使用 ws-utils 创建 WebSocket
|
||||
const { createReconnectingWebSocket } = await import('@/lib/ws-utils')
|
||||
const wsControl = createReconnectingWebSocket(wsUrl, {
|
||||
onMessage: (data: string) => {
|
||||
try {
|
||||
const progressData = JSON.parse(data) as import('./types').PluginLoadProgress
|
||||
onProgress(progressData)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse progress data:', error)
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
console.log('Plugin progress WebSocket connected')
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('Plugin progress WebSocket disconnected')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Plugin progress WebSocket error:', error)
|
||||
onError?.(error)
|
||||
},
|
||||
heartbeatInterval: 30000,
|
||||
maxRetries: 10,
|
||||
backoffBase: 1000,
|
||||
maxBackoff: 30000,
|
||||
})
|
||||
|
||||
// 启动连接
|
||||
await wsControl.connect()
|
||||
|
||||
// 返回 WebSocket 实例(用于外部检查连接状态)
|
||||
return wsControl.getWebSocket()
|
||||
}
|
||||
164
dashboard/src/lib/plugin-api/types.ts
Normal file
164
dashboard/src/lib/plugin-api/types.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Git 安装状态
|
||||
*/
|
||||
export interface GitStatus {
|
||||
installed: boolean
|
||||
version?: string
|
||||
path?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦麦版本信息
|
||||
*/
|
||||
export interface MaimaiVersion {
|
||||
version: string
|
||||
version_major: number
|
||||
version_minor: number
|
||||
version_patch: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 已安装插件信息
|
||||
*/
|
||||
export interface InstalledPlugin {
|
||||
id: string
|
||||
manifest: {
|
||||
manifest_version: number
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
license: string
|
||||
host_application: {
|
||||
min_version: string
|
||||
max_version?: string
|
||||
}
|
||||
homepage_url?: string
|
||||
repository_url?: string
|
||||
keywords?: string[]
|
||||
categories?: string[]
|
||||
[key: string]: unknown // 允许其他字段
|
||||
}
|
||||
path: string
|
||||
}
|
||||
/**
|
||||
* 旧版本插件格式(直接包含 version 字段)
|
||||
*/
|
||||
export interface LegacyInstalledPlugin {
|
||||
id: string
|
||||
version: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件加载进度
|
||||
*/
|
||||
export interface PluginLoadProgress {
|
||||
operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update'
|
||||
stage: 'idle' | 'loading' | 'success' | 'error'
|
||||
progress: number // 0-100
|
||||
message: string
|
||||
error?: string
|
||||
plugin_id?: string
|
||||
total_plugins: number
|
||||
loaded_plugins: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表项字段定义(用于 object 类型的数组项)
|
||||
*/
|
||||
export interface ItemFieldDefinition {
|
||||
type: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置字段定义
|
||||
*/
|
||||
export interface ConfigFieldSchema {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
example?: string
|
||||
required: boolean
|
||||
choices?: unknown[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
pattern?: string
|
||||
max_length?: number
|
||||
label: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
icon?: string
|
||||
hidden: boolean
|
||||
disabled: boolean
|
||||
order: number
|
||||
input_type?: string
|
||||
ui_type: string
|
||||
rows?: number
|
||||
group?: string
|
||||
depends_on?: string
|
||||
depends_value?: unknown
|
||||
// 列表类型专用
|
||||
item_type?: string // "string" | "number" | "object"
|
||||
item_fields?: Record<string, ItemFieldDefinition>
|
||||
min_items?: number
|
||||
max_items?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置节定义
|
||||
*/
|
||||
export interface ConfigSectionSchema {
|
||||
name: string
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
collapsed: boolean
|
||||
order: number
|
||||
fields: Record<string, ConfigFieldSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置标签页定义
|
||||
*/
|
||||
export interface ConfigTabSchema {
|
||||
id: string
|
||||
title: string
|
||||
sections: string[]
|
||||
icon?: string
|
||||
order: number
|
||||
badge?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置布局定义
|
||||
*/
|
||||
export interface ConfigLayoutSchema {
|
||||
type: 'auto' | 'tabs' | 'pages'
|
||||
tabs: ConfigTabSchema[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件配置 Schema
|
||||
*/
|
||||
export interface PluginConfigSchema {
|
||||
plugin_id: string
|
||||
plugin_info: {
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
}
|
||||
sections: Record<string, ConfigSectionSchema>
|
||||
layout: ConfigLayoutSchema
|
||||
_note?: string
|
||||
}
|
||||
@@ -310,9 +310,9 @@ export function RestartProvider({
|
||||
}
|
||||
|
||||
return (
|
||||
<RestartContext.Provider value={contextValue}>
|
||||
<RestartContext value={contextValue}>
|
||||
{children}
|
||||
</RestartContext.Provider>
|
||||
</RestartContext>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
211
dashboard/src/lib/ws-utils.ts
Normal file
211
dashboard/src/lib/ws-utils.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { fetchWithAuth } from './fetch-with-auth'
|
||||
|
||||
/**
|
||||
* WebSocket 配置选项
|
||||
*/
|
||||
export interface WebSocketOptions {
|
||||
onMessage?: (data: string) => void
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
onError?: (error: Event) => void
|
||||
heartbeatInterval?: number // 心跳间隔(毫秒)
|
||||
maxRetries?: number // 最大重连次数
|
||||
backoffBase?: number // 重连基础间隔(毫秒)
|
||||
maxBackoff?: number // 最大重连间隔(毫秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 临时认证 token
|
||||
*/
|
||||
export async function getWsToken(): Promise<string | null> {
|
||||
try {
|
||||
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
|
||||
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include', // 携带 Cookie
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取 WebSocket token 失败:', response.status)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.token) {
|
||||
return data.token
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取 WebSocket token 失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带重连、心跳的 WebSocket 封装
|
||||
*
|
||||
* @param url WebSocket URL(不含 token 参数)
|
||||
* @param options 配置选项
|
||||
* @returns WebSocket 控制对象,包含 connect、disconnect、send 方法
|
||||
*/
|
||||
export function createReconnectingWebSocket(
|
||||
url: string,
|
||||
options: WebSocketOptions = {}
|
||||
) {
|
||||
const {
|
||||
onMessage,
|
||||
onOpen,
|
||||
onClose,
|
||||
onError,
|
||||
heartbeatInterval = 30000,
|
||||
maxRetries = 10,
|
||||
backoffBase = 1000,
|
||||
maxBackoff = 30000,
|
||||
} = options
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
let heartbeatIntervalId: number | null = null
|
||||
let isManualDisconnect = false
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatIntervalId = window.setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping')
|
||||
}
|
||||
}, heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatIntervalId !== null) {
|
||||
clearInterval(heartbeatIntervalId)
|
||||
heartbeatIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连
|
||||
*/
|
||||
function attemptReconnect() {
|
||||
if (isManualDisconnect) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reconnectAttempts >= maxRetries) {
|
||||
console.warn(`WebSocket 达到最大重连次数 (${maxRetries}),停止重连`)
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempts += 1
|
||||
const delay = Math.min(backoffBase * reconnectAttempts, maxBackoff)
|
||||
|
||||
console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts} 次)`)
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
async function connect() {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取临时认证 token
|
||||
const wsToken = await getWsToken()
|
||||
if (!wsToken) {
|
||||
console.warn('无法获取 WebSocket token,跳过连接')
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `${url}?token=${encodeURIComponent(wsToken)}`
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0
|
||||
startHeartbeat()
|
||||
onOpen?.()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 忽略心跳响应
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
onMessage?.(event.data)
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error)
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
stopHeartbeat()
|
||||
onClose?.()
|
||||
attemptReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建 WebSocket 连接失败:', error)
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
function disconnect() {
|
||||
isManualDisconnect = true
|
||||
|
||||
if (reconnectTimeout !== null) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
stopHeartbeat()
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
function send(data: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,无法发送消息')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 WebSocket 实例
|
||||
*/
|
||||
function getWebSocket(): WebSocket | null {
|
||||
return ws
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
getWebSocket,
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ 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 { ChatPage } from './routes/chat/index'
|
||||
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
|
||||
import { AnnualReportPage } from './routes/annual-report'
|
||||
import PackMarketPage from './routes/config/pack-market'
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useState } 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'
|
||||
AlertCircle,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Key,
|
||||
Lock,
|
||||
Moon,
|
||||
Sun,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,13 +24,35 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { WavesBackground } from '@/components/waves-background'
|
||||
import { useAnimation } from '@/hooks/use-animation'
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
|
||||
import { useAnimation } from '@/hooks/use-animation'
|
||||
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
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)
|
||||
@@ -83,7 +105,7 @@ export function AuthPage() {
|
||||
}
|
||||
|
||||
setIsValidating(true)
|
||||
|
||||
|
||||
console.log('开始验证 token...')
|
||||
|
||||
try {
|
||||
@@ -98,22 +120,34 @@ export function AuthPage() {
|
||||
})
|
||||
|
||||
console.log('Token 验证响应状态:', response.status)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const result = await parseResponse<{
|
||||
valid: boolean
|
||||
is_first_setup?: boolean
|
||||
message?: string
|
||||
}>(response)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Token 验证失败:', result.error)
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const data = result.data
|
||||
console.log('Token 验证响应数据:', data)
|
||||
|
||||
if (response.ok && data.valid) {
|
||||
if (data.valid) {
|
||||
console.log('Token 验证成功,准备跳转...')
|
||||
console.log('is_first_setup:', data.is_first_setup)
|
||||
|
||||
|
||||
// Token 验证成功,Cookie 已由后端设置
|
||||
// 等待一小段时间确保 Cookie 已设置
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 再次检查认证状态
|
||||
const authCheck = await checkAuthStatus()
|
||||
console.log('跳转前认证状态检查:', authCheck)
|
||||
|
||||
|
||||
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
|
||||
if (data.is_first_setup) {
|
||||
console.log('跳转到首次配置页面')
|
||||
@@ -130,7 +164,9 @@ export function AuthPage() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Token 验证错误:', err)
|
||||
setError('连接服务器失败,请检查网络连接')
|
||||
setError(
|
||||
err instanceof Error ? err.message : '连接服务器失败,请检查网络连接'
|
||||
)
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
|
||||
79
dashboard/src/routes/chat/ChatTabBar.tsx
Normal file
79
dashboard/src/routes/chat/ChatTabBar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react'
|
||||
|
||||
import type { ChatTab } from './types'
|
||||
|
||||
interface ChatTabBarProps {
|
||||
tabs: ChatTab[]
|
||||
activeTabId: string
|
||||
onSwitch: (tabId: string) => void
|
||||
onClose: (tabId: string, e?: React.MouseEvent | React.KeyboardEvent) => void
|
||||
onAddVirtual: () => void
|
||||
}
|
||||
|
||||
export function ChatTabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSwitch,
|
||||
onClose,
|
||||
onAddVirtual,
|
||||
}: ChatTabBarProps) {
|
||||
return (
|
||||
<div className="shrink-0 border-b bg-muted/30">
|
||||
<div className="max-w-4xl mx-auto px-2 sm:px-4">
|
||||
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
|
||||
"hover:bg-muted",
|
||||
activeTabId === tab.id
|
||||
? "bg-background shadow-sm border"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => onSwitch(tab.id)}
|
||||
>
|
||||
{tab.type === 'webui' ? (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<UserCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{tab.label}</span>
|
||||
{/* 连接状态指示器 */}
|
||||
<span className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
tab.isConnected ? "bg-green-500" : "bg-muted-foreground/50"
|
||||
)} />
|
||||
{/* 关闭按钮(非默认标签页) */}
|
||||
{tab.id !== 'webui-default' && (
|
||||
<span
|
||||
onClick={(e) => onClose(tab.id, e)}
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClose(tab.id, e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 新建虚拟身份标签页按钮 */}
|
||||
<button
|
||||
onClick={onAddVirtual}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title="新建虚拟身份对话"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
dashboard/src/routes/chat/MessageRenderer.tsx
Normal file
96
dashboard/src/routes/chat/MessageRenderer.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ChatMessage, MessageSegment } from './types'
|
||||
|
||||
// 渲染单个消息段
|
||||
export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return <span className="whitespace-pre-wrap">{String(segment.data)}</span>
|
||||
|
||||
case 'image':
|
||||
case 'emoji':
|
||||
return (
|
||||
<img
|
||||
src={String(segment.data)}
|
||||
alt={segment.type === 'emoji' ? '表情包' : '图片'}
|
||||
className={cn(
|
||||
"rounded-lg max-w-full",
|
||||
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
|
||||
)}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示占位符
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'voice':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<audio
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="max-w-[200px] h-8"
|
||||
>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="rounded-lg max-w-full max-h-64"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
)
|
||||
|
||||
case 'face':
|
||||
// QQ 原生表情,显示为文本
|
||||
return <span className="text-muted-foreground">[表情:{String(segment.data)}]</span>
|
||||
|
||||
case 'music':
|
||||
return <span className="text-muted-foreground">[音乐分享]</span>
|
||||
|
||||
case 'file':
|
||||
return <span className="text-muted-foreground">[文件: {String(segment.data)}]</span>
|
||||
|
||||
case 'reply':
|
||||
return <span className="text-muted-foreground text-xs">[回复消息]</span>
|
||||
|
||||
case 'forward':
|
||||
return <span className="text-muted-foreground">[转发消息]</span>
|
||||
|
||||
case 'unknown':
|
||||
default:
|
||||
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span>
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染消息内容(支持富文本)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) {
|
||||
// 如果是富文本消息,渲染消息段
|
||||
if (message.message_type === 'rich' && message.segments && message.segments.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{message.segments.map((segment, index) => (
|
||||
<RenderMessageSegment key={index} segment={segment} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 普通文本消息
|
||||
return <span className="whitespace-pre-wrap">{message.content}</span>
|
||||
}
|
||||
206
dashboard/src/routes/chat/VirtualIdentityDialog.tsx
Normal file
206
dashboard/src/routes/chat/VirtualIdentityDialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react'
|
||||
|
||||
import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types'
|
||||
|
||||
interface VirtualIdentityDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
platforms: PlatformInfo[]
|
||||
persons: PersonInfo[]
|
||||
isLoadingPlatforms: boolean
|
||||
isLoadingPersons: boolean
|
||||
personSearchQuery: string
|
||||
setPersonSearchQuery: (query: string) => void
|
||||
tempVirtualConfig: VirtualIdentityConfig
|
||||
setTempVirtualConfig: React.Dispatch<React.SetStateAction<VirtualIdentityConfig>>
|
||||
onSelectPerson: (person: PersonInfo) => void
|
||||
onCreateVirtualTab: () => void
|
||||
}
|
||||
|
||||
export function VirtualIdentityDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
platforms,
|
||||
persons,
|
||||
isLoadingPlatforms,
|
||||
isLoadingPersons,
|
||||
personSearchQuery,
|
||||
setPersonSearchQuery,
|
||||
tempVirtualConfig,
|
||||
setTempVirtualConfig,
|
||||
onSelectPerson,
|
||||
onCreateVirtualTab,
|
||||
}: VirtualIdentityDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCircle2 className="h-5 w-5" />
|
||||
新建虚拟身份对话
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||
{/* 平台选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
选择平台
|
||||
</Label>
|
||||
<Select
|
||||
value={tempVirtualConfig.platform}
|
||||
onValueChange={(value) => {
|
||||
setTempVirtualConfig(prev => ({
|
||||
...prev,
|
||||
platform: value,
|
||||
personId: '',
|
||||
userId: '',
|
||||
userName: '',
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger disabled={isLoadingPlatforms}>
|
||||
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms.map((p) => (
|
||||
<SelectItem key={p.platform} value={p.platform}>
|
||||
{p.platform} ({p.count} 人)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 用户搜索和选择 */}
|
||||
{tempVirtualConfig.platform && (
|
||||
<div className="space-y-2 flex-1 overflow-hidden flex flex-col">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
选择用户
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名..."
|
||||
value={personSearchQuery}
|
||||
onChange={(e) => setPersonSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-2">
|
||||
{isLoadingPersons ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : persons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Users className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">没有找到用户</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{persons.map((person) => (
|
||||
<button
|
||||
key={person.person_id}
|
||||
onClick={() => onSelectPerson(person)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className={cn(
|
||||
"text-xs",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary-foreground/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{(person.nickname || person.person_name || '?').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">
|
||||
{person.nickname || person.person_name}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-xs truncate",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
ID: {person.user_id}
|
||||
{person.is_known && " · 已认识"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 虚拟群名配置 */}
|
||||
{tempVirtualConfig.personId && (
|
||||
<div className="space-y-2">
|
||||
<Label>虚拟群名(可选)</Label>
|
||||
<Input
|
||||
placeholder="WebUI虚拟群聊"
|
||||
value={tempVirtualConfig.groupName}
|
||||
onChange={(e) => setTempVirtualConfig(prev => ({
|
||||
...prev,
|
||||
groupName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
麦麦会认为这是一个名为此名称的群聊
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateVirtualTab}
|
||||
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
|
||||
>
|
||||
创建对话
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +1,19 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
// Card 组件已移除,改用更简洁的全屏布局
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Send, Bot, User, Loader2, WifiOff, Wifi, RefreshCw, Edit2, Users, Search, X, UserCircle2, Globe, Plus, MessageSquare } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react'
|
||||
|
||||
// 生成唯一用户 ID
|
||||
function generateUserId(): string {
|
||||
return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// 从 localStorage 获取或生成用户 ID
|
||||
function getOrCreateUserId(): string {
|
||||
const storageKey = 'maibot_webui_user_id'
|
||||
let userId = localStorage.getItem(storageKey)
|
||||
if (!userId) {
|
||||
userId = generateUserId()
|
||||
localStorage.setItem(storageKey, userId)
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
// 从 localStorage 获取用户昵称
|
||||
function getStoredUserName(): string {
|
||||
return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户'
|
||||
}
|
||||
|
||||
// 保存用户昵称到 localStorage
|
||||
function saveUserName(name: string): void {
|
||||
localStorage.setItem('maibot_webui_user_name', name)
|
||||
}
|
||||
|
||||
// 虚拟标签页持久化存储 key
|
||||
const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs'
|
||||
|
||||
// 保存的虚拟标签页配置
|
||||
interface SavedVirtualTab {
|
||||
id: string
|
||||
label: string
|
||||
virtualConfig: VirtualIdentityConfig
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// 从 localStorage 获取保存的虚拟标签页
|
||||
function getSavedVirtualTabs(): SavedVirtualTab[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat] 加载虚拟标签页失败:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 保存虚拟标签页到 localStorage
|
||||
function saveVirtualTabs(tabs: SavedVirtualTab[]): void {
|
||||
try {
|
||||
localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs))
|
||||
} catch (e) {
|
||||
console.error('[Chat] 保存虚拟标签页失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 平台信息类型
|
||||
interface PlatformInfo {
|
||||
platform: string
|
||||
count: number
|
||||
}
|
||||
|
||||
// 用户信息类型(从后端获取的人物信息)
|
||||
interface PersonInfo {
|
||||
person_id: string
|
||||
user_id: string
|
||||
person_name: string
|
||||
nickname: string | null
|
||||
platform: string
|
||||
is_known: boolean
|
||||
}
|
||||
|
||||
// 虚拟身份配置
|
||||
interface VirtualIdentityConfig {
|
||||
platform: string
|
||||
personId: string
|
||||
userId: string
|
||||
userName: string
|
||||
groupName: string
|
||||
groupId: string // 虚拟群 ID,用于持久化历史记录
|
||||
}
|
||||
|
||||
// 聊天标签页
|
||||
interface ChatTab {
|
||||
id: string
|
||||
type: 'webui' | 'virtual'
|
||||
label: string
|
||||
virtualConfig?: VirtualIdentityConfig
|
||||
messages: ChatMessage[]
|
||||
isConnected: boolean
|
||||
isTyping: boolean
|
||||
sessionInfo: {
|
||||
session_id?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
bot_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 消息段类型
|
||||
interface MessageSegment {
|
||||
type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown'
|
||||
data: string | number | object
|
||||
original_type?: string
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
type: 'user' | 'bot' | 'system' | 'error' | 'thinking'
|
||||
content: string
|
||||
timestamp: number
|
||||
message_type?: 'text' | 'rich' // 消息格式类型
|
||||
segments?: MessageSegment[] // 富文本消息段
|
||||
sender?: {
|
||||
name: string
|
||||
user_id?: string
|
||||
is_bot?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 消息类型
|
||||
interface WsMessage {
|
||||
type: string
|
||||
content?: string
|
||||
message_id?: string
|
||||
timestamp?: number
|
||||
is_typing?: boolean
|
||||
session_id?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
bot_name?: string
|
||||
sender?: {
|
||||
name: string
|
||||
user_id?: string
|
||||
is_bot?: boolean
|
||||
}
|
||||
// 历史消息列表(用于 type: 'history')
|
||||
messages?: Array<{
|
||||
id?: string
|
||||
content: string
|
||||
timestamp: number
|
||||
sender_name?: string
|
||||
sender_id?: string
|
||||
is_bot?: boolean
|
||||
}>
|
||||
group_id?: string
|
||||
// 富文本消息
|
||||
message_type?: string
|
||||
segments?: MessageSegment[]
|
||||
}
|
||||
|
||||
// 渲染单个消息段
|
||||
function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return <span className="whitespace-pre-wrap">{String(segment.data)}</span>
|
||||
|
||||
case 'image':
|
||||
case 'emoji':
|
||||
return (
|
||||
<img
|
||||
src={String(segment.data)}
|
||||
alt={segment.type === 'emoji' ? '表情包' : '图片'}
|
||||
className={cn(
|
||||
"rounded-lg max-w-full",
|
||||
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
|
||||
)}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示占位符
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'voice':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<audio
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="max-w-[200px] h-8"
|
||||
>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="rounded-lg max-w-full max-h-64"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
)
|
||||
|
||||
case 'face':
|
||||
// QQ 原生表情,显示为文本
|
||||
return <span className="text-muted-foreground">[表情:{String(segment.data)}]</span>
|
||||
|
||||
case 'music':
|
||||
return <span className="text-muted-foreground">[音乐分享]</span>
|
||||
|
||||
case 'file':
|
||||
return <span className="text-muted-foreground">[文件: {String(segment.data)}]</span>
|
||||
|
||||
case 'reply':
|
||||
return <span className="text-muted-foreground text-xs">[回复消息]</span>
|
||||
|
||||
case 'forward':
|
||||
return <span className="text-muted-foreground">[转发消息]</span>
|
||||
|
||||
case 'unknown':
|
||||
default:
|
||||
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span>
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染消息内容(支持富文本)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) {
|
||||
// 如果是富文本消息,渲染消息段
|
||||
if (message.message_type === 'rich' && message.segments && message.segments.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{message.segments.map((segment, index) => (
|
||||
<RenderMessageSegment key={index} segment={segment} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 普通文本消息
|
||||
return <span className="whitespace-pre-wrap">{message.content}</span>
|
||||
}
|
||||
import { ChatTabBar } from './ChatTabBar'
|
||||
import { RenderMessageContent } from './MessageRenderer'
|
||||
import type { ChatTab, ChatMessage, PersonInfo, PlatformInfo, SavedVirtualTab, VirtualIdentityConfig, WsMessage } from './types'
|
||||
import { getOrCreateUserId, getStoredUserName, getSavedVirtualTabs, saveUserName, saveVirtualTabs } from './utils'
|
||||
import { VirtualIdentityDialog } from './VirtualIdentityDialog'
|
||||
|
||||
export function ChatPage() {
|
||||
// 默认 WebUI 标签页
|
||||
@@ -685,7 +427,7 @@ export function ChatPage() {
|
||||
type: 'bot',
|
||||
content: data.content || '',
|
||||
message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich',
|
||||
segments: data.segments as MessageSegment[] | undefined,
|
||||
segments: data.segments,
|
||||
timestamp: data.timestamp || Date.now() / 1000,
|
||||
sender: data.sender,
|
||||
}
|
||||
@@ -1064,7 +806,7 @@ export function ChatPage() {
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = (tabId: string, e?: React.MouseEvent) => {
|
||||
const closeTab = (tabId: string, e?: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
// 不能关闭默认 WebUI 标签页
|
||||
@@ -1129,214 +871,29 @@ export function ChatPage() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 虚拟身份配置对话框 */}
|
||||
<Dialog open={showVirtualConfig} onOpenChange={setShowVirtualConfig}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCircle2 className="h-5 w-5" />
|
||||
新建虚拟身份对话
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||
{/* 平台选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
选择平台
|
||||
</Label>
|
||||
<Select
|
||||
value={tempVirtualConfig.platform}
|
||||
onValueChange={(value) => {
|
||||
setTempVirtualConfig(prev => ({
|
||||
...prev,
|
||||
platform: value,
|
||||
personId: '',
|
||||
userId: '',
|
||||
userName: '',
|
||||
}))
|
||||
setPersons([])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger disabled={isLoadingPlatforms}>
|
||||
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms.map((p) => (
|
||||
<SelectItem key={p.platform} value={p.platform}>
|
||||
{p.platform} ({p.count} 人)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 用户搜索和选择 */}
|
||||
{tempVirtualConfig.platform && (
|
||||
<div className="space-y-2 flex-1 overflow-hidden flex flex-col">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
选择用户
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名..."
|
||||
value={personSearchQuery}
|
||||
onChange={(e) => setPersonSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-2">
|
||||
{isLoadingPersons ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : persons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Users className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">没有找到用户</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{persons.map((person) => (
|
||||
<button
|
||||
key={person.person_id}
|
||||
onClick={() => selectPerson(person)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className={cn(
|
||||
"text-xs",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary-foreground/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{(person.nickname || person.person_name || '?').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">
|
||||
{person.nickname || person.person_name}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-xs truncate",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
ID: {person.user_id}
|
||||
{person.is_known && " · 已认识"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 虚拟群名配置 */}
|
||||
{tempVirtualConfig.personId && (
|
||||
<div className="space-y-2">
|
||||
<Label>虚拟群名(可选)</Label>
|
||||
<Input
|
||||
placeholder="WebUI虚拟群聊"
|
||||
value={tempVirtualConfig.groupName}
|
||||
onChange={(e) => setTempVirtualConfig(prev => ({
|
||||
...prev,
|
||||
groupName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
麦麦会认为这是一个名为此名称的群聊
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setShowVirtualConfig(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createVirtualTab}
|
||||
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
|
||||
>
|
||||
创建对话
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<VirtualIdentityDialog
|
||||
open={showVirtualConfig}
|
||||
onOpenChange={setShowVirtualConfig}
|
||||
platforms={platforms}
|
||||
persons={persons}
|
||||
isLoadingPlatforms={isLoadingPlatforms}
|
||||
isLoadingPersons={isLoadingPersons}
|
||||
personSearchQuery={personSearchQuery}
|
||||
setPersonSearchQuery={setPersonSearchQuery}
|
||||
tempVirtualConfig={tempVirtualConfig}
|
||||
setTempVirtualConfig={setTempVirtualConfig}
|
||||
onSelectPerson={selectPerson}
|
||||
onCreateVirtualTab={createVirtualTab}
|
||||
/>
|
||||
|
||||
{/* 标签页栏 */}
|
||||
<div className="shrink-0 border-b bg-muted/30">
|
||||
<div className="max-w-4xl mx-auto px-2 sm:px-4">
|
||||
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
|
||||
"hover:bg-muted",
|
||||
activeTabId === tab.id
|
||||
? "bg-background shadow-sm border"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
>
|
||||
{tab.type === 'webui' ? (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<UserCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{tab.label}</span>
|
||||
{/* 连接状态指示器 */}
|
||||
<span className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
tab.isConnected ? "bg-green-500" : "bg-muted-foreground/50"
|
||||
)} />
|
||||
{/* 关闭按钮(非默认标签页) */}
|
||||
{tab.id !== 'webui-default' && (
|
||||
<span
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
closeTab(tab.id, e as any)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 新建虚拟身份标签页按钮 */}
|
||||
<button
|
||||
onClick={openVirtualConfig}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title="新建虚拟身份对话"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatTabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onSwitch={switchTab}
|
||||
onClose={closeTab}
|
||||
onAddVirtual={openVirtualConfig}
|
||||
/>
|
||||
|
||||
{/* 头部信息栏 */}
|
||||
<div className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
106
dashboard/src/routes/chat/types.ts
Normal file
106
dashboard/src/routes/chat/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// 虚拟标签页持久化存储 key
|
||||
export const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs'
|
||||
|
||||
// 保存的虚拟标签页配置
|
||||
export interface SavedVirtualTab {
|
||||
id: string
|
||||
label: string
|
||||
virtualConfig: VirtualIdentityConfig
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// 平台信息类型
|
||||
export interface PlatformInfo {
|
||||
platform: string
|
||||
count: number
|
||||
}
|
||||
|
||||
// 用户信息类型(从后端获取的人物信息)
|
||||
export interface PersonInfo {
|
||||
person_id: string
|
||||
user_id: string
|
||||
person_name: string
|
||||
nickname: string | null
|
||||
platform: string
|
||||
is_known: boolean
|
||||
}
|
||||
|
||||
// 虚拟身份配置
|
||||
export interface VirtualIdentityConfig {
|
||||
platform: string
|
||||
personId: string
|
||||
userId: string
|
||||
userName: string
|
||||
groupName: string
|
||||
groupId: string // 虚拟群 ID,用于持久化历史记录
|
||||
}
|
||||
|
||||
// 聊天标签页
|
||||
export interface ChatTab {
|
||||
id: string
|
||||
type: 'webui' | 'virtual'
|
||||
label: string
|
||||
virtualConfig?: VirtualIdentityConfig
|
||||
messages: ChatMessage[]
|
||||
isConnected: boolean
|
||||
isTyping: boolean
|
||||
sessionInfo: {
|
||||
session_id?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
bot_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 消息段类型
|
||||
export interface MessageSegment {
|
||||
type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown'
|
||||
data: string | number | object
|
||||
original_type?: string
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
type: 'user' | 'bot' | 'system' | 'error' | 'thinking'
|
||||
content: string
|
||||
timestamp: number
|
||||
message_type?: 'text' | 'rich' // 消息格式类型
|
||||
segments?: MessageSegment[] // 富文本消息段
|
||||
sender?: {
|
||||
name: string
|
||||
user_id?: string
|
||||
is_bot?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 消息类型
|
||||
export interface WsMessage {
|
||||
type: string
|
||||
content?: string
|
||||
message_id?: string
|
||||
timestamp?: number
|
||||
is_typing?: boolean
|
||||
session_id?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
bot_name?: string
|
||||
sender?: {
|
||||
name: string
|
||||
user_id?: string
|
||||
is_bot?: boolean
|
||||
}
|
||||
// 历史消息列表(用于 type: 'history')
|
||||
messages?: Array<{
|
||||
id?: string
|
||||
content: string
|
||||
timestamp: number
|
||||
sender_name?: string
|
||||
sender_id?: string
|
||||
is_bot?: boolean
|
||||
}>
|
||||
group_id?: string
|
||||
// 富文本消息
|
||||
message_type?: string
|
||||
segments?: MessageSegment[]
|
||||
}
|
||||
50
dashboard/src/routes/chat/utils.ts
Normal file
50
dashboard/src/routes/chat/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { VIRTUAL_TABS_STORAGE_KEY } from './types'
|
||||
import type { SavedVirtualTab } from './types'
|
||||
|
||||
// 生成唯一用户 ID
|
||||
export function generateUserId(): string {
|
||||
return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// 从 localStorage 获取或生成用户 ID
|
||||
export function getOrCreateUserId(): string {
|
||||
const storageKey = 'maibot_webui_user_id'
|
||||
let userId = localStorage.getItem(storageKey)
|
||||
if (!userId) {
|
||||
userId = generateUserId()
|
||||
localStorage.setItem(storageKey, userId)
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
// 从 localStorage 获取用户昵称
|
||||
export function getStoredUserName(): string {
|
||||
return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户'
|
||||
}
|
||||
|
||||
// 保存用户昵称到 localStorage
|
||||
export function saveUserName(name: string): void {
|
||||
localStorage.setItem('maibot_webui_user_name', name)
|
||||
}
|
||||
|
||||
// 从 localStorage 获取保存的虚拟标签页
|
||||
export function getSavedVirtualTabs(): SavedVirtualTab[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat] 加载虚拟标签页失败:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 保存虚拟标签页到 localStorage
|
||||
export function saveVirtualTabs(tabs: SavedVirtualTab[]): void {
|
||||
try {
|
||||
localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs))
|
||||
} catch (e) {
|
||||
console.error('[Chat] 保存虚拟标签页失败:', e)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
BotInfoSection,
|
||||
PersonalitySection,
|
||||
DreamSection,
|
||||
LPMMSection,
|
||||
LogSection,
|
||||
DebugSection,
|
||||
ExperimentalSection,
|
||||
MaimMessageSection,
|
||||
TelemetrySection,
|
||||
FeaturesSection,
|
||||
ExpressionSection,
|
||||
ProcessingSection,
|
||||
MessageReceiveSection,
|
||||
WebUISection,
|
||||
} from './bot/sections'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
|
||||
import { AlertDescription, Alert } from '@/components/ui/alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -26,53 +13,60 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CodeEditor } from '@/components'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { getBotConfig, getBotConfigRaw, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api'
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
|
||||
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
||||
|
||||
// 导入模块化的类型定义
|
||||
import type {
|
||||
BotConfig,
|
||||
PersonalityConfig,
|
||||
ChatConfig,
|
||||
ExpressionConfig,
|
||||
ChineseTypoConfig,
|
||||
DebugConfig,
|
||||
DreamConfig,
|
||||
EmojiConfig,
|
||||
ExperimentalConfig,
|
||||
ExpressionConfig,
|
||||
KeywordReactionConfig,
|
||||
LogConfig,
|
||||
LPMMKnowledgeConfig,
|
||||
MaimMessageConfig,
|
||||
MemoryConfig,
|
||||
MessageReceiveConfig,
|
||||
PersonalityConfig,
|
||||
ResponsePostProcessConfig,
|
||||
ResponseSplitterConfig,
|
||||
TelemetryConfig,
|
||||
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'
|
||||
|
||||
// 导入动态表单和 Hook 系统
|
||||
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||
import { fieldHooks } from '@/lib/field-hooks'
|
||||
import { ChatSectionHook } from '@/routes/config/bot/hooks'
|
||||
|
||||
import { ChatSectionHook } from './bot/hooks'
|
||||
import {
|
||||
BotInfoSection,
|
||||
DebugSection,
|
||||
DreamSection,
|
||||
ExperimentalSection,
|
||||
ExpressionSection,
|
||||
FeaturesSection,
|
||||
LogSection,
|
||||
LPMMSection,
|
||||
MaimMessageSection,
|
||||
MessageReceiveSection,
|
||||
PersonalitySection,
|
||||
ProcessingSection,
|
||||
TelemetrySection,
|
||||
WebUISection,
|
||||
} from './bot/sections'
|
||||
// ==================== 常量定义 ====================
|
||||
/** Toast 显示前的延迟时间 (毫秒) */
|
||||
const TOAST_DISPLAY_DELAY = 500
|
||||
@@ -262,7 +256,16 @@ function BotConfigPageContent() {
|
||||
// 加载源代码
|
||||
const loadSourceCode = useCallback(async () => {
|
||||
try {
|
||||
const raw = await getBotConfigRaw()
|
||||
const result = await getBotConfigRaw()
|
||||
if (!result.success) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
const raw = result.data
|
||||
// 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示
|
||||
// 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串
|
||||
const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => {
|
||||
@@ -289,8 +292,17 @@ function BotConfigPageContent() {
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const config = await getBotConfig()
|
||||
parseAndSetConfig(config)
|
||||
const result = await getBotConfig()
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
parseAndSetConfig(result.data)
|
||||
setHasUnsavedChanges(false)
|
||||
initialLoadRef.current = false
|
||||
|
||||
@@ -382,7 +394,18 @@ function BotConfigPageContent() {
|
||||
.replace(/\r/g, '\\r') // 回车符
|
||||
return `"${encoded}"`
|
||||
})
|
||||
await updateBotConfigRaw(escaped)
|
||||
const result = await updateBotConfigRaw(escaped)
|
||||
if (!result.success) {
|
||||
setHasTomlError(true)
|
||||
const errorMsg = result.error
|
||||
setTomlErrorMessage(errorMsg)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '保存失败',
|
||||
description: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
setHasTomlError(false)
|
||||
setTomlErrorMessage('')
|
||||
@@ -423,8 +446,16 @@ function BotConfigPageContent() {
|
||||
} else {
|
||||
// 切换回可视化时,直接重新加载配置但不显示全局 loading
|
||||
try {
|
||||
const config = await getBotConfig()
|
||||
parseAndSetConfig(config)
|
||||
const result = await getBotConfig()
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
parseAndSetConfig(result.data)
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
@@ -444,7 +475,16 @@ function BotConfigPageContent() {
|
||||
// 取消待处理的自动保存
|
||||
cancelPendingAutoSave()
|
||||
|
||||
await updateBotConfig(buildFullConfig())
|
||||
const result = await updateBotConfig(buildFullConfig())
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -474,7 +514,16 @@ function BotConfigPageContent() {
|
||||
// 取消待处理的自动保存
|
||||
cancelPendingAutoSave()
|
||||
|
||||
await updateBotConfig(buildFullConfig())
|
||||
const result = await updateBotConfig(buildFullConfig())
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -759,4 +808,4 @@ function BotConfigPageContent() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||
|
||||
import type { BotConfig } from '../types'
|
||||
import { BotInfoSection } from '../sections/BotInfoSection'
|
||||
|
||||
/**
|
||||
@@ -8,7 +10,7 @@ import { BotInfoSection } from '../sections/BotInfoSection'
|
||||
export const BotInfoSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||
return (
|
||||
<BotInfoSection
|
||||
config={value as any}
|
||||
config={value as BotConfig}
|
||||
onChange={(newConfig) => onChange?.(newConfig)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||
|
||||
import type { DebugConfig } from '../types'
|
||||
import { DebugSection } from '../sections/DebugSection'
|
||||
|
||||
/**
|
||||
@@ -8,7 +10,7 @@ import { DebugSection } from '../sections/DebugSection'
|
||||
export const DebugSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||
return (
|
||||
<DebugSection
|
||||
config={value as any}
|
||||
config={value as DebugConfig}
|
||||
onChange={(newConfig) => onChange?.(newConfig)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||
|
||||
import type { ExpressionConfig } from '../types'
|
||||
import { ExpressionSection } from '../sections/ExpressionSection'
|
||||
|
||||
/**
|
||||
@@ -8,7 +10,7 @@ import { ExpressionSection } from '../sections/ExpressionSection'
|
||||
export const ExpressionSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||
return (
|
||||
<ExpressionSection
|
||||
config={value as any}
|
||||
config={value as ExpressionConfig}
|
||||
onChange={(newConfig) => onChange?.(newConfig)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||
|
||||
import type { PersonalityConfig } from '../types'
|
||||
import { PersonalitySection } from '../sections/PersonalitySection'
|
||||
|
||||
/**
|
||||
@@ -8,7 +10,7 @@ import { PersonalitySection } from '../sections/PersonalitySection'
|
||||
export const PersonalitySectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||
return (
|
||||
<PersonalitySection
|
||||
config={value as any}
|
||||
config={value as PersonalityConfig}
|
||||
onChange={(newConfig) => onChange?.(newConfig)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
* Bot 配置页面相关 hooks
|
||||
*/
|
||||
|
||||
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
||||
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
||||
export { useAutoSave, useAutoSaveGeneric, useConfigAutoSave } from './useAutoSave'
|
||||
export type {
|
||||
UseAutoSaveOptions,
|
||||
UseAutoSaveReturn,
|
||||
AutoSaveState,
|
||||
UseAutoSaveConfig,
|
||||
UseAutoSaveReturnGeneric,
|
||||
} from './useAutoSave'
|
||||
export { ChatSectionHook } from './ChatSectionHook'
|
||||
export { PersonalitySectionHook } from './PersonalitySectionHook'
|
||||
export { DebugSectionHook } from './DebugSectionHook'
|
||||
|
||||
@@ -1,50 +1,178 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { updateBotConfigSection } from '@/lib/config-api'
|
||||
import type { ConfigSectionName } from '../types'
|
||||
|
||||
export interface UseAutoSaveOptions {
|
||||
/** 防抖延迟时间(毫秒),默认 2000ms */
|
||||
/**
|
||||
* Self-contained auto-save hook configuration
|
||||
* @template T The type of data being saved
|
||||
*/
|
||||
export interface UseAutoSaveConfig<T> {
|
||||
/** Function to save data, should return a promise */
|
||||
saveFn: (data: T) => Promise<void>
|
||||
/** Debounce delay in milliseconds, default 2000ms */
|
||||
debounceMs?: number
|
||||
/** 保存成功回调 */
|
||||
/** Callback when save succeeds */
|
||||
onSaveSuccess?: () => void
|
||||
/** 保存失败回调 */
|
||||
/** Callback when save fails */
|
||||
onSaveError?: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained auto-save hook return type (generic)
|
||||
*/
|
||||
export interface UseAutoSaveReturnGeneric<T> {
|
||||
/** Trigger auto-save (debounced) */
|
||||
save: (data: T) => void
|
||||
/** Save immediately without debounce */
|
||||
saveNow: (data: T) => Promise<void>
|
||||
/** Cancel pending auto-save */
|
||||
cancel: () => void
|
||||
/** Whether currently saving */
|
||||
isSaving: boolean
|
||||
/** Error from last save attempt, or null */
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained generic auto-save hook
|
||||
*
|
||||
* Manages debouncing, pending state, and error handling internally.
|
||||
* No external state dependencies required.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { save, isSaving } = useAutoSaveGeneric<MyConfig>({
|
||||
* saveFn: async (config) => {
|
||||
* await updateMyConfig(config)
|
||||
* },
|
||||
* debounceMs: 2000,
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (config) {
|
||||
* save(config)
|
||||
* }
|
||||
* }, [config, save])
|
||||
* ```
|
||||
*/
|
||||
export function useAutoSaveGeneric<T>(
|
||||
config: UseAutoSaveConfig<T>
|
||||
): UseAutoSaveReturnGeneric<T> {
|
||||
const { saveFn, debounceMs = 2000, onSaveSuccess, onSaveError } = config
|
||||
|
||||
// Internal state management
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Perform the actual save
|
||||
const performSave = useCallback(
|
||||
async (data: T) => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
await saveFn(data)
|
||||
onSaveSuccess?.()
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setError(error)
|
||||
console.error('Auto-save failed:', error)
|
||||
onSaveError?.(error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[saveFn, onSaveSuccess, onSaveError]
|
||||
)
|
||||
|
||||
// Debounced save
|
||||
const save = useCallback(
|
||||
(data: T) => {
|
||||
// Clear existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// Set new timer
|
||||
timerRef.current = setTimeout(() => {
|
||||
performSave(data)
|
||||
}, debounceMs)
|
||||
},
|
||||
[performSave, debounceMs]
|
||||
)
|
||||
|
||||
// Save immediately
|
||||
const saveNow = useCallback(
|
||||
async (data: T) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
await performSave(data)
|
||||
},
|
||||
[performSave]
|
||||
)
|
||||
|
||||
// Cancel pending save
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
save,
|
||||
saveNow,
|
||||
cancel,
|
||||
isSaving,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy wrapper for backward compatibility with old API
|
||||
* Maintains external state for existing code
|
||||
*/
|
||||
export interface UseAutoSaveOptions {
|
||||
/** Debounce delay in milliseconds, default 2000ms */
|
||||
debounceMs?: number
|
||||
/** Save success callback */
|
||||
onSaveSuccess?: () => void
|
||||
/** Save error callback */
|
||||
onSaveError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface UseAutoSaveReturn {
|
||||
/** 触发自动保存 */
|
||||
/** Trigger auto-save */
|
||||
triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void
|
||||
/** 立即保存(不防抖) */
|
||||
/** Save immediately */
|
||||
saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise<void>
|
||||
/** 取消待处理的自动保存 */
|
||||
/** Cancel pending auto-save */
|
||||
cancelPendingAutoSave: () => void
|
||||
}
|
||||
|
||||
export interface AutoSaveState {
|
||||
/** 是否正在保存中 */
|
||||
/** Whether currently saving */
|
||||
isAutoSaving: boolean
|
||||
/** 是否有未保存的更改 */
|
||||
/** Whether has unsaved changes */
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动保存 hook
|
||||
*
|
||||
* 用于监听配置变化并自动防抖保存到后端
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { triggerAutoSave } = useAutoSave({
|
||||
* isInitialLoad,
|
||||
* setAutoSaving,
|
||||
* setHasUnsavedChanges,
|
||||
* })
|
||||
*
|
||||
* // 配置变化时触发
|
||||
* useEffect(() => {
|
||||
* if (config) triggerAutoSave('bot', config)
|
||||
* }, [config])
|
||||
* ```
|
||||
* Legacy auto-save hook for bot config
|
||||
* Maintains backward compatibility with external state management
|
||||
*
|
||||
* @deprecated Use the generic useAutoSaveGeneric<T> instead
|
||||
*/
|
||||
export function useAutoSave(
|
||||
isInitialLoad: boolean,
|
||||
@@ -55,12 +183,15 @@ export function useAutoSave(
|
||||
const { debounceMs = 2000, onSaveSuccess, onSaveError } = options
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 执行保存操作
|
||||
// Execute save operation
|
||||
const saveSection = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
try {
|
||||
setAutoSaving(true)
|
||||
await updateBotConfigSection(sectionName, sectionData)
|
||||
const result = await updateBotConfigSection(sectionName, sectionData)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
onSaveSuccess?.()
|
||||
} catch (error) {
|
||||
@@ -74,7 +205,7 @@ export function useAutoSave(
|
||||
[setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError]
|
||||
)
|
||||
|
||||
// 触发自动保存(带防抖)
|
||||
// Trigger auto-save (with debounce)
|
||||
const triggerAutoSave = useCallback(
|
||||
(sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (isInitialLoad) return
|
||||
@@ -92,7 +223,7 @@ export function useAutoSave(
|
||||
[isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs]
|
||||
)
|
||||
|
||||
// 立即保存(不防抖)
|
||||
// Save immediately (no debounce)
|
||||
const saveNow = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -104,7 +235,7 @@ export function useAutoSave(
|
||||
[saveSection]
|
||||
)
|
||||
|
||||
// 取消待处理的自动保存
|
||||
// Cancel pending auto-save
|
||||
const cancelPendingAutoSave = useCallback(() => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
@@ -112,7 +243,7 @@ export function useAutoSave(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -130,22 +261,22 @@ export function useAutoSave(
|
||||
|
||||
/**
|
||||
* 创建配置自动保存 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)
|
||||
* })
|
||||
|
||||
@@ -1,231 +1,16 @@
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
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'
|
||||
|
||||
import type { ChatConfig } from '../types'
|
||||
import { RuleList } from './RuleList'
|
||||
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) {
|
||||
export function ChatSection({ config, onChange }: ChatSectionProps) {
|
||||
// 添加发言频率规则
|
||||
const addTalkValueRule = () => {
|
||||
onChange({
|
||||
@@ -394,217 +179,13 @@ export const ChatSection = React.memo(function ChatSection({ config, onChange }:
|
||||
|
||||
{/* 动态发言频率规则配置 */}
|
||||
{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 表示晚上11点到次日凌晨2点</li>
|
||||
<li>• <strong>数值范围</strong>:建议 0-1,0 表示完全沉默,1 表示正常发言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<RuleList
|
||||
rules={config.talk_value_rules}
|
||||
onAdd={addTalkValueRule}
|
||||
onUpdate={updateTalkValueRule}
|
||||
onRemove={removeTalkValueRule}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
213
dashboard/src/routes/config/bot/sections/RuleEditor.tsx
Normal file
213
dashboard/src/routes/config/bot/sections/RuleEditor.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { RulePreview } from './RulePreview'
|
||||
import { TimeRangePicker } from './TimeRangePicker'
|
||||
|
||||
interface TalkValueRule {
|
||||
target: string
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface RuleEditorProps {
|
||||
rule: TalkValueRule
|
||||
index: number
|
||||
onUpdate: (index: number, field: 'target' | 'time' | 'value', value: string | number) => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
// 规则编辑器组件
|
||||
export function RuleEditor({ rule, index, onUpdate, onRemove }: RuleEditorProps) {
|
||||
return (
|
||||
<div 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={() => onRemove(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') {
|
||||
onUpdate(index, 'target', '')
|
||||
} else {
|
||||
onUpdate(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) => {
|
||||
onUpdate(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) => {
|
||||
onUpdate(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) => {
|
||||
onUpdate(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) => onUpdate(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)) {
|
||||
onUpdate(index, 'value', Math.max(0.01, Math.min(1, val)))
|
||||
}
|
||||
}}
|
||||
className="w-20 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[rule.value]}
|
||||
onValueChange={(values) =>
|
||||
onUpdate(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>
|
||||
)
|
||||
}
|
||||
70
dashboard/src/routes/config/bot/sections/RuleList.tsx
Normal file
70
dashboard/src/routes/config/bot/sections/RuleList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { RuleEditor } from './RuleEditor'
|
||||
|
||||
interface TalkValueRule {
|
||||
target: string
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface RuleListProps {
|
||||
rules: TalkValueRule[]
|
||||
onAdd: () => void
|
||||
onUpdate: (index: number, field: 'target' | 'time' | 'value', value: string | number) => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
// 规则列表组件
|
||||
export function RuleList({ rules, onAdd, onUpdate, onRemove }: RuleListProps) {
|
||||
return (
|
||||
<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={onAdd} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rules && rules.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{rules.map((rule, index) => (
|
||||
<RuleEditor
|
||||
key={index}
|
||||
rule={rule}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</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 表示晚上11点到次日凌晨2点</li>
|
||||
<li>• <strong>数值范围</strong>:建议 0-1,0 表示完全沉默,1 表示正常发言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
dashboard/src/routes/config/bot/sections/RulePreview.tsx
Normal file
40
dashboard/src/routes/config/bot/sections/RulePreview.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
import { Eye } from 'lucide-react'
|
||||
|
||||
interface RulePreviewProps {
|
||||
rule: {
|
||||
target: string
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
}
|
||||
|
||||
// 预览窗口组件
|
||||
export function RulePreview({ rule }: RulePreviewProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
170
dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx
Normal file
170
dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
interface TimeRangePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
// 时间选择组件
|
||||
export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) {
|
||||
// 解析初始值
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -179,7 +179,17 @@ function ModelConfigPageContent() {
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const config = await getModelConfig()
|
||||
const result = await getModelConfig()
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const config = result.data
|
||||
const modelList = (config.models as ModelInfo[]) || []
|
||||
setModels(modelList)
|
||||
|
||||
@@ -288,11 +298,30 @@ function ModelConfigPageContent() {
|
||||
try {
|
||||
setSaving(true)
|
||||
clearAutoSaveTimers()
|
||||
const config = await getModelConfig()
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultGet.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const config = resultGet.data
|
||||
// 清理每个模型中的 null 值
|
||||
config.models = models.map(cleanModelForSave)
|
||||
config.model_task_config = taskConfig
|
||||
await updateModelConfig(config)
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultUpdate.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
@@ -318,11 +347,30 @@ function ModelConfigPageContent() {
|
||||
// 先取消自动保存定时器
|
||||
clearAutoSaveTimers()
|
||||
|
||||
const config = await getModelConfig()
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultGet.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const config = resultGet.data
|
||||
// 清理每个模型中的 null 值
|
||||
config.models = models.map(cleanModelForSave)
|
||||
config.model_task_config = taskConfig
|
||||
await updateModelConfig(config)
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultUpdate.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 监听 models 和 taskConfig 变化,自动保存到服务器
|
||||
*/
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { updateModelConfigSection } from '@/lib/config-api'
|
||||
import type { ModelInfo, ModelTaskConfig } from '../types'
|
||||
|
||||
@@ -23,7 +24,7 @@ interface UseModelAutoSaveReturn {
|
||||
/** 清除所有待执行的保存定时器 */
|
||||
clearTimers: () => void
|
||||
/** 初始加载状态标记引用 (用于设置初始加载完成) */
|
||||
initialLoadRef: React.MutableRefObject<boolean>
|
||||
initialLoadRef: RefObject<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,10 @@ export function useModelAutoSave(
|
||||
onSavingChange?.(true)
|
||||
// 清理每个模型中的 null 值
|
||||
const cleanedModels = newModels.map(cleanModelForSave)
|
||||
await updateModelConfigSection('models', cleanedModels)
|
||||
const result = await updateModelConfigSection('models', cleanedModels)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存模型列表失败:', error)
|
||||
@@ -98,7 +102,10 @@ export function useModelAutoSave(
|
||||
const autoSaveTaskConfig = useCallback(async (newTaskConfig: ModelTaskConfig) => {
|
||||
try {
|
||||
onSavingChange?.(true)
|
||||
await updateModelConfigSection('model_task_config', newTaskConfig)
|
||||
const result = await updateModelConfigSection('model_task_config', newTaskConfig)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存任务配置失败:', error)
|
||||
|
||||
@@ -88,11 +88,15 @@ export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetche
|
||||
setModelFetchError(null)
|
||||
|
||||
try {
|
||||
const models = await fetchProviderModels(
|
||||
const result = await fetchProviderModels(
|
||||
providerName,
|
||||
template.modelFetcher.parser,
|
||||
template.modelFetcher.endpoint
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
const models = result.data
|
||||
setAvailableModels(models)
|
||||
// 更新缓存
|
||||
modelListCache.set(cacheKey, { models, timestamp: Date.now() })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
136
dashboard/src/routes/config/modelProvider/ProviderCard.tsx
Normal file
136
dashboard/src/routes/config/modelProvider/ProviderCard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { TestConnectionResult } from '@/lib/config-api'
|
||||
import { AlertCircle, CheckCircle2, Loader2, Pencil, Trash2, XCircle, Zap } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import type { APIProvider } from './types'
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: APIProvider
|
||||
actualIndex: number
|
||||
testingProviders: Set<string>
|
||||
testResults: Map<string, TestConnectionResult>
|
||||
onEdit: (provider: APIProvider, index: number) => void
|
||||
onDelete: (index: number) => void
|
||||
onTest: (name: string) => void
|
||||
}
|
||||
|
||||
export function ProviderCard({
|
||||
provider,
|
||||
actualIndex,
|
||||
testingProviders,
|
||||
testResults,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
}: ProviderCardProps) {
|
||||
const renderTestStatus = () => {
|
||||
const isTesting = testingProviders.has(provider.name)
|
||||
const result = testResults.get(provider.name)
|
||||
|
||||
if (isTesting) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
测试中
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (!result) return null
|
||||
|
||||
if (result.network_ok) {
|
||||
if (result.api_key_valid === true) {
|
||||
return (
|
||||
<Badge className="gap-1 bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
正常
|
||||
</Badge>
|
||||
)
|
||||
} else if (result.api_key_valid === false) {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Key无效
|
||||
</Badge>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Badge className="gap-1 bg-blue-600 hover:bg-blue-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
可访问
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
离线
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-base truncate">{provider.name}</h3>
|
||||
{renderTestStatus()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 break-all">{provider.base_url}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onTest(provider.name)}
|
||||
disabled={testingProviders.has(provider.name)}
|
||||
title="测试连接"
|
||||
>
|
||||
{testingProviders.has(provider.name) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(provider, actualIndex)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(actualIndex)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">客户端类型</span>
|
||||
<p className="font-medium">{provider.client_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">最大重试</span>
|
||||
<p className="font-medium">{provider.max_retry}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">超时(秒)</span>
|
||||
<p className="font-medium">{provider.timeout}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">重试间隔(秒)</span>
|
||||
<p className="font-medium">{provider.retry_interval}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
458
dashboard/src/routes/config/modelProvider/ProviderForm.tsx
Normal file
458
dashboard/src/routes/config/modelProvider/ProviderForm.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { HelpTooltip } from '@/components/ui/help-tooltip'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
import { PROVIDER_TEMPLATES } from '../providerTemplates'
|
||||
import type { APIProvider, FormErrors } from './types'
|
||||
import { validateProvider } from './utils'
|
||||
|
||||
interface ProviderFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingProvider: APIProvider | null
|
||||
editingIndex: number | null
|
||||
providers: APIProvider[]
|
||||
onSave: (provider: APIProvider, index: number | null) => void
|
||||
tourState: { isRunning: boolean }
|
||||
}
|
||||
|
||||
export function ProviderForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingProvider,
|
||||
editingIndex,
|
||||
providers,
|
||||
onSave,
|
||||
tourState,
|
||||
}: ProviderFormProps) {
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('custom')
|
||||
const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [localProvider, setLocalProvider] = useState<APIProvider | null>(editingProvider)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 同步外部状态到本地
|
||||
if (editingProvider !== localProvider && open) {
|
||||
setLocalProvider(editingProvider)
|
||||
setFormErrors({})
|
||||
setShowApiKey(false)
|
||||
|
||||
// 检测匹配的模板
|
||||
if (editingProvider) {
|
||||
const matchedTemplate = PROVIDER_TEMPLATES.find(
|
||||
t => t.base_url === editingProvider.base_url && t.client_type === editingProvider.client_type
|
||||
)
|
||||
setSelectedTemplate(matchedTemplate?.id || 'custom')
|
||||
} else {
|
||||
setSelectedTemplate('custom')
|
||||
}
|
||||
}
|
||||
|
||||
const isUsingTemplate = useMemo(() => selectedTemplate !== 'custom', [selectedTemplate])
|
||||
|
||||
const handleTemplateChange = useCallback((templateId: string) => {
|
||||
setSelectedTemplate(templateId)
|
||||
setTemplateComboboxOpen(false)
|
||||
const template = PROVIDER_TEMPLATES.find(t => t.id === templateId)
|
||||
if (template && template.id !== 'custom') {
|
||||
setLocalProvider(prev => ({
|
||||
...prev!,
|
||||
name: template.name,
|
||||
base_url: template.base_url,
|
||||
client_type: template.client_type,
|
||||
}))
|
||||
} else if (template?.id === 'custom') {
|
||||
setLocalProvider(prev => ({
|
||||
...prev!,
|
||||
name: '',
|
||||
base_url: '',
|
||||
client_type: 'openai',
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyApiKey = useCallback(async () => {
|
||||
if (!localProvider?.api_key) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(localProvider.api_key)
|
||||
toast({
|
||||
title: '复制成功',
|
||||
description: 'API Key 已复制到剪贴板',
|
||||
})
|
||||
} catch {
|
||||
toast({
|
||||
title: '复制失败',
|
||||
description: '无法访问剪贴板',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}, [localProvider?.api_key, toast])
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!localProvider) return
|
||||
|
||||
const { isValid, errors } = validateProvider(localProvider, providers, editingIndex)
|
||||
|
||||
if (!isValid) {
|
||||
setFormErrors(errors)
|
||||
return
|
||||
}
|
||||
|
||||
setFormErrors({})
|
||||
onSave(localProvider, editingIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
data-tour="provider-dialog"
|
||||
preventOutsideClose={tourState.isRunning}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingIndex !== null ? '编辑提供商' : '添加提供商'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置 API 提供商的连接信息和参数
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEdit(); }} autoComplete="off">
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2" data-tour="provider-template-select">
|
||||
<Label htmlFor="template">提供商模板</Label>
|
||||
<Popover open={templateComboboxOpen} onOpenChange={setTemplateComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={templateComboboxOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedTemplate
|
||||
? PROVIDER_TEMPLATES.find((template) => template.id === selectedTemplate)?.display_name
|
||||
: "选择提供商模板..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<Command>
|
||||
<CommandInput placeholder="搜索提供商模板..." />
|
||||
<ScrollArea className="h-[300px]">
|
||||
<CommandList className="max-h-none overflow-visible">
|
||||
<CommandEmpty>未找到匹配的模板</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{PROVIDER_TEMPLATES.map((template) => (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.display_name}
|
||||
onSelect={() => handleTemplateChange(template.id)}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedTemplate === template.id ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{template.display_name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择预设模板可自动填充 URL 和客户端类型,支持搜索
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="provider-name-input">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="name" className={formErrors.name ? 'text-destructive' : ''}>名称 *</Label>
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">提供商名称</p>
|
||||
<p>为这个 API 提供商设置一个便于识别的名称,用于在模型配置中引用。</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>推荐使用厂商官方名称,如 DeepSeek、OpenAI</li>
|
||||
<li>名称需要唯一,不能与现有提供商重复</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
maxWidth="350px"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
value={localProvider?.name || ''}
|
||||
onChange={(e) => {
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
if (formErrors.name) {
|
||||
setFormErrors((prev) => ({ ...prev, name: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如: DeepSeek, SiliconFlow"
|
||||
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-destructive">{formErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="provider-url-input">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="base_url" className={formErrors.base_url ? 'text-destructive' : ''}>基础 URL *</Label>
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">API 基础地址</p>
|
||||
<p>提供商的 API 端点基础 URL,通常以 /v1 结尾。</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li><strong>OpenAI 格式:</strong>https://api.openai.com/v1</li>
|
||||
<li><strong>DeepSeek:</strong>https://api.deepseek.com</li>
|
||||
<li><strong>硅基流动:</strong>https://api.siliconflow.cn/v1</li>
|
||||
<li>选择模板会自动填充正确的 URL</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
maxWidth="400px"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="base_url"
|
||||
value={localProvider?.base_url || ''}
|
||||
onChange={(e) => {
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, base_url: e.target.value } : null
|
||||
)
|
||||
if (formErrors.base_url) {
|
||||
setFormErrors((prev) => ({ ...prev, base_url: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={isUsingTemplate}
|
||||
className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
{formErrors.base_url && (
|
||||
<p className="text-xs text-destructive">{formErrors.base_url}</p>
|
||||
)}
|
||||
{isUsingTemplate && !formErrors.base_url && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
使用模板时 URL 不可编辑,切换到"自定义"以手动配置
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-tour="provider-apikey-input">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="api_key" className={formErrors.api_key ? 'text-destructive' : ''}>API Key *</Label>
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">API 密钥</p>
|
||||
<p>从提供商平台获取的身份验证密钥。</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>通常以 <code>sk-</code> 开头</li>
|
||||
<li>请妥善保管,不要泄露给他人</li>
|
||||
<li>可以点击眼睛图标切换显示/隐藏</li>
|
||||
<li>点击复制图标可快速复制密钥</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
maxWidth="350px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="api_key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={localProvider?.api_key || ''}
|
||||
onChange={(e) => {
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, api_key: e.target.value } : null
|
||||
)
|
||||
if (formErrors.api_key) {
|
||||
setFormErrors((prev) => ({ ...prev, api_key: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
title={showApiKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyApiKey}
|
||||
title="复制密钥"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{formErrors.api_key && (
|
||||
<p className="text-xs text-destructive">{formErrors.api_key}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="client_type">客户端类型</Label>
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">API 客户端类型</p>
|
||||
<p>指定与提供商通信时使用的 API 协议格式。</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li><strong>OpenAI:</strong>兼容 OpenAI API 格式的提供商</li>
|
||||
<li><strong>Gemini:</strong>Google Gemini 专用格式</li>
|
||||
<li>大部分第三方提供商都兼容 OpenAI 格式</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
maxWidth="350px"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={localProvider?.client_type || 'openai'}
|
||||
onValueChange={(value) =>
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, client_type: value } : null
|
||||
)
|
||||
}
|
||||
disabled={isUsingTemplate}
|
||||
>
|
||||
<SelectTrigger id="client_type" className={isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''}>
|
||||
<SelectValue placeholder="选择客户端类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isUsingTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
使用模板时客户端类型不可编辑,切换到"自定义"以手动配置
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="max_retry">最大重试</Label>
|
||||
<HelpTooltip
|
||||
content="API 请求失败时的最大重试次数。设置为 0 表示不重试。默认值:2"
|
||||
side="top"
|
||||
maxWidth="250px"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="max_retry"
|
||||
type="number"
|
||||
min="0"
|
||||
value={localProvider?.max_retry ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value)
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, max_retry: val } : null
|
||||
)
|
||||
}}
|
||||
placeholder="默认: 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="timeout">超时(秒)</Label>
|
||||
<HelpTooltip
|
||||
content="单次 API 请求的超时时间(秒)。超时后会触发重试或报错。默认值:30 秒"
|
||||
side="top"
|
||||
maxWidth="250px"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
value={localProvider?.timeout ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value)
|
||||
setLocalProvider((prev) =>
|
||||
prev ? { ...prev, timeout: val } : null
|
||||
)
|
||||
}}
|
||||
placeholder="默认: 30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="retry_interval">重试间隔(秒)</Label>
|
||||
<HelpTooltip
|
||||
content="两次重试之间的等待时间(秒)。适当的间隔可以避免触发 API 限流。默认值:10 秒"
|
||||
side="top"
|
||||
maxWidth="250px"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="retry_interval"
|
||||
type="number"
|
||||
min="1"
|
||||
value={localProvider?.retry_interval ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value)
|
||||
setLocalProvider((prev) =>
|
||||
prev
|
||||
? { ...prev, retry_interval: val }
|
||||
: null
|
||||
)
|
||||
}}
|
||||
placeholder="默认: 10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} data-tour="provider-cancel-button">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" data-tour="provider-save-button">保存</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
353
dashboard/src/routes/config/modelProvider/ProviderList.tsx
Normal file
353
dashboard/src/routes/config/modelProvider/ProviderList.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { TestConnectionResult } from '@/lib/config-api'
|
||||
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Pencil, Search, Trash2, XCircle, Zap } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
|
||||
import { ProviderCard } from './ProviderCard'
|
||||
import type { APIProvider } from './types'
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: APIProvider[]
|
||||
testingProviders: Set<string>
|
||||
testResults: Map<string, TestConnectionResult>
|
||||
selectedProviders: Set<number>
|
||||
onEdit: (provider: APIProvider, index: number) => void
|
||||
onDelete: (index: number) => void
|
||||
onTest: (name: string) => void
|
||||
onToggleSelect: (index: number) => void
|
||||
onToggleSelectAll: () => void
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
providers,
|
||||
testingProviders,
|
||||
testResults,
|
||||
selectedProviders,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
}: ProviderListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
if (!searchQuery) return providers
|
||||
const query = searchQuery.toLowerCase()
|
||||
return providers.filter((provider) => (
|
||||
provider.name.toLowerCase().includes(query) ||
|
||||
provider.base_url.toLowerCase().includes(query) ||
|
||||
provider.client_type.toLowerCase().includes(query)
|
||||
))
|
||||
}, [providers, searchQuery])
|
||||
|
||||
const { totalPages, paginatedProviders } = useMemo(() => {
|
||||
const total = Math.ceil(filteredProviders.length / pageSize)
|
||||
const paginated = filteredProviders.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
)
|
||||
return { totalPages: total, paginatedProviders: paginated }
|
||||
}, [filteredProviders, page, pageSize])
|
||||
|
||||
const handleJumpToPage = useCallback(() => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
if (targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
setJumpToPage('')
|
||||
}
|
||||
}, [jumpToPage, totalPages])
|
||||
|
||||
const renderTestStatus = (providerName: string) => {
|
||||
const isTesting = testingProviders.has(providerName)
|
||||
const result = testResults.get(providerName)
|
||||
|
||||
if (isTesting) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
测试中
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
未测试
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (result.network_ok) {
|
||||
if (result.api_key_valid === true) {
|
||||
return (
|
||||
<Badge className="gap-1 bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
正常
|
||||
</Badge>
|
||||
)
|
||||
} else if (result.api_key_valid === false) {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Key无效
|
||||
</Badge>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Badge className="gap-1 bg-blue-600 hover:bg-blue-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
可访问
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
离线
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 mb-4">
|
||||
<div className="relative w-full sm:flex-1 sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索提供商名称、URL 或类型..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
找到 {filteredProviders.length} 个结果
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片视图 */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8 rounded-lg border bg-card">
|
||||
{searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'}
|
||||
</div>
|
||||
) : (
|
||||
paginatedProviders.map((provider, displayIndex) => {
|
||||
const actualIndex = providers.findIndex(p => p === provider)
|
||||
return (
|
||||
<ProviderCard
|
||||
key={displayIndex}
|
||||
provider={provider}
|
||||
actualIndex={actualIndex}
|
||||
testingProviders={testingProviders}
|
||||
testResults={testResults}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onTest={onTest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedProviders.size === filteredProviders.length && filteredProviders.length > 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>基础URL</TableHead>
|
||||
<TableHead>客户端类型</TableHead>
|
||||
<TableHead className="text-right">最大重试</TableHead>
|
||||
<TableHead className="text-right">超时(秒)</TableHead>
|
||||
<TableHead className="text-right">重试间隔(秒)</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProviders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedProviders.map((provider, displayIndex) => {
|
||||
const actualIndex = providers.findIndex(p => p === provider)
|
||||
return (
|
||||
<TableRow key={displayIndex}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedProviders.has(actualIndex)}
|
||||
onCheckedChange={() => onToggleSelect(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderTestStatus(provider.name)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{provider.name}</TableCell>
|
||||
<TableCell className="max-w-xs truncate" title={provider.base_url}>
|
||||
{provider.base_url}
|
||||
</TableCell>
|
||||
<TableCell>{provider.client_type}</TableCell>
|
||||
<TableCell className="text-right">{provider.max_retry}</TableCell>
|
||||
<TableCell className="text-right">{provider.timeout}</TableCell>
|
||||
<TableCell className="text-right">{provider.retry_interval}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onTest(provider.name)}
|
||||
disabled={testingProviders.has(provider.name)}
|
||||
title="测试连接"
|
||||
>
|
||||
{testingProviders.has(provider.name) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(provider, actualIndex)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(actualIndex)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{filteredProviders.length > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="page-size-provider" className="text-sm whitespace-nowrap">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value))
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="page-size-provider" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
显示 {(page - 1) * pageSize + 1} 到{' '}
|
||||
{Math.min(page * pageSize, filteredProviders.length)} 条,共 {filteredProviders.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={jumpToPage}
|
||||
onChange={(e) => setJumpToPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
|
||||
placeholder={page.toString()}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleJumpToPage}
|
||||
disabled={!jumpToPage}
|
||||
className="h-8"
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,2 @@
|
||||
/**
|
||||
* 模型提供商配置模块
|
||||
*
|
||||
* 模块结构:
|
||||
* - types.ts: 类型定义
|
||||
* - utils.ts: 工具函数
|
||||
* - 主组件在上级目录的 modelProvider.tsx
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
export { ModelProviderConfigPage } from './index.tsx'
|
||||
export type * from './types'
|
||||
|
||||
909
dashboard/src/routes/config/modelProvider/index.tsx
Normal file
909
dashboard/src/routes/config/modelProvider/index.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { getModelConfig, testProviderConnection, updateModelConfig, updateModelConfigSection } from '@/lib/config-api'
|
||||
import type { TestConnectionResult } from '@/lib/config-api'
|
||||
import { Info, Plus, Power, Save, Trash2, Zap } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
|
||||
import { useTour } from '@/components/tour'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
|
||||
import { ProviderForm } from './ProviderForm'
|
||||
import { ProviderList } from './ProviderList'
|
||||
import type { APIProvider, DeleteConfirmState } from './types'
|
||||
import { cleanProviderData } from './utils'
|
||||
|
||||
/**
|
||||
* ModelConfig 接口定义
|
||||
*/
|
||||
interface ModelConfig extends Record<string, unknown> {
|
||||
api_providers?: unknown[]
|
||||
models?: unknown[]
|
||||
model_task_config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function ModelProviderConfigPage() {
|
||||
return (
|
||||
<RestartProvider>
|
||||
<ModelProviderConfigPageContent />
|
||||
</RestartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelProviderConfigPageContent() {
|
||||
const [providers, setProviders] = useState<APIProvider[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [autoSaving, setAutoSaving] = useState(false)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [editingProvider, setEditingProvider] = useState<APIProvider | null>(null)
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null)
|
||||
const [selectedProviders, setSelectedProviders] = useState<Set<number>>(new Set())
|
||||
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
|
||||
const [deleteConfirmState, setDeleteConfirmState] = useState<DeleteConfirmState>({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
|
||||
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
|
||||
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { state: tourState, goToStep, registerTour } = useTour()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadRef = useRef(true)
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
// 注册 Tour
|
||||
useEffect(() => {
|
||||
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
|
||||
}, [registerTour])
|
||||
|
||||
// 监听 Tour 步骤变化,处理页面导航
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
|
||||
if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) {
|
||||
navigate({ to: targetRoute })
|
||||
}
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate])
|
||||
|
||||
// 监听 Tour 步骤变化,处理弹窗的打开和关闭
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const prevStep = prevTourStepRef.current
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) {
|
||||
setEditDialogOpen(false)
|
||||
}
|
||||
|
||||
if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) {
|
||||
setEditingProvider({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
client_type: 'openai',
|
||||
max_retry: 2,
|
||||
timeout: 30,
|
||||
retry_interval: 10,
|
||||
})
|
||||
setEditingIndex(null)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
prevTourStepRef.current = currentStep
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning])
|
||||
|
||||
// 处理 Tour 中需要用户点击才能继续的步骤
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
|
||||
|
||||
const handleTourClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
|
||||
setTimeout(() => goToStep(3), 300)
|
||||
} else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(10), 300)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTourClick, true)
|
||||
return () => document.removeEventListener('click', handleTourClick, true)
|
||||
}, [tourState, goToStep])
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getModelConfig()
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const config = result.data as ModelConfig
|
||||
setProviders(Array.isArray(config.api_providers) ? config.api_providers as APIProvider[] : [])
|
||||
setHasUnsavedChanges(false)
|
||||
initialLoadRef.current = false
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async () => {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
|
||||
const cleanedProviders = providers.map(provider => ({
|
||||
...provider,
|
||||
max_retry: provider.max_retry ?? 2,
|
||||
timeout: provider.timeout ?? 30,
|
||||
retry_interval: provider.retry_interval ?? 10,
|
||||
}))
|
||||
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart')
|
||||
if (!shouldProceed) {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultGet.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const config = resultGet.data as ModelConfig
|
||||
|
||||
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
|
||||
const originalModels = Array.isArray(config.models) ? config.models : []
|
||||
const filteredModels = originalModels.filter((model: unknown) => {
|
||||
return typeof model === 'object' && model !== null && 'api_provider' in model && validProviderNames.has((model as Record<string, unknown>).api_provider as string)
|
||||
})
|
||||
|
||||
config.api_providers = cleanedProviders
|
||||
config.models = filteredModels
|
||||
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultUpdate.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '正在重启麦麦...',
|
||||
})
|
||||
await handleRestart()
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkDeleteProviderImpact = useCallback(async (
|
||||
newProviders: APIProvider[],
|
||||
context: 'auto' | 'manual' | 'restart' = 'auto'
|
||||
) => {
|
||||
try {
|
||||
const result = await getModelConfig()
|
||||
if (!result.success) {
|
||||
console.error('加载配置失败:', result.error)
|
||||
return { shouldProceed: true, providers: newProviders }
|
||||
}
|
||||
const config = result.data
|
||||
const oldProviderNames = new Set(providers.map(p => p.name))
|
||||
const newProviderNames = new Set(newProviders.map(p => p.name))
|
||||
|
||||
const deletedProviders = Array.from(oldProviderNames).filter(
|
||||
name => !newProviderNames.has(name)
|
||||
)
|
||||
|
||||
if (deletedProviders.length === 0) {
|
||||
return { shouldProceed: true, providers: newProviders }
|
||||
}
|
||||
|
||||
const models = Array.isArray(config.models) ? config.models : []
|
||||
const affected = models.filter((m: unknown) =>
|
||||
typeof m === 'object' && m !== null && 'api_provider' in m && deletedProviders.includes((m as Record<string, unknown>).api_provider as string)
|
||||
)
|
||||
|
||||
if (affected.length === 0) {
|
||||
return { shouldProceed: true, providers: newProviders }
|
||||
}
|
||||
|
||||
setDeleteConfirmState({
|
||||
isOpen: true,
|
||||
providersToDelete: deletedProviders,
|
||||
affectedModels: affected,
|
||||
pendingProviders: newProviders,
|
||||
context,
|
||||
oldProviders: [...providers],
|
||||
})
|
||||
|
||||
return { shouldProceed: false, providers: newProviders }
|
||||
} catch (error) {
|
||||
console.error('检查删除影响失败:', error)
|
||||
return { shouldProceed: true, providers: newProviders }
|
||||
}
|
||||
}, [providers])
|
||||
|
||||
const handleConfirmDeleteProvider = async () => {
|
||||
try {
|
||||
const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving
|
||||
savingFlag(true)
|
||||
|
||||
setDeleteConfirmState(prev => ({ ...prev, isOpen: false }))
|
||||
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: resultGet.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
savingFlag(false)
|
||||
return
|
||||
}
|
||||
const config = resultGet.data as ModelConfig
|
||||
|
||||
const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData)
|
||||
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
|
||||
const originalModels = Array.isArray(config.models) ? config.models : []
|
||||
const filteredModels = originalModels.filter((model: unknown) => {
|
||||
return typeof model === 'object' && model !== null && 'api_provider' in model && validProviderNames.has((model as Record<string, unknown>).api_provider as string)
|
||||
})
|
||||
|
||||
const deletedModelNames = new Set(
|
||||
deleteConfirmState.affectedModels.map((m: unknown) => typeof m === 'object' && m !== null && 'name' in m ? (m as Record<string, unknown>).name as string : '')
|
||||
)
|
||||
|
||||
const modelTaskConfig = config.model_task_config
|
||||
if (modelTaskConfig && typeof modelTaskConfig === 'object') {
|
||||
Object.keys(modelTaskConfig).forEach(taskName => {
|
||||
const task = (modelTaskConfig as Record<string, unknown>)[taskName]
|
||||
if (task && typeof task === 'object' && 'model_list' in task) {
|
||||
const taskObj = task as Record<string, unknown>
|
||||
if (Array.isArray(taskObj.model_list)) {
|
||||
taskObj.model_list = taskObj.model_list.filter(
|
||||
(modelName: unknown) => typeof modelName === 'string' && !deletedModelNames.has(modelName)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
config.api_providers = cleanedProviders
|
||||
config.models = filteredModels
|
||||
config.model_task_config = modelTaskConfig
|
||||
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultUpdate.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
savingFlag(false)
|
||||
return
|
||||
}
|
||||
|
||||
setProviders(deleteConfirmState.pendingProviders)
|
||||
setHasUnsavedChanges(false)
|
||||
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`,
|
||||
})
|
||||
|
||||
setDeleteConfirmState({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
setSelectedProviders(new Set())
|
||||
|
||||
if (deleteConfirmState.context === 'restart') {
|
||||
await handleRestart()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
if (deleteConfirmState.context === 'auto') {
|
||||
setAutoSaving(false)
|
||||
} else {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDeleteProvider = () => {
|
||||
if (deleteConfirmState.oldProviders.length > 0) {
|
||||
setProviders(deleteConfirmState.oldProviders)
|
||||
}
|
||||
setDeleteConfirmState({
|
||||
isOpen: false,
|
||||
providersToDelete: [],
|
||||
affectedModels: [],
|
||||
pendingProviders: [],
|
||||
context: 'auto',
|
||||
oldProviders: [],
|
||||
})
|
||||
setHasUnsavedChanges(false)
|
||||
}
|
||||
|
||||
const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => {
|
||||
if (initialLoadRef.current) return
|
||||
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto')
|
||||
|
||||
if (!shouldProceed) {
|
||||
setHasUnsavedChanges(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAutoSaving(true)
|
||||
const cleanedProviders = newProviders.map(cleanProviderData)
|
||||
const result = await updateModelConfigSection('api_providers', cleanedProviders)
|
||||
if (!result.success) {
|
||||
console.error('自动保存失败:', result.error)
|
||||
toast({
|
||||
title: '自动保存失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setHasUnsavedChanges(true)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存失败:', error)
|
||||
toast({
|
||||
title: '自动保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setHasUnsavedChanges(true)
|
||||
} finally {
|
||||
setAutoSaving(false)
|
||||
}
|
||||
}, [providers, checkDeleteProviderImpact])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return
|
||||
|
||||
setHasUnsavedChanges(true)
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSaveProviders(providers)
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [providers, autoSaveProviders])
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
|
||||
const cleanedProviders = providers.map(cleanProviderData)
|
||||
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual')
|
||||
if (!shouldProceed) {
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const resultGet = await getModelConfig()
|
||||
if (!resultGet.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultGet.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const config = resultGet.data as ModelConfig
|
||||
|
||||
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
|
||||
const originalModels = Array.isArray(config.models) ? config.models : []
|
||||
const filteredModels = originalModels.filter((model: unknown) => {
|
||||
if (typeof model !== 'object' || model === null || !('api_provider' in model)) return false
|
||||
const modelObj = model as Record<string, unknown>
|
||||
const isValid = validProviderNames.has(modelObj.api_provider as string)
|
||||
if (!isValid) {
|
||||
console.warn(`模型 "${modelObj.name}" 引用了已删除的提供商 "${modelObj.api_provider}"、将被移除`)
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
|
||||
if (originalModels.length !== filteredModels.length) {
|
||||
const removedCount = originalModels.length - filteredModels.length
|
||||
toast({
|
||||
title: '注意',
|
||||
description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`,
|
||||
variant: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
console.log('发送的 providers 数据:', cleanedProviders)
|
||||
config.api_providers = cleanedProviders
|
||||
config.models = filteredModels
|
||||
console.log('完整配置数据:', config)
|
||||
|
||||
const resultUpdate = await updateModelConfig(config)
|
||||
if (!resultUpdate.success) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: resultUpdate.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '模型提供商配置已保存',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (provider: APIProvider | null, index: number | null) => {
|
||||
if (provider) {
|
||||
setEditingProvider(provider)
|
||||
} else {
|
||||
setEditingProvider({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
client_type: 'openai',
|
||||
max_retry: 2,
|
||||
timeout: 30,
|
||||
retry_interval: 10,
|
||||
})
|
||||
}
|
||||
setEditingIndex(index)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEdit = (provider: APIProvider, index: number | null) => {
|
||||
const providerToSave = cleanProviderData(provider)
|
||||
|
||||
if (index !== null) {
|
||||
const newProviders = [...providers]
|
||||
newProviders[index] = providerToSave
|
||||
setProviders(newProviders)
|
||||
} else {
|
||||
setProviders([...providers, providerToSave])
|
||||
}
|
||||
|
||||
setEditDialogOpen(false)
|
||||
setEditingProvider(null)
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const openDeleteDialog = (index: number) => {
|
||||
setDeletingIndex(index)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deletingIndex !== null) {
|
||||
const newProviders = providers.filter((_, i) => i !== deletingIndex)
|
||||
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual')
|
||||
|
||||
if (shouldProceed) {
|
||||
setProviders(newProviders)
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '提供商已从列表中移除',
|
||||
})
|
||||
}
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setDeletingIndex(null)
|
||||
}
|
||||
|
||||
const toggleProviderSelection = (index: number) => {
|
||||
const newSelected = new Set(selectedProviders)
|
||||
if (newSelected.has(index)) {
|
||||
newSelected.delete(index)
|
||||
} else {
|
||||
newSelected.add(index)
|
||||
}
|
||||
setSelectedProviders(newSelected)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedProviders.size === providers.length) {
|
||||
setSelectedProviders(new Set())
|
||||
} else {
|
||||
const allIndices = providers.map((_, idx) => idx)
|
||||
setSelectedProviders(new Set(allIndices))
|
||||
}
|
||||
}
|
||||
|
||||
const openBatchDeleteDialog = () => {
|
||||
if (selectedProviders.size === 0) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '请先选择要删除的提供商',
|
||||
variant: 'default',
|
||||
})
|
||||
return
|
||||
}
|
||||
setBatchDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmBatchDelete = async () => {
|
||||
const newProviders = providers.filter((_, index) => !selectedProviders.has(index))
|
||||
|
||||
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual')
|
||||
|
||||
if (shouldProceed) {
|
||||
setProviders(newProviders)
|
||||
setSelectedProviders(new Set())
|
||||
toast({
|
||||
title: '批量删除成功',
|
||||
description: `已删除 ${selectedProviders.size} 个提供商`,
|
||||
})
|
||||
}
|
||||
|
||||
setBatchDeleteDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleTestConnection = async (providerName: string) => {
|
||||
setTestingProviders(prev => new Set(prev).add(providerName))
|
||||
|
||||
try {
|
||||
const result = await testProviderConnection(providerName)
|
||||
if (!result.success) {
|
||||
toast({
|
||||
title: '测试失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const testResult = result.data
|
||||
setTestResults(prev => new Map(prev).set(providerName, testResult))
|
||||
|
||||
if (testResult.network_ok) {
|
||||
if (testResult.api_key_valid === true) {
|
||||
toast({
|
||||
title: '连接正常',
|
||||
description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`,
|
||||
})
|
||||
} else if (testResult.api_key_valid === false) {
|
||||
toast({
|
||||
title: '连接正常但 Key 无效',
|
||||
description: `${providerName} 网络连接正常、但 API Key 无效或已过期`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: '网络连接正常',
|
||||
description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: '连接失败',
|
||||
description: testResult.error || '无法连接到提供商',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '测试失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setTestingProviders(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(providerName)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestAllConnections = async () => {
|
||||
for (const provider of providers) {
|
||||
await handleTestConnection(provider.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">AI模型厂商配置</h1>
|
||||
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">管理 AI 模型厂商的 API 配置</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{selectedProviders.size > 0 && (
|
||||
<Button
|
||||
onClick={openBatchDeleteDialog}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
|
||||
批量删除 ({selectedProviders.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleTestAllConnections}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={providers.length === 0 || testingProviders.size > 0}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{testingProviders.size > 0 ? `测试中 (${testingProviders.size})` : '测试全部'}
|
||||
</Button>
|
||||
<Button onClick={() => openEditDialog(null, null)} size="sm" className="w-full sm:w-auto" data-tour="add-provider-button">
|
||||
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
|
||||
添加提供商
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveConfig}
|
||||
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto sm:min-w-[120px]"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
|
||||
{saving ? '保存中...' : autoSaving ? '自动保存中...' : hasUnsavedChanges ? '保存配置' : '已保存'}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={saving || autoSaving || isRestarting}
|
||||
size="sm"
|
||||
className="w-full sm:w-auto sm:min-w-[120px]"
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{isRestarting ? '重启中...' : hasUnsavedChanges ? '保存并重启' : '重启麦麦'}
|
||||
</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>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-260px)]">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
testingProviders={testingProviders}
|
||||
testResults={testResults}
|
||||
selectedProviders={selectedProviders}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={openDeleteDialog}
|
||||
onTest={handleTestConnection}
|
||||
onToggleSelect={toggleProviderSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
<ProviderForm
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
editingProvider={editingProvider}
|
||||
editingIndex={editingIndex}
|
||||
providers={providers}
|
||||
onSave={handleSaveEdit}
|
||||
tourState={tourState}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除提供商 "{deletingIndex !== null ? providers[deletingIndex]?.name : ''}" 吗?
|
||||
此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 批量删除确认对话框 */}
|
||||
<AlertDialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除选中的 {selectedProviders.size} 个提供商吗?
|
||||
此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmBatchDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
批量删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 删除提供商影响确认对话框 */}
|
||||
<AlertDialog open={deleteConfirmState.isOpen} onOpenChange={(open) => setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除提供商</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
您即将删除以下提供商:
|
||||
<strong className="text-foreground ml-1">
|
||||
{deleteConfirmState.providersToDelete.join(', ')}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-yellow-600 dark:text-yellow-500 font-medium">
|
||||
⚠️ 此操作将同时删除 {deleteConfirmState.affectedModels.length} 个关联的模型:
|
||||
</p>
|
||||
<ScrollArea className="h-32 w-full rounded border p-3">
|
||||
<div className="space-y-1">
|
||||
{deleteConfirmState.affectedModels.map((model: any, idx: number) => (
|
||||
<div key={idx} className="text-sm">
|
||||
<span className="font-mono text-muted-foreground">•</span>
|
||||
<span className="ml-2 font-medium">{model.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({model.model_identifier})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
这些模型将从模型列表和所有任务分配中移除。此操作无法撤销。
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelDeleteProvider}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDeleteProvider}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 重启遮罩层 */}
|
||||
<RestartOverlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -161,9 +161,9 @@ function IndexPageContent() {
|
||||
// 获取审核统计
|
||||
const fetchReviewStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await getReviewStats()
|
||||
if (isMountedRef.current) {
|
||||
setUncheckedCount(data.unchecked)
|
||||
const result = await getReviewStats()
|
||||
if (result.success && isMountedRef.current) {
|
||||
setUncheckedCount(result.data.unchecked)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取审核统计失败:', error)
|
||||
|
||||
@@ -16,16 +16,16 @@ export function useChatNameMap() {
|
||||
const loadChatNameMap = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getChatList()
|
||||
if (response?.data) {
|
||||
const result = await getChatList()
|
||||
if (result.success) {
|
||||
const nameMap = new Map<string, string>()
|
||||
response.data.forEach((chat: ChatInfo) => {
|
||||
result.data.forEach((chat: ChatInfo) => {
|
||||
nameMap.set(chat.chat_id, chat.chat_name)
|
||||
})
|
||||
setChatNameMap(nameMap)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天列表失败:', error)
|
||||
console.error('加载聚天列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { Users, Search, Edit, Trash2, Eye, User, MessageSquare, Hash, Clock, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Edit,
|
||||
Eye,
|
||||
Hash,
|
||||
Search,
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { Clock, MessageSquare } from 'lucide-react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -33,6 +25,19 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,9 +46,29 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
import {
|
||||
batchDeletePersons,
|
||||
deletePerson,
|
||||
getPersonDetail,
|
||||
getPersonList,
|
||||
getPersonStats,
|
||||
updatePerson,
|
||||
} from '@/lib/person-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { PersonInfo, PersonUpdateRequest } from '@/types/person'
|
||||
import { getPersonList, getPersonDetail, updatePerson, deletePerson, getPersonStats, batchDeletePersons } from '@/lib/person-api'
|
||||
|
||||
export function PersonManagementPage() {
|
||||
const [persons, setPersons] = useState<PersonInfo[]>([])
|
||||
@@ -68,15 +93,18 @@ export function PersonManagementPage() {
|
||||
const loadPersons = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getPersonList({
|
||||
const result = await getPersonList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: search || undefined,
|
||||
is_known: filterKnown,
|
||||
platform: filterPlatform,
|
||||
})
|
||||
setPersons(response.data)
|
||||
setTotal(response.total)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
setPersons(result.data.data)
|
||||
setTotal(result.data.total)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
@@ -91,9 +119,9 @@ export function PersonManagementPage() {
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await getPersonStats()
|
||||
if (response?.data) {
|
||||
setStats(response.data)
|
||||
const result = await getPersonStats()
|
||||
if (result.success) {
|
||||
setStats(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
@@ -110,8 +138,11 @@ export function PersonManagementPage() {
|
||||
// 查看详情
|
||||
const handleViewDetail = async (person: PersonInfo) => {
|
||||
try {
|
||||
const response = await getPersonDetail(person.person_id)
|
||||
setSelectedPerson(response.data)
|
||||
const result = await getPersonDetail(person.person_id)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
setSelectedPerson(result.data)
|
||||
setIsDetailDialogOpen(true)
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -131,7 +162,10 @@ export function PersonManagementPage() {
|
||||
// 删除人物
|
||||
const handleDelete = async (person: PersonInfo) => {
|
||||
try {
|
||||
await deletePerson(person.person_id)
|
||||
const result = await deletePerson(person.person_id)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: `已删除人物信息: ${person.person_name || person.nickname || person.user_id}`,
|
||||
@@ -190,9 +224,12 @@ export function PersonManagementPage() {
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
const result = await batchDeletePersons(Array.from(selectedPersons))
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
toast({
|
||||
title: '批量删除完成',
|
||||
description: result.message,
|
||||
description: result.data.message,
|
||||
})
|
||||
setSelectedPersons(new Set())
|
||||
setBatchDeleteDialogOpen(false)
|
||||
@@ -858,7 +895,10 @@ function PersonEditDialog({
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await updatePerson(person.person_id, formData)
|
||||
const result = await updatePerson(person.person_id, formData)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '人物信息已更新',
|
||||
|
||||
@@ -341,16 +341,44 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [schemaData, configData, rawConfigData] = await Promise.all([
|
||||
const [schemaResult, configResult, rawResult] = await Promise.all([
|
||||
getPluginConfigSchema(plugin.id),
|
||||
getPluginConfig(plugin.id),
|
||||
getPluginConfigRaw(plugin.id)
|
||||
])
|
||||
setSchema(schemaData)
|
||||
setConfig(configData)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(configData)))
|
||||
setSourceCode(rawConfigData)
|
||||
setOriginalSourceCode(rawConfigData)
|
||||
|
||||
if (!schemaResult.success) {
|
||||
toast({
|
||||
title: '加载配置架构失败',
|
||||
description: schemaResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!configResult.success) {
|
||||
toast({
|
||||
title: '加载配置数据失败',
|
||||
description: configResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!rawResult.success) {
|
||||
toast({
|
||||
title: '加载原始配置失败',
|
||||
description: rawResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSchema(schemaResult.data)
|
||||
setConfig(configResult.data)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(configResult.data)))
|
||||
setSourceCode(rawResult.data)
|
||||
setOriginalSourceCode(rawResult.data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载配置失败',
|
||||
@@ -433,7 +461,15 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||
// 重置配置
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await resetPluginConfig(plugin.id)
|
||||
const resetResult = await resetPluginConfig(plugin.id)
|
||||
if (!resetResult.success) {
|
||||
toast({
|
||||
title: '重置失败',
|
||||
description: resetResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: '配置已重置',
|
||||
description: '下次加载插件时将使用默认配置'
|
||||
@@ -452,10 +488,18 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||
// 切换启用状态
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
const result = await togglePlugin(plugin.id)
|
||||
const toggleResult = await togglePlugin(plugin.id)
|
||||
if (!toggleResult.success) {
|
||||
toast({
|
||||
title: '切换失败',
|
||||
description: toggleResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: result.message,
|
||||
description: result.note
|
||||
title: toggleResult.data.message,
|
||||
description: toggleResult.data.note
|
||||
})
|
||||
loadConfig()
|
||||
} catch (error) {
|
||||
@@ -723,8 +767,16 @@ function PluginConfigPageContent() {
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getInstalledPlugins()
|
||||
setPlugins(data)
|
||||
const installedResult = await getInstalledPlugins()
|
||||
if (!installedResult.success) {
|
||||
toast({
|
||||
title: '加载插件列表失败',
|
||||
description: installedResult.error,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
setPlugins(installedResult.data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载插件列表失败',
|
||||
|
||||
@@ -131,10 +131,37 @@ export function PluginDetailPage() {
|
||||
getInstalledPlugins(),
|
||||
])
|
||||
|
||||
setGitStatus(gitStatusResult)
|
||||
setMaimaiVersion(versionResult)
|
||||
setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins))
|
||||
setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins))
|
||||
if (!gitStatusResult.success) {
|
||||
toast({
|
||||
title: 'Git 状态检查失败',
|
||||
description: gitStatusResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setGitStatus(gitStatusResult.data)
|
||||
}
|
||||
|
||||
if (!versionResult.success) {
|
||||
toast({
|
||||
title: '版本获取失败',
|
||||
description: versionResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setMaimaiVersion(versionResult.data)
|
||||
}
|
||||
|
||||
if (!installedPlugins.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedPlugins.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins.data))
|
||||
setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins.data))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
@@ -243,7 +270,16 @@ export function PluginDetailPage() {
|
||||
try {
|
||||
setOperating(true)
|
||||
|
||||
await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
const installResult = await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
|
||||
if (!installResult.success) {
|
||||
toast({
|
||||
title: '安装失败',
|
||||
description: installResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录下载统计
|
||||
recordPluginDownload(plugin.id).catch((err) => {
|
||||
@@ -256,9 +292,17 @@ export function PluginDetailPage() {
|
||||
})
|
||||
|
||||
// 重新加载安装状态
|
||||
const installedPlugins = await getInstalledPlugins()
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
|
||||
const installedPluginsResult = await getInstalledPlugins()
|
||||
if (!installedPluginsResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedPluginsResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data))
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '安装失败',
|
||||
@@ -277,7 +321,16 @@ export function PluginDetailPage() {
|
||||
try {
|
||||
setOperating(true)
|
||||
|
||||
await uninstallPlugin(plugin.id)
|
||||
const uninstallResult = await uninstallPlugin(plugin.id)
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
toast({
|
||||
title: '卸载失败',
|
||||
description: uninstallResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '卸载成功',
|
||||
@@ -285,9 +338,17 @@ export function PluginDetailPage() {
|
||||
})
|
||||
|
||||
// 重新加载安装状态
|
||||
const installedPlugins = await getInstalledPlugins()
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
|
||||
const installedPluginsResult = await getInstalledPlugins()
|
||||
if (!installedPluginsResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedPluginsResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data))
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '卸载失败',
|
||||
@@ -306,17 +367,34 @@ export function PluginDetailPage() {
|
||||
try {
|
||||
setOperating(true)
|
||||
|
||||
const result = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
const updateResult = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
|
||||
|
||||
if (!updateResult.success) {
|
||||
toast({
|
||||
title: '更新失败',
|
||||
description: updateResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '更新成功',
|
||||
description: `${plugin.manifest.name} 已从 ${result.old_version} 更新到 ${result.new_version}`,
|
||||
description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`,
|
||||
})
|
||||
|
||||
// 重新加载安装状态
|
||||
const installedPlugins = await getInstalledPlugins()
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
|
||||
const installedPluginsResult = await getInstalledPlugins()
|
||||
if (!installedPluginsResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedPluginsResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data))
|
||||
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data))
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '更新失败',
|
||||
|
||||
146
dashboard/src/routes/plugins/InstallDialog.tsx
Normal file
146
dashboard/src/routes/plugins/InstallDialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Download } from 'lucide-react'
|
||||
|
||||
import type { PluginInfo } from './types'
|
||||
|
||||
interface InstallDialogProps {
|
||||
open: boolean
|
||||
plugin: PluginInfo | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onInstall: (branch: string) => void
|
||||
}
|
||||
|
||||
export function InstallDialog({ open, plugin, onOpenChange, onInstall }: InstallDialogProps) {
|
||||
const [selectedBranch, setSelectedBranch] = useState('main')
|
||||
const [customBranch, setCustomBranch] = useState('')
|
||||
const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset')
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
|
||||
const handleInstall = () => {
|
||||
const branch = branchInputMode === 'custom' ? customBranch : selectedBranch
|
||||
|
||||
if (!branch || branch.trim() === '') {
|
||||
return
|
||||
}
|
||||
|
||||
onInstall(branch)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>安装插件</DialogTitle>
|
||||
<DialogDescription>
|
||||
安装 {plugin?.manifest.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
版本: {plugin?.manifest.version}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
作者: {typeof plugin?.manifest.author === 'string'
|
||||
? plugin.manifest.author
|
||||
: plugin?.manifest.author?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 高级选项开关 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="advanced-options"
|
||||
checked={showAdvancedOptions}
|
||||
onCheckedChange={(checked) => setShowAdvancedOptions(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-options"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
高级选项
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 高级选项内容 */}
|
||||
{showAdvancedOptions && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">分支选择</label>
|
||||
|
||||
<Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="preset" className="text-xs">预设分支</TabsTrigger>
|
||||
<TabsTrigger value="custom" className="text-xs">自定义分支</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 预设分支选择 */}
|
||||
{branchInputMode === 'preset' && (
|
||||
<div className="mt-3">
|
||||
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分支" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main">main (默认)</SelectItem>
|
||||
<SelectItem value="master">master</SelectItem>
|
||||
<SelectItem value="dev">dev (开发版)</SelectItem>
|
||||
<SelectItem value="develop">develop</SelectItem>
|
||||
<SelectItem value="beta">beta (测试版)</SelectItem>
|
||||
<SelectItem value="stable">stable (稳定版)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义分支输入 */}
|
||||
{branchInputMode === 'custom' && (
|
||||
<div className="space-y-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入分支名称,例如: feature/new-feature"
|
||||
value={customBranch}
|
||||
onChange={(e) => setCustomBranch(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入 Git 分支名称、标签或提交哈希
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAdvancedOptions && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
将从默认分支 (main) 安装插件
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleInstall}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
安装
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
87
dashboard/src/routes/plugins/InstalledTab.tsx
Normal file
87
dashboard/src/routes/plugins/InstalledTab.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types'
|
||||
import { PluginCard } from './PluginCard'
|
||||
|
||||
interface InstalledTabProps {
|
||||
plugins: PluginInfo[]
|
||||
searchQuery: string
|
||||
categoryFilter: string
|
||||
showCompatibleOnly: boolean
|
||||
gitStatus: GitStatus | null
|
||||
maimaiVersion: MaimaiVersion | null
|
||||
pluginStats: Record<string, PluginStatsData>
|
||||
loadProgress: PluginLoadProgress | null
|
||||
onInstall: (plugin: PluginInfo) => void
|
||||
onUpdate: (plugin: PluginInfo) => void
|
||||
onUninstall: (plugin: PluginInfo) => void
|
||||
checkPluginCompatibility: (plugin: PluginInfo) => boolean
|
||||
needsUpdate: (plugin: PluginInfo) => boolean
|
||||
getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null
|
||||
}
|
||||
|
||||
export function InstalledTab({
|
||||
plugins,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
showCompatibleOnly,
|
||||
gitStatus,
|
||||
maimaiVersion,
|
||||
pluginStats,
|
||||
loadProgress,
|
||||
onInstall,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
checkPluginCompatibility,
|
||||
needsUpdate,
|
||||
getStatusBadge,
|
||||
}: InstalledTabProps) {
|
||||
// 过滤已安装插件
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
// 跳过没有 manifest 的插件
|
||||
if (!plugin.manifest) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 只显示已安装
|
||||
if (!plugin.installed) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
|
||||
// 分类过滤
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter))
|
||||
|
||||
// 兼容性过滤
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(plugin)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesCompatibility
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
gitStatus={gitStatus}
|
||||
maimaiVersion={maimaiVersion}
|
||||
pluginStats={pluginStats}
|
||||
loadProgress={loadProgress}
|
||||
onInstall={onInstall}
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
checkPluginCompatibility={checkPluginCompatibility}
|
||||
needsUpdate={needsUpdate}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
dashboard/src/routes/plugins/MarketplaceTab.tsx
Normal file
83
dashboard/src/routes/plugins/MarketplaceTab.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types'
|
||||
import { PluginCard } from './PluginCard'
|
||||
|
||||
interface MarketplaceTabProps {
|
||||
plugins: PluginInfo[]
|
||||
searchQuery: string
|
||||
categoryFilter: string
|
||||
showCompatibleOnly: boolean
|
||||
gitStatus: GitStatus | null
|
||||
maimaiVersion: MaimaiVersion | null
|
||||
pluginStats: Record<string, PluginStatsData>
|
||||
loadProgress: PluginLoadProgress | null
|
||||
onInstall: (plugin: PluginInfo) => void
|
||||
onUpdate: (plugin: PluginInfo) => void
|
||||
onUninstall: (plugin: PluginInfo) => void
|
||||
checkPluginCompatibility: (plugin: PluginInfo) => boolean
|
||||
needsUpdate: (plugin: PluginInfo) => boolean
|
||||
getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null
|
||||
}
|
||||
|
||||
export function MarketplaceTab({
|
||||
plugins,
|
||||
searchQuery,
|
||||
categoryFilter,
|
||||
showCompatibleOnly,
|
||||
gitStatus,
|
||||
maimaiVersion,
|
||||
pluginStats,
|
||||
loadProgress,
|
||||
onInstall,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
checkPluginCompatibility,
|
||||
needsUpdate,
|
||||
getStatusBadge,
|
||||
}: MarketplaceTabProps) {
|
||||
// 过滤插件
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
// 跳过没有 manifest 的插件
|
||||
if (!plugin.manifest) {
|
||||
console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id)
|
||||
return false
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
|
||||
// 分类过滤
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter))
|
||||
|
||||
// 兼容性过滤
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(plugin)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesCompatibility
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
gitStatus={gitStatus}
|
||||
maimaiVersion={maimaiVersion}
|
||||
pluginStats={pluginStats}
|
||||
loadProgress={loadProgress}
|
||||
onInstall={onInstall}
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
checkPluginCompatibility={checkPluginCompatibility}
|
||||
needsUpdate={needsUpdate}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
dashboard/src/routes/plugins/PluginCard.tsx
Normal file
235
dashboard/src/routes/plugins/PluginCard.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw, Star, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types'
|
||||
import { CATEGORY_NAMES } from './types'
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: PluginInfo
|
||||
gitStatus: GitStatus | null
|
||||
maimaiVersion: MaimaiVersion | null
|
||||
pluginStats: Record<string, PluginStatsData>
|
||||
loadProgress: PluginLoadProgress | null
|
||||
onInstall: (plugin: PluginInfo) => void
|
||||
onUpdate: (plugin: PluginInfo) => void
|
||||
onUninstall: (plugin: PluginInfo) => void
|
||||
checkPluginCompatibility: (plugin: PluginInfo) => boolean
|
||||
needsUpdate: (plugin: PluginInfo) => boolean
|
||||
getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null
|
||||
}
|
||||
|
||||
export function PluginCard({
|
||||
plugin,
|
||||
gitStatus,
|
||||
maimaiVersion,
|
||||
pluginStats,
|
||||
loadProgress,
|
||||
onInstall,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
checkPluginCompatibility,
|
||||
needsUpdate,
|
||||
getStatusBadge,
|
||||
}: PluginCardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plugin.id}
|
||||
className="flex flex-col hover:shadow-lg transition-shadow h-full"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-xl">{plugin.manifest?.name || plugin.id}</CardTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
{plugin.manifest?.categories && plugin.manifest.categories[0] && (
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
{CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusBadge(plugin)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">{plugin.manifest?.description || '无描述'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="space-y-3">
|
||||
{/* 统计信息 */}
|
||||
<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>{(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span>{(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plugin.manifest?.keywords && plugin.manifest.keywords.slice(0, 3).map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="text-xs">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{plugin.manifest.keywords.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* 版本和作者 */}
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t space-y-1">
|
||||
<div>v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}</div>
|
||||
{/* 支持版本 */}
|
||||
{plugin.manifest?.host_application && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>支持:</span>
|
||||
<span className="font-medium">
|
||||
{plugin.manifest.host_application.min_version}
|
||||
{plugin.manifest.host_application.max_version
|
||||
? ` - ${plugin.manifest.host_application.max_version}`
|
||||
: ' - 最新版本'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/plugin-detail', search: { pluginId: plugin.id } })}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
{plugin.installed ? (
|
||||
needsUpdate(plugin) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!gitStatus?.installed}
|
||||
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
|
||||
onClick={() => onUpdate(plugin)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
更新
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!gitStatus?.installed}
|
||||
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
|
||||
onClick={() => onUninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
卸载
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!gitStatus?.installed ||
|
||||
loadProgress?.operation === 'install' ||
|
||||
(maimaiVersion !== null && !checkPluginCompatibility(plugin))
|
||||
}
|
||||
title={
|
||||
!gitStatus?.installed
|
||||
? 'Git 未安装'
|
||||
: (maimaiVersion !== null && !checkPluginCompatibility(plugin))
|
||||
? `不兼容当前版本 (需要 ${plugin.manifest?.host_application?.min_version || '未知'}${plugin.manifest?.host_application?.max_version ? ` - ${plugin.manifest.host_application.max_version}` : '+'},当前 ${maimaiVersion?.version})`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onInstall(plugin)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
{loadProgress?.operation === 'install' && loadProgress?.plugin_id === plugin.id ? '安装中...' : '安装'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
{/* 安装/卸载/更新进度显示 - 在卡片下方 */}
|
||||
{loadProgress &&
|
||||
(loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') &&
|
||||
loadProgress.operation !== 'fetch' &&
|
||||
loadProgress.plugin_id === plugin.id && (
|
||||
<div className="px-6 pb-4 -mt-2">
|
||||
<div className={`space-y-2 p-3 rounded-lg border ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
|
||||
: 'bg-muted/50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{loadProgress.stage === 'loading' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : loadProgress.stage === 'success' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 text-red-600" />
|
||||
)}
|
||||
<span className={`text-xs font-medium ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: ''
|
||||
}`}>
|
||||
{loadProgress.stage === 'loading' ? (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '正在安装'}
|
||||
{loadProgress.operation === 'uninstall' && '正在卸载'}
|
||||
{loadProgress.operation === 'update' && '正在更新'}
|
||||
</>
|
||||
) : loadProgress.stage === 'success' ? (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '安装完成'}
|
||||
{loadProgress.operation === 'uninstall' && '卸载完成'}
|
||||
{loadProgress.operation === 'update' && '更新完成'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '安装失败'}
|
||||
{loadProgress.operation === 'uninstall' && '卸载失败'}
|
||||
{loadProgress.operation === 'update' && '更新失败'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{loadProgress.stage !== 'error' && (
|
||||
<span className={`text-xs font-medium ${
|
||||
loadProgress.stage === 'success' ? 'text-green-700 dark:text-green-300' : ''
|
||||
}`}>{loadProgress.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{loadProgress.stage !== 'error' && (
|
||||
<Progress
|
||||
value={loadProgress.progress}
|
||||
className={`h-1.5 ${loadProgress.stage === 'success' ? '[&>div]:bg-green-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<div className={`text-xs ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'text-green-600 dark:text-green-400 truncate'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-muted-foreground truncate'
|
||||
}`}>
|
||||
{loadProgress.stage === 'error' ? (loadProgress.error || loadProgress.message || '操作失败') : loadProgress.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +1,39 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Search, Download, Star, CheckCircle2, AlertCircle, Loader2, AlertTriangle, RefreshCw, Trash2, Settings2, RotateCw, Info } from 'lucide-react'
|
||||
import type { PluginInfo } from '@/types/plugin'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2, RotateCw, Search, Settings2 } from 'lucide-react'
|
||||
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import {
|
||||
fetchPluginList,
|
||||
checkGitStatus,
|
||||
connectPluginProgressWebSocket,
|
||||
installPlugin,
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import {
|
||||
checkGitStatus,
|
||||
checkPluginInstalled,
|
||||
connectPluginProgressWebSocket,
|
||||
fetchPluginList,
|
||||
getInstalledPluginVersion,
|
||||
getInstalledPlugins,
|
||||
getMaimaiVersion,
|
||||
installPlugin,
|
||||
isPluginCompatible,
|
||||
uninstallPlugin,
|
||||
updatePlugin,
|
||||
getMaimaiVersion,
|
||||
isPluginCompatible,
|
||||
getInstalledPlugins,
|
||||
checkPluginInstalled,
|
||||
getInstalledPluginVersion,
|
||||
type GitStatus,
|
||||
type PluginLoadProgress,
|
||||
type MaimaiVersion,
|
||||
type InstalledPlugin
|
||||
type InstalledPlugin,
|
||||
} from '@/lib/plugin-api'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { recordPluginDownload, getPluginStats, type PluginStatsData } from '@/lib/plugin-stats'
|
||||
import { getPluginStats, recordPluginDownload, type PluginStatsData } from '@/lib/plugin-stats'
|
||||
|
||||
// 分类名称映射
|
||||
const CATEGORY_NAMES: Record<string, string> = {
|
||||
'Group Management': '群组管理',
|
||||
'Entertainment & Interaction': '娱乐互动',
|
||||
'Utility Tools': '实用工具',
|
||||
'Content Generation': '内容生成',
|
||||
'Multimedia': '多媒体',
|
||||
'External Integration': '外部集成',
|
||||
'Data Analysis & Insights': '数据分析与洞察',
|
||||
'Other': '其他',
|
||||
}
|
||||
import { InstallDialog } from './InstallDialog'
|
||||
import { InstalledTab } from './InstalledTab'
|
||||
import { MarketplaceTab } from './MarketplaceTab'
|
||||
import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress } from './types'
|
||||
|
||||
// 主导出组件:包装 RestartProvider
|
||||
export function PluginsPage() {
|
||||
@@ -88,10 +64,6 @@ function PluginsPageContent() {
|
||||
// 安装对话框状态
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false)
|
||||
const [installingPlugin, setInstallingPlugin] = useState<PluginInfo | null>(null)
|
||||
const [selectedBranch, setSelectedBranch] = useState('main')
|
||||
const [customBranch, setCustomBranch] = useState('')
|
||||
const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset')
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -180,34 +152,71 @@ function PluginsPageContent() {
|
||||
|
||||
// 3. 检查 Git 状态
|
||||
if (!isUnmounted) {
|
||||
const status = await checkGitStatus()
|
||||
setGitStatus(status)
|
||||
|
||||
if (!status.installed) {
|
||||
const statusResult = await checkGitStatus()
|
||||
if (!statusResult.success) {
|
||||
toast({
|
||||
title: 'Git 未安装',
|
||||
description: status.error || '请先安装 Git 才能使用插件安装功能',
|
||||
title: 'Git 状态检查失败',
|
||||
description: statusResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setGitStatus({ installed: false, error: statusResult.error })
|
||||
} else {
|
||||
setGitStatus(statusResult.data)
|
||||
|
||||
if (!statusResult.data.installed) {
|
||||
toast({
|
||||
title: 'Git 未安装',
|
||||
description: statusResult.data.error || '请先安装 Git 才能使用插件安装功能',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 获取麦麦版本
|
||||
if (!isUnmounted) {
|
||||
const version = await getMaimaiVersion()
|
||||
setMaimaiVersion(version)
|
||||
const versionResult = await getMaimaiVersion()
|
||||
if (!versionResult.success) {
|
||||
toast({
|
||||
title: '版本获取失败',
|
||||
description: versionResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setMaimaiVersion(versionResult.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 加载插件列表(包含已安装信息)
|
||||
if (!isUnmounted) {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchPluginList()
|
||||
const apiResult = await fetchPluginList()
|
||||
if (!apiResult.success) {
|
||||
if (!isUnmounted) {
|
||||
setError(apiResult.error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: apiResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = apiResult.data
|
||||
|
||||
if (!isUnmounted) {
|
||||
// 获取已安装插件列表
|
||||
const installed = await getInstalledPlugins()
|
||||
const installedResult = await getInstalledPlugins()
|
||||
if (!installedResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const installed = installedResult.data
|
||||
setInstalledPlugins(installed)
|
||||
|
||||
// 将已安装信息合并到插件数据中
|
||||
@@ -261,16 +270,6 @@ function PluginsPageContent() {
|
||||
// 6. 加载所有插件的统计数据
|
||||
loadPluginStats(mergedData)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isUnmounted) {
|
||||
const errorMessage = err instanceof Error ? err.message : '加载插件列表失败'
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!isUnmounted) {
|
||||
setLoading(false)
|
||||
@@ -307,13 +306,6 @@ function PluginsPageContent() {
|
||||
const marketVer = plugin.manifest.version?.trim()
|
||||
|
||||
if (installedVer !== marketVer) {
|
||||
// console.log(`[Plugin ${plugin.id}] 版本不一致:`, {
|
||||
// installed: installedVer,
|
||||
// market: marketVer,
|
||||
// installedType: typeof plugin.installed_version,
|
||||
// marketType: typeof plugin.manifest.version
|
||||
// })
|
||||
|
||||
// 简单的版本比较:只有当市场版本比已安装版本新时才显示"可更新"
|
||||
// 如果本地版本更新(比如手动更新或市场数据过期),则显示"已安装"
|
||||
const installedParts = installedVer?.split('.').map(Number) || [0, 0, 0]
|
||||
@@ -383,40 +375,6 @@ function PluginsPageContent() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 过滤插件
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
// 跳过没有 manifest 的插件
|
||||
if (!plugin.manifest) {
|
||||
console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id)
|
||||
return false
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
|
||||
// 分类过滤
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter))
|
||||
|
||||
// 标签页过滤
|
||||
let matchesTab = true
|
||||
if (activeTab === 'installed') {
|
||||
matchesTab = plugin.installed === true
|
||||
} else if (activeTab === 'updates') {
|
||||
matchesTab = plugin.installed === true && needsUpdate(plugin)
|
||||
}
|
||||
|
||||
// 兼容性过滤
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(plugin)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesTab && matchesCompatibility
|
||||
})
|
||||
|
||||
// 打开安装对话框
|
||||
const openInstallDialog = (plugin: PluginInfo) => {
|
||||
if (!gitStatus?.installed) {
|
||||
@@ -439,19 +397,13 @@ function PluginsPageContent() {
|
||||
}
|
||||
|
||||
setInstallingPlugin(plugin)
|
||||
setSelectedBranch('main')
|
||||
setCustomBranch('')
|
||||
setBranchInputMode('preset')
|
||||
setShowAdvancedOptions(false)
|
||||
setInstallDialogOpen(true)
|
||||
}
|
||||
|
||||
// 安装插件处理
|
||||
const handleInstall = async () => {
|
||||
const handleInstall = async (branch: string) => {
|
||||
if (!installingPlugin) return
|
||||
|
||||
const branch = branchInputMode === 'custom' ? customBranch : selectedBranch
|
||||
|
||||
if (!branch || branch.trim() === '') {
|
||||
toast({
|
||||
title: '分支名称不能为空',
|
||||
@@ -463,12 +415,21 @@ function PluginsPageContent() {
|
||||
try {
|
||||
setInstallDialogOpen(false)
|
||||
|
||||
await installPlugin(
|
||||
const installResult = await installPlugin(
|
||||
installingPlugin.id,
|
||||
installingPlugin.manifest.repository_url || '',
|
||||
branch
|
||||
)
|
||||
|
||||
if (!installResult.success) {
|
||||
toast({
|
||||
title: '安装失败',
|
||||
description: installResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录下载统计
|
||||
recordPluginDownload(installingPlugin.id).catch(err => {
|
||||
console.warn('Failed to record download:', err)
|
||||
@@ -480,7 +441,16 @@ function PluginsPageContent() {
|
||||
})
|
||||
|
||||
// 重新加载已安装插件列表
|
||||
const installed = await getInstalledPlugins()
|
||||
const installedResult = await getInstalledPlugins()
|
||||
if (!installedResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const installed = installedResult.data
|
||||
setInstalledPlugins(installed)
|
||||
|
||||
// 重新合并已安装信息到插件列表
|
||||
@@ -513,7 +483,16 @@ function PluginsPageContent() {
|
||||
// 卸载插件处理
|
||||
const handleUninstall = async (plugin: PluginInfo) => {
|
||||
try {
|
||||
await uninstallPlugin(plugin.id)
|
||||
const uninstallResult = await uninstallPlugin(plugin.id)
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
toast({
|
||||
title: '卸载失败',
|
||||
description: uninstallResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '卸载成功',
|
||||
@@ -521,7 +500,16 @@ function PluginsPageContent() {
|
||||
})
|
||||
|
||||
// 重新加载已安装插件列表
|
||||
const installed = await getInstalledPlugins()
|
||||
const installedResult = await getInstalledPlugins()
|
||||
if (!installedResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const installed = installedResult.data
|
||||
setInstalledPlugins(installed)
|
||||
|
||||
// 重新合并已安装信息到插件列表
|
||||
@@ -561,19 +549,37 @@ function PluginsPageContent() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updatePlugin(
|
||||
const updateResult = await updatePlugin(
|
||||
plugin.id,
|
||||
plugin.manifest.repository_url || '',
|
||||
'main'
|
||||
)
|
||||
|
||||
if (!updateResult.success) {
|
||||
toast({
|
||||
title: '更新失败',
|
||||
description: updateResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '更新成功',
|
||||
description: `${plugin.manifest.name} 已从 ${result.old_version} 更新到 ${result.new_version}`,
|
||||
description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`,
|
||||
})
|
||||
|
||||
// 重新加载已安装插件列表
|
||||
const installed = await getInstalledPlugins()
|
||||
const installedResult = await getInstalledPlugins()
|
||||
if (!installedResult.success) {
|
||||
toast({
|
||||
title: '获取已安装插件失败',
|
||||
description: installedResult.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const installed = installedResult.data
|
||||
setInstalledPlugins(installed)
|
||||
|
||||
// 重新合并已安装信息到插件列表
|
||||
@@ -601,6 +607,50 @@ function PluginsPageContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤插件用于标签页统计
|
||||
const getFilteredPluginCount = (tab: 'all' | 'installed' | 'updates') => {
|
||||
return plugins.filter(p => {
|
||||
if (!p.manifest) return false
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(p.manifest.categories && p.manifest.categories.includes(categoryFilter))
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(p)
|
||||
|
||||
let matchesTab = true
|
||||
if (tab === 'installed') {
|
||||
matchesTab = p.installed === true
|
||||
} else if (tab === 'updates') {
|
||||
matchesTab = p.installed === true && needsUpdate(p)
|
||||
}
|
||||
|
||||
return matchesSearch && matchesCategory && matchesCompatibility && matchesTab
|
||||
}).length
|
||||
}
|
||||
|
||||
// 过滤插件用于可更新标签页
|
||||
const filteredUpdatablePlugins = plugins.filter(plugin => {
|
||||
if (!plugin.manifest) return false
|
||||
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter))
|
||||
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(plugin)
|
||||
|
||||
return plugin.installed && needsUpdate(plugin) && matchesSearch && matchesCategory && matchesCompatibility
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
@@ -718,55 +768,13 @@ function PluginsPageContent() {
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="all">
|
||||
全部插件 ({
|
||||
plugins.filter(p => {
|
||||
if (!p.manifest) return false
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(p.manifest.categories && p.manifest.categories.includes(categoryFilter))
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(p)
|
||||
return matchesSearch && matchesCategory && matchesCompatibility
|
||||
}).length
|
||||
})
|
||||
全部插件 ({getFilteredPluginCount('all')})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="installed">
|
||||
已安装 ({
|
||||
plugins.filter(p => {
|
||||
if (!p.manifest) return false
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(p.manifest.categories && p.manifest.categories.includes(categoryFilter))
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(p)
|
||||
return p.installed && matchesSearch && matchesCategory && matchesCompatibility
|
||||
}).length
|
||||
})
|
||||
已安装 ({getFilteredPluginCount('installed')})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="updates">
|
||||
可更新 ({
|
||||
plugins.filter(p => {
|
||||
if (!p.manifest) return false
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
(p.manifest.categories && p.manifest.categories.includes(categoryFilter))
|
||||
const matchesCompatibility = !showCompatibleOnly ||
|
||||
!maimaiVersion ||
|
||||
checkPluginCompatibility(p)
|
||||
return p.installed && needsUpdate(p) && matchesSearch && matchesCategory && matchesCompatibility
|
||||
}).length
|
||||
})
|
||||
可更新 ({getFilteredPluginCount('updates')})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -831,328 +839,57 @@ function PluginsPageContent() {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : filteredPlugins.length === 0 ? (
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">未找到插件</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery || categoryFilter !== 'all'
|
||||
? '尝试调整搜索条件或筛选器'
|
||||
: '暂无可用插件'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : activeTab === 'all' ? (
|
||||
<MarketplaceTab
|
||||
plugins={plugins}
|
||||
searchQuery={searchQuery}
|
||||
categoryFilter={categoryFilter}
|
||||
showCompatibleOnly={showCompatibleOnly}
|
||||
gitStatus={gitStatus}
|
||||
maimaiVersion={maimaiVersion}
|
||||
pluginStats={pluginStats}
|
||||
loadProgress={loadProgress}
|
||||
onInstall={openInstallDialog}
|
||||
onUpdate={handleUpdate}
|
||||
onUninstall={handleUninstall}
|
||||
checkPluginCompatibility={checkPluginCompatibility}
|
||||
needsUpdate={needsUpdate}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
) : activeTab === 'installed' ? (
|
||||
<InstalledTab
|
||||
plugins={plugins}
|
||||
searchQuery={searchQuery}
|
||||
categoryFilter={categoryFilter}
|
||||
showCompatibleOnly={showCompatibleOnly}
|
||||
gitStatus={gitStatus}
|
||||
maimaiVersion={maimaiVersion}
|
||||
pluginStats={pluginStats}
|
||||
loadProgress={loadProgress}
|
||||
onInstall={openInstallDialog}
|
||||
onUpdate={handleUpdate}
|
||||
onUninstall={handleUninstall}
|
||||
checkPluginCompatibility={checkPluginCompatibility}
|
||||
needsUpdate={needsUpdate}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<Card
|
||||
key={plugin.id}
|
||||
className="flex flex-col hover:shadow-lg transition-shadow h-full"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-xl">{plugin.manifest?.name || plugin.id}</CardTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
{plugin.manifest?.categories && plugin.manifest.categories[0] && (
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
{CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusBadge(plugin)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">{plugin.manifest?.description || '无描述'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="space-y-3">
|
||||
{/* 统计信息 */}
|
||||
<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>{(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span>{(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plugin.manifest?.keywords && plugin.manifest.keywords.slice(0, 3).map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="text-xs">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{plugin.manifest.keywords.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* 版本和作者 */}
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t space-y-1">
|
||||
<div>v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}</div>
|
||||
{/* 支持版本 */}
|
||||
{plugin.manifest?.host_application && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>支持:</span>
|
||||
<span className="font-medium">
|
||||
{plugin.manifest.host_application.min_version}
|
||||
{plugin.manifest.host_application.max_version
|
||||
? ` - ${plugin.manifest.host_application.max_version}`
|
||||
: ' - 最新版本'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/plugin-detail', search: { pluginId: plugin.id } })}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
{plugin.installed ? (
|
||||
needsUpdate(plugin) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!gitStatus?.installed}
|
||||
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
|
||||
onClick={() => handleUpdate(plugin)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
更新
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!gitStatus?.installed}
|
||||
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
卸载
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!gitStatus?.installed ||
|
||||
loadProgress?.operation === 'install' ||
|
||||
(maimaiVersion !== null && !checkPluginCompatibility(plugin))
|
||||
}
|
||||
title={
|
||||
!gitStatus?.installed
|
||||
? 'Git 未安装'
|
||||
: (maimaiVersion !== null && !checkPluginCompatibility(plugin))
|
||||
? `不兼容当前版本 (需要 ${plugin.manifest?.host_application?.min_version || '未知'}${plugin.manifest?.host_application?.max_version ? ` - ${plugin.manifest.host_application.max_version}` : '+'},当前 ${maimaiVersion?.version})`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => openInstallDialog(plugin)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
{loadProgress?.operation === 'install' && loadProgress?.plugin_id === plugin.id ? '安装中...' : '安装'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
{/* 安装/卸载/更新进度显示 - 在卡片下方 */}
|
||||
{loadProgress &&
|
||||
(loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') &&
|
||||
loadProgress.operation !== 'fetch' &&
|
||||
loadProgress.plugin_id === plugin.id && (
|
||||
<div className="px-6 pb-4 -mt-2">
|
||||
<div className={`space-y-2 p-3 rounded-lg border ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
|
||||
: 'bg-muted/50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{loadProgress.stage === 'loading' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : loadProgress.stage === 'success' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 text-red-600" />
|
||||
)}
|
||||
<span className={`text-xs font-medium ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: ''
|
||||
}`}>
|
||||
{loadProgress.stage === 'loading' ? (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '正在安装'}
|
||||
{loadProgress.operation === 'uninstall' && '正在卸载'}
|
||||
{loadProgress.operation === 'update' && '正在更新'}
|
||||
</>
|
||||
) : loadProgress.stage === 'success' ? (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '安装完成'}
|
||||
{loadProgress.operation === 'uninstall' && '卸载完成'}
|
||||
{loadProgress.operation === 'update' && '更新完成'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{loadProgress.operation === 'install' && '安装失败'}
|
||||
{loadProgress.operation === 'uninstall' && '卸载失败'}
|
||||
{loadProgress.operation === 'update' && '更新失败'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{loadProgress.stage !== 'error' && (
|
||||
<span className={`text-xs font-medium ${
|
||||
loadProgress.stage === 'success' ? 'text-green-700 dark:text-green-300' : ''
|
||||
}`}>{loadProgress.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{loadProgress.stage !== 'error' && (
|
||||
<Progress
|
||||
value={loadProgress.progress}
|
||||
className={`h-1.5 ${loadProgress.stage === 'success' ? '[&>div]:bg-green-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<div className={`text-xs ${
|
||||
loadProgress.stage === 'success'
|
||||
? 'text-green-600 dark:text-green-400 truncate'
|
||||
: loadProgress.stage === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-muted-foreground truncate'
|
||||
}`}>
|
||||
{loadProgress.stage === 'error' ? (loadProgress.error || loadProgress.message || '操作失败') : loadProgress.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{filteredUpdatablePlugins.map((plugin) => (
|
||||
<div key={plugin.id}>
|
||||
{/* PluginCard would go here */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 安装对话框 */}
|
||||
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>安装插件</DialogTitle>
|
||||
<DialogDescription>
|
||||
安装 {installingPlugin?.manifest.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
版本: {installingPlugin?.manifest.version}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
作者: {typeof installingPlugin?.manifest.author === 'string'
|
||||
? installingPlugin.manifest.author
|
||||
: installingPlugin?.manifest.author?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 高级选项开关 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="advanced-options"
|
||||
checked={showAdvancedOptions}
|
||||
onCheckedChange={(checked) => setShowAdvancedOptions(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-options"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
高级选项
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 高级选项内容 */}
|
||||
{showAdvancedOptions && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">分支选择</label>
|
||||
|
||||
<Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="preset" className="text-xs">预设分支</TabsTrigger>
|
||||
<TabsTrigger value="custom" className="text-xs">自定义分支</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 预设分支选择 */}
|
||||
{branchInputMode === 'preset' && (
|
||||
<div className="mt-3">
|
||||
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分支" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main">main (默认)</SelectItem>
|
||||
<SelectItem value="master">master</SelectItem>
|
||||
<SelectItem value="dev">dev (开发版)</SelectItem>
|
||||
<SelectItem value="develop">develop</SelectItem>
|
||||
<SelectItem value="beta">beta (测试版)</SelectItem>
|
||||
<SelectItem value="stable">stable (稳定版)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义分支输入 */}
|
||||
{branchInputMode === 'custom' && (
|
||||
<div className="space-y-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入分支名称,例如: feature/new-feature"
|
||||
value={customBranch}
|
||||
onChange={(e) => setCustomBranch(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入 Git 分支名称、标签或提交哈希
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAdvancedOptions && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
将从默认分支 (main) 安装插件
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setInstallDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleInstall}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
安装
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<InstallDialog
|
||||
open={installDialogOpen}
|
||||
plugin={installingPlugin}
|
||||
onOpenChange={setInstallDialogOpen}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
|
||||
{/* 重启遮罩层 */}
|
||||
<RestartOverlay />
|
||||
@@ -1160,4 +897,3 @@ function PluginsPageContent() {
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
18
dashboard/src/routes/plugins/types.ts
Normal file
18
dashboard/src/routes/plugins/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PluginInfo } from '@/types/plugin'
|
||||
import type { GitStatus, MaimaiVersion, PluginLoadProgress } from '@/lib/plugin-api'
|
||||
import type { PluginStatsData } from '@/lib/plugin-stats'
|
||||
|
||||
// 分类名称映射
|
||||
export const CATEGORY_NAMES: Record<string, string> = {
|
||||
'Group Management': '群组管理',
|
||||
'Entertainment & Interaction': '娱乐互动',
|
||||
'Utility Tools': '实用工具',
|
||||
'Content Generation': '内容生成',
|
||||
'Multimedia': '多媒体',
|
||||
'External Integration': '外部集成',
|
||||
'Data Analysis & Insights': '数据分析与洞察',
|
||||
'Other': '其他',
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { PluginInfo, GitStatus, MaimaiVersion, PluginLoadProgress, PluginStatsData }
|
||||
File diff suppressed because it is too large
Load Diff
933
dashboard/src/routes/resource/emoji/EmojiDialogs.tsx
Normal file
933
dashboard/src/routes/resource/emoji/EmojiDialogs.tsx
Normal file
@@ -0,0 +1,933 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeft, Check, CheckCircle2, ImageIcon, Upload, X } from 'lucide-react'
|
||||
import Dashboard from '@uppy/react/dashboard'
|
||||
import Uppy from '@uppy/core'
|
||||
import '@uppy/core/css/style.min.css'
|
||||
import '@uppy/dashboard/css/style.min.css'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import '@/styles/uppy-custom.css'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getEmojiOriginalUrl,
|
||||
getEmojiUploadUrl,
|
||||
updateEmoji,
|
||||
} from '@/lib/emoji-api'
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import type { Emoji } from '@/types/emoji'
|
||||
|
||||
import type { UploadedFileInfo, UploadStep } from './types'
|
||||
|
||||
// ============================
|
||||
// 详情对话框组件
|
||||
// ============================
|
||||
export function EmojiDetailDialog({
|
||||
emoji,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
emoji: Emoji | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
if (!emoji) return null
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>表情包详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[calc(90vh-8rem)] pr-4">
|
||||
<div className="space-y-4">
|
||||
{/* 表情包预览图 - 使用原图 */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-32 h-32 bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={getEmojiOriginalUrl(emoji.id)}
|
||||
alt={emoji.description || '表情包'}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent) {
|
||||
parent.innerHTML =
|
||||
'<svg class="h-16 w-16 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">ID</Label>
|
||||
<div className="mt-1 font-mono">{emoji.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">格式</Label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{emoji.format.toUpperCase()}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">文件路径</Label>
|
||||
<div className="mt-1 font-mono text-sm break-all bg-muted p-2 rounded">
|
||||
{emoji.full_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">哈希值</Label>
|
||||
<div className="mt-1 font-mono text-sm break-all bg-muted p-2 rounded">
|
||||
{emoji.emoji_hash}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">描述</Label>
|
||||
{emoji.description ? (
|
||||
<div className="mt-1 rounded-lg border bg-muted/50 p-3">
|
||||
<Markdown className="prose-sm">{emoji.description}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm text-muted-foreground">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">情绪</Label>
|
||||
<div className="mt-1">
|
||||
{emoji.emotion ? (
|
||||
<span className="text-sm">{emoji.emotion}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">状态</Label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{emoji.is_registered && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
已注册
|
||||
</Badge>
|
||||
)}
|
||||
{emoji.is_banned && (
|
||||
<Badge variant="destructive">已封禁</Badge>
|
||||
)}
|
||||
{!emoji.is_registered && !emoji.is_banned && (
|
||||
<Badge variant="outline">未注册</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">使用次数</Label>
|
||||
<div className="mt-1 font-mono text-lg">
|
||||
{emoji.usage_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">记录时间</Label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatTime(emoji.record_time)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">注册时间</Label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatTime(emoji.register_time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">最后使用</Label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatTime(emoji.last_used_time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 编辑对话框组件
|
||||
// ============================
|
||||
export function EmojiEditDialog({
|
||||
emoji,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
emoji: Emoji | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [emotionInput, setEmotionInput] = useState('')
|
||||
const [isRegistered, setIsRegistered] = useState(false)
|
||||
const [isBanned, setIsBanned] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (emoji) {
|
||||
setEmotionInput(emoji.emotion || '')
|
||||
setIsRegistered(emoji.is_registered)
|
||||
setIsBanned(emoji.is_banned)
|
||||
}
|
||||
}, [emoji])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!emoji) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
// 将输入的标签字符串标准化为逗号分隔格式
|
||||
const emotionString = emotionInput
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
|
||||
await updateEmoji(emoji.id, {
|
||||
emotion: emotionString || undefined,
|
||||
is_registered: isRegistered,
|
||||
is_banned: isBanned,
|
||||
})
|
||||
|
||||
toast({
|
||||
title: '成功',
|
||||
description: '表情包信息已更新',
|
||||
})
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存失败'
|
||||
toast({
|
||||
title: '错误',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!emoji) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑表情包</DialogTitle>
|
||||
<DialogDescription>修改表情包的情绪和状态信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>情绪</Label>
|
||||
<Textarea
|
||||
value={emotionInput}
|
||||
onChange={(e) => setEmotionInput(e.target.value)}
|
||||
placeholder="输入情绪描述..."
|
||||
rows={2}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
输入情绪相关的文本描述
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_registered"
|
||||
checked={isRegistered}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
setIsRegistered(true)
|
||||
setIsBanned(false) // 注册时自动取消封禁
|
||||
} else {
|
||||
setIsRegistered(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="is_registered" className="cursor-pointer">
|
||||
已注册
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_banned"
|
||||
checked={isBanned}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
setIsBanned(true)
|
||||
setIsRegistered(false) // 封禁时自动取消注册
|
||||
} else {
|
||||
setIsBanned(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="is_banned" className="cursor-pointer">
|
||||
已封禁
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 上传对话框组件
|
||||
// ============================
|
||||
export function EmojiUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [step, setStep] = useState<UploadStep>('select')
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFileInfo[]>([])
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 创建 Uppy 实例(仅用于文件选择,不自动上传)
|
||||
const uppy = useMemo(() => {
|
||||
const uppyInstance = new Uppy({
|
||||
id: 'emoji-uploader',
|
||||
autoProceed: false,
|
||||
restrictions: {
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
allowedFileTypes: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
maxNumberOfFiles: 20,
|
||||
},
|
||||
locale: {
|
||||
pluralize: () => 0,
|
||||
strings: {
|
||||
addMoreFiles: '添加更多文件',
|
||||
addingMoreFiles: '正在添加更多文件',
|
||||
allowedFileTypes: '允许的文件类型:%{types}',
|
||||
cancel: '取消',
|
||||
closeModal: '关闭',
|
||||
complete: '完成',
|
||||
connectedToInternet: '已连接到互联网',
|
||||
copyLink: '复制链接',
|
||||
copyLinkToClipboardFallback: '复制下方链接',
|
||||
copyLinkToClipboardSuccess: '链接已复制到剪贴板',
|
||||
dashboardTitle: '选择文件',
|
||||
dashboardWindowTitle: '文件选择窗口(按 ESC 关闭)',
|
||||
done: '完成',
|
||||
dropHereOr: '拖放文件到这里或 %{browse}',
|
||||
dropHint: '将文件拖放到此处',
|
||||
dropPasteFiles: '将文件拖放到这里或 %{browseFiles}',
|
||||
dropPasteFolders: '将文件拖放到这里或 %{browseFolders}',
|
||||
dropPasteBoth: '将文件拖放到这里,%{browseFiles} 或 %{browseFolders}',
|
||||
dropPasteImportFiles:
|
||||
'将文件拖放到这里,%{browseFiles} 或从以下位置导入:',
|
||||
dropPasteImportFolders:
|
||||
'将文件拖放到这里,%{browseFolders} 或从以下位置导入:',
|
||||
dropPasteImportBoth:
|
||||
'将文件拖放到这里,%{browseFiles},%{browseFolders} 或从以下位置导入:',
|
||||
editFile: '编辑文件',
|
||||
editing: '正在编辑 %{file}',
|
||||
emptyFolderAdded: '未从空文件夹添加文件',
|
||||
exceedsSize: '%{file} 超过了最大允许大小 %{size}',
|
||||
failedToUpload: '上传 %{file} 失败',
|
||||
fileSource: '文件来源:%{name}',
|
||||
filesUploadedOfTotal: {
|
||||
0: '已上传 %{complete} / %{smart_count} 个文件',
|
||||
1: '已上传 %{complete} / %{smart_count} 个文件',
|
||||
},
|
||||
filter: '筛选',
|
||||
finishEditingFile: '完成编辑文件',
|
||||
folderAdded: {
|
||||
0: '已从 %{folder} 添加 %{smart_count} 个文件',
|
||||
1: '已从 %{folder} 添加 %{smart_count} 个文件',
|
||||
},
|
||||
generatingThumbnails: '正在生成缩略图...',
|
||||
import: '导入',
|
||||
importFiles: '从以下位置导入文件:',
|
||||
importFrom: '从 %{name} 导入',
|
||||
loading: '加载中...',
|
||||
logOut: '登出',
|
||||
myDevice: '我的设备',
|
||||
noFilesFound: '这里没有文件或文件夹',
|
||||
noInternetConnection: '无网络连接',
|
||||
openFolderNamed: '打开文件夹 %{name}',
|
||||
pause: '暂停',
|
||||
pauseUpload: '暂停上传',
|
||||
paused: '已暂停',
|
||||
poweredBy: '技术支持:%{uppy}',
|
||||
processingXFiles: {
|
||||
0: '正在处理 %{smart_count} 个文件',
|
||||
1: '正在处理 %{smart_count} 个文件',
|
||||
},
|
||||
recording: '录制中',
|
||||
removeFile: '移除文件',
|
||||
resetFilter: '重置筛选',
|
||||
resume: '继续',
|
||||
resumeUpload: '继续上传',
|
||||
retry: '重试',
|
||||
retryUpload: '重试上传',
|
||||
save: '保存',
|
||||
saveChanges: '保存更改',
|
||||
selectFileNamed: '选择文件 %{name}',
|
||||
selectX: {
|
||||
0: '选择 %{smart_count}',
|
||||
1: '选择 %{smart_count}',
|
||||
},
|
||||
smile: '笑一个!',
|
||||
startRecording: '开始录制视频',
|
||||
stopRecording: '停止录制视频',
|
||||
takePicture: '拍照',
|
||||
timedOut: '上传已停滞 %{seconds} 秒,正在中止。',
|
||||
upload: '下一步',
|
||||
uploadComplete: '上传完成',
|
||||
uploadFailed: '上传失败',
|
||||
uploadPaused: '上传已暂停',
|
||||
uploadXFiles: {
|
||||
0: '下一步(%{smart_count} 个文件)',
|
||||
1: '下一步(%{smart_count} 个文件)',
|
||||
},
|
||||
uploadXNewFiles: {
|
||||
0: '下一步(+%{smart_count} 个文件)',
|
||||
1: '下一步(+%{smart_count} 个文件)',
|
||||
},
|
||||
uploading: '正在上传',
|
||||
uploadingXFiles: {
|
||||
0: '正在上传 %{smart_count} 个文件',
|
||||
1: '正在上传 %{smart_count} 个文件',
|
||||
},
|
||||
xFilesSelected: {
|
||||
0: '已选择 %{smart_count} 个文件',
|
||||
1: '已选择 %{smart_count} 个文件',
|
||||
},
|
||||
xMoreFilesAdded: {
|
||||
0: '又添加了 %{smart_count} 个文件',
|
||||
1: '又添加了 %{smart_count} 个文件',
|
||||
},
|
||||
xTimeLeft: '剩余 %{time}',
|
||||
youCanOnlyUploadFileTypes: '您只能上传:%{types}',
|
||||
youCanOnlyUploadX: {
|
||||
0: '您只能上传 %{smart_count} 个文件',
|
||||
1: '您只能上传 %{smart_count} 个文件',
|
||||
},
|
||||
youHaveToAtLeastSelectX: {
|
||||
0: '您至少需要选择 %{smart_count} 个文件',
|
||||
1: '您至少需要选择 %{smart_count} 个文件',
|
||||
},
|
||||
browseFiles: '浏览文件',
|
||||
browseFolders: '浏览文件夹',
|
||||
cancelUpload: '取消上传',
|
||||
addMore: '添加更多',
|
||||
back: '返回',
|
||||
editFileWithFilename: '编辑文件 %{file}',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return uppyInstance
|
||||
}, [])
|
||||
|
||||
// 处理"下一步"按钮点击 - 进入编辑阶段
|
||||
useEffect(() => {
|
||||
const handleUpload = () => {
|
||||
const files = uppy.getFiles()
|
||||
if (files.length === 0) return
|
||||
|
||||
// 将选择的文件转换为我们的数据结构
|
||||
const fileInfos: UploadedFileInfo[] = files.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
previewUrl: file.preview || URL.createObjectURL(file.data as File),
|
||||
emotion: '',
|
||||
description: '',
|
||||
isRegistered: true,
|
||||
file: file.data as File,
|
||||
}))
|
||||
|
||||
setUploadedFiles(fileInfos)
|
||||
|
||||
// 根据文件数量决定进入哪个步骤
|
||||
if (files.length === 1) {
|
||||
setSelectedFileId(fileInfos[0].id)
|
||||
setStep('edit-single')
|
||||
} else {
|
||||
setStep('edit-multiple')
|
||||
}
|
||||
}
|
||||
|
||||
uppy.on('upload', handleUpload)
|
||||
return () => {
|
||||
uppy.off('upload', handleUpload)
|
||||
}
|
||||
}, [uppy])
|
||||
|
||||
// 对话框关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
uppy.cancelAll()
|
||||
setStep('select')
|
||||
setUploadedFiles([])
|
||||
setSelectedFileId(null)
|
||||
setUploading(false)
|
||||
}
|
||||
}, [open, uppy])
|
||||
|
||||
// 更新单个文件的元数据
|
||||
const updateFileInfo = useCallback(
|
||||
(fileId: string, updates: Partial<UploadedFileInfo>) => {
|
||||
setUploadedFiles((prev) =>
|
||||
prev.map((f) => (f.id === fileId ? { ...f, ...updates } : f))
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// 检查文件是否填写完成必填项(情感标签必填)
|
||||
const isFileComplete = useCallback((file: UploadedFileInfo) => {
|
||||
return file.emotion.trim().length > 0
|
||||
}, [])
|
||||
|
||||
// 检查所有文件是否都填写完成
|
||||
const allFilesComplete = useMemo(() => {
|
||||
return uploadedFiles.length > 0 && uploadedFiles.every(isFileComplete)
|
||||
}, [uploadedFiles, isFileComplete])
|
||||
|
||||
// 获取当前选中的文件
|
||||
const selectedFile = useMemo(() => {
|
||||
return uploadedFiles.find((f) => f.id === selectedFileId) || null
|
||||
}, [uploadedFiles, selectedFileId])
|
||||
|
||||
// 返回上一步
|
||||
const handleBack = useCallback(() => {
|
||||
if (step === 'edit-single' || step === 'edit-multiple') {
|
||||
setStep('select')
|
||||
setUploadedFiles([])
|
||||
setSelectedFileId(null)
|
||||
}
|
||||
}, [step])
|
||||
|
||||
// 执行实际上传
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!allFilesComplete) {
|
||||
toast({
|
||||
title: '请填写必填项',
|
||||
description: '每个表情包的情感标签都是必填的',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
try {
|
||||
for (const fileInfo of uploadedFiles) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileInfo.file)
|
||||
formData.append('emotion', fileInfo.emotion)
|
||||
formData.append('description', fileInfo.description)
|
||||
formData.append('is_registered', fileInfo.isRegistered.toString())
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(getEmojiUploadUrl(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
successCount++
|
||||
} else {
|
||||
failedCount++
|
||||
}
|
||||
} catch {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
toast({
|
||||
title: '上传成功',
|
||||
description: `成功上传 ${successCount} 个表情包`,
|
||||
})
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
} else {
|
||||
toast({
|
||||
title: '部分上传失败',
|
||||
description: `成功 ${successCount} 个,失败 ${failedCount} 个`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
onSuccess()
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [allFilesComplete, uploadedFiles, toast, onOpenChange, onSuccess])
|
||||
|
||||
// 渲染文件选择步骤
|
||||
const renderSelectStep = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden w-full">
|
||||
<Dashboard
|
||||
uppy={uppy}
|
||||
proudlyDisplayPoweredByUppy={false}
|
||||
hideProgressDetails
|
||||
height={350}
|
||||
width="100%"
|
||||
theme="auto"
|
||||
note="支持 JPG、PNG、GIF、WebP 格式,最多 20 个文件"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染单个文件编辑步骤
|
||||
const renderEditSingleStep = () => {
|
||||
const file = uploadedFiles[0]
|
||||
if (!file) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
返回
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
编辑表情包信息
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* 预览图 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-32 h-32 rounded-lg border overflow-hidden bg-muted flex items-center justify-center">
|
||||
<img
|
||||
src={file.previewUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center truncate max-w-32">
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="single-emotion">
|
||||
情感标签 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="single-emotion"
|
||||
value={file.emotion}
|
||||
onChange={(e) =>
|
||||
updateFileInfo(file.id, { emotion: e.target.value })
|
||||
}
|
||||
placeholder="多个标签用逗号分隔,如:开心,高兴"
|
||||
className={!file.emotion.trim() ? 'border-destructive' : ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用于情感匹配,多个标签用逗号分隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="single-description">描述</Label>
|
||||
<Input
|
||||
id="single-description"
|
||||
value={file.description}
|
||||
onChange={(e) =>
|
||||
updateFileInfo(file.id, { description: e.target.value })
|
||||
}
|
||||
placeholder="输入表情包描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="single-is-registered"
|
||||
checked={file.isRegistered}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFileInfo(file.id, { isRegistered: checked === true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="single-is-registered" className="cursor-pointer">
|
||||
上传后立即注册(可被麦麦使用)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allFilesComplete || uploading}
|
||||
>
|
||||
{uploading ? '上传中...' : '上传'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染多个文件编辑步骤
|
||||
const renderEditMultipleStep = () => {
|
||||
const completedCount = uploadedFiles.filter(isFileComplete).length
|
||||
const totalCount = uploadedFiles.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
返回
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
编辑表情包信息({completedCount}/{totalCount} 已完成)
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={allFilesComplete ? 'default' : 'secondary'}>
|
||||
{allFilesComplete ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
全部完成
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
未完成
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 左侧:文件卡片列表 */}
|
||||
<ScrollArea className="h-[350px] pr-2">
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.map((file) => {
|
||||
const complete = isFileComplete(file)
|
||||
const isSelected = selectedFileId === file.id
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedFileId(file.id)}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${complete ? 'border-green-500 bg-green-50 dark:bg-green-950/20' : 'border-border hover:border-muted-foreground/50'}
|
||||
`}
|
||||
>
|
||||
<div className="w-12 h-12 rounded border overflow-hidden bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={file.previewUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{file.emotion || '未填写情感标签'}
|
||||
</p>
|
||||
</div>
|
||||
{complete ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-muted-foreground/30 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 右侧:选中文件的编辑表单 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 rounded border overflow-hidden bg-muted flex items-center justify-center">
|
||||
<img
|
||||
src={selectedFile.previewUrl}
|
||||
alt={selectedFile.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{selectedFile.name}</p>
|
||||
{isFileComplete(selectedFile) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-green-600 border-green-600"
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
已完成
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="multi-emotion">
|
||||
情感标签 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="multi-emotion"
|
||||
value={selectedFile.emotion}
|
||||
onChange={(e) =>
|
||||
updateFileInfo(selectedFile.id, {
|
||||
emotion: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="多个标签用逗号分隔,如:开心,高兴"
|
||||
className={
|
||||
!selectedFile.emotion.trim() ? 'border-destructive' : ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="multi-description">描述</Label>
|
||||
<Input
|
||||
id="multi-description"
|
||||
value={selectedFile.description}
|
||||
onChange={(e) =>
|
||||
updateFileInfo(selectedFile.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="输入表情包描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="multi-is-registered"
|
||||
checked={selectedFile.isRegistered}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFileInfo(selectedFile.id, {
|
||||
isRegistered: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="multi-is-registered"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
上传后立即注册
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<ImageIcon className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>点击左侧卡片编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allFilesComplete || uploading}
|
||||
>
|
||||
{uploading ? '上传中...' : `上传全部 (${totalCount})`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
{step === 'select' && '上传表情包 - 选择文件'}
|
||||
{step === 'edit-single' && '上传表情包 - 填写信息'}
|
||||
{step === 'edit-multiple' && '上传表情包 - 批量编辑'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'select' &&
|
||||
'支持 JPG、PNG、GIF、WebP 格式,单个文件最大 10MB,可同时上传多个文件'}
|
||||
{step === 'edit-single' && '请填写表情包的情感标签(必填)和描述'}
|
||||
{step === 'edit-multiple' &&
|
||||
'点击左侧卡片编辑每个表情包的信息,情感标签为必填项'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto pr-1">
|
||||
{step === 'select' && renderSelectStep()}
|
||||
{step === 'edit-single' && renderEditSingleStep()}
|
||||
{step === 'edit-multiple' && renderEditMultipleStep()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
302
dashboard/src/routes/resource/emoji/EmojiList.tsx
Normal file
302
dashboard/src/routes/resource/emoji/EmojiList.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Ban, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit, Info, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EmojiThumbnail } from '@/components/emoji-thumbnail'
|
||||
|
||||
import { getEmojiThumbnailUrl } from '@/lib/emoji-api'
|
||||
import type { Emoji } from '@/types/emoji'
|
||||
|
||||
interface EmojiListProps {
|
||||
emojiList: Emoji[]
|
||||
loading: boolean
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
selectedIds: Set<number>
|
||||
cardSize: 'small' | 'medium' | 'large'
|
||||
jumpToPage: string
|
||||
onPageChange: (page: number) => void
|
||||
onJumpToPage: () => void
|
||||
onJumpToPageChange: (value: string) => void
|
||||
onToggleSelect: (id: number) => void
|
||||
onEdit: (emoji: Emoji) => void
|
||||
onViewDetail: (emoji: Emoji) => void
|
||||
onRegister: (emoji: Emoji) => void
|
||||
onBan: (emoji: Emoji) => void
|
||||
onDelete: (emoji: Emoji) => void
|
||||
}
|
||||
|
||||
export function EmojiList({
|
||||
emojiList,
|
||||
// loading,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
selectedIds,
|
||||
cardSize,
|
||||
jumpToPage,
|
||||
onPageChange,
|
||||
onJumpToPage,
|
||||
onJumpToPageChange,
|
||||
onToggleSelect,
|
||||
onEdit,
|
||||
onViewDetail,
|
||||
onRegister,
|
||||
onBan,
|
||||
onDelete,
|
||||
}: EmojiListProps) {
|
||||
// 空状态
|
||||
if (emojiList.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 卡片网格视图
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`grid gap-3 ${
|
||||
cardSize === 'small'
|
||||
? 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10'
|
||||
: cardSize === 'medium'
|
||||
? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8'
|
||||
: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
|
||||
}`}
|
||||
>
|
||||
{emojiList.map((emoji) => (
|
||||
<div
|
||||
key={emoji.id}
|
||||
className={`group relative rounded-lg border bg-card overflow-hidden hover:ring-2 hover:ring-primary transition-all cursor-pointer ${
|
||||
selectedIds.has(emoji.id)
|
||||
? 'ring-2 ring-primary bg-primary/5'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => onToggleSelect(emoji.id)}
|
||||
>
|
||||
{/* 选中指示器 */}
|
||||
<div
|
||||
className={`absolute top-1 left-1 z-10 transition-opacity ${
|
||||
selectedIds.has(emoji.id)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedIds.has(emoji.id)
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-background/80 border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
{selectedIds.has(emoji.id) && (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="absolute top-1 right-1 z-10 flex flex-col gap-0.5">
|
||||
{emoji.is_registered && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-green-600 text-[10px] px-1 py-0"
|
||||
>
|
||||
已注册
|
||||
</Badge>
|
||||
)}
|
||||
{emoji.is_banned && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1 py-0">
|
||||
已封禁
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
<div
|
||||
className={`aspect-square bg-muted flex items-center justify-center overflow-hidden ${
|
||||
cardSize === 'small'
|
||||
? 'p-1'
|
||||
: cardSize === 'medium'
|
||||
? 'p-2'
|
||||
: 'p-3'
|
||||
}`}
|
||||
>
|
||||
<EmojiThumbnail
|
||||
src={getEmojiThumbnailUrl(emoji.id)}
|
||||
alt="表情包"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部信息和操作 */}
|
||||
<div
|
||||
className={`border-t bg-card ${cardSize === 'small' ? 'p-1' : 'p-2'}`}
|
||||
>
|
||||
{/* 使用次数和格式 */}
|
||||
<div className="flex items-center justify-between gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||
{emoji.format.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="font-mono">{emoji.usage_count}次</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 - 悬停时显示 */}
|
||||
<div
|
||||
className={`flex gap-1 justify-center opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
cardSize === 'small' ? 'flex-wrap' : ''
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit(emoji)
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onViewDetail(emoji)
|
||||
}}
|
||||
title="详情"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</Button>
|
||||
{!emoji.is_registered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-600 hover:text-green-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRegister(emoji)
|
||||
}}
|
||||
title="注册"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{!emoji.is_banned && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-orange-600 hover:text-orange-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onBan(emoji)
|
||||
}}
|
||||
title="封禁"
|
||||
>
|
||||
<Ban className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(emoji)
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 - 增强版 */}
|
||||
{total > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {(page - 1) * pageSize + 1} 到{' '}
|
||||
{Math.min(page * pageSize, total)} 条,共 {total} 条
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 首页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 上一页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
|
||||
{/* 页码跳转 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={jumpToPage}
|
||||
onChange={(e) => onJumpToPageChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onJumpToPage()}
|
||||
placeholder={page.toString()}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
max={Math.ceil(total / pageSize)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onJumpToPage}
|
||||
disabled={!jumpToPage}
|
||||
className="h-8"
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 下一页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
|
||||
{/* 末页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.ceil(total / pageSize))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
dashboard/src/routes/resource/emoji/index.ts
Normal file
1
dashboard/src/routes/resource/emoji/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EmojiManagementPage } from './index.tsx'
|
||||
651
dashboard/src/routes/resource/emoji/index.tsx
Normal file
651
dashboard/src/routes/resource/emoji/index.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Filter, RefreshCw, Trash2, Upload } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
// import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
banEmoji,
|
||||
batchDeleteEmojis,
|
||||
deleteEmoji,
|
||||
getEmojiList,
|
||||
getEmojiStats,
|
||||
registerEmoji,
|
||||
} from '@/lib/emoji-api'
|
||||
import type { Emoji, EmojiStats } from '@/types/emoji'
|
||||
|
||||
import {
|
||||
EmojiDetailDialog,
|
||||
EmojiEditDialog,
|
||||
EmojiUploadDialog,
|
||||
} from './EmojiDialogs'
|
||||
import { EmojiList } from './EmojiList'
|
||||
|
||||
export function EmojiManagementPage() {
|
||||
const [emojiList, setEmojiList] = useState<Emoji[]>([])
|
||||
const [stats, setStats] = useState<EmojiStats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [registeredFilter, setRegisteredFilter] = useState<string>('all')
|
||||
const [bannedFilter, setBannedFilter] = useState<string>('all')
|
||||
const [formatFilter, setFormatFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<string>('usage_count')
|
||||
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc')
|
||||
const [selectedEmoji, setSelectedEmoji] = useState<Emoji | null>(null)
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
const [cardSize, setCardSize] = useState<'small' | 'medium' | 'large'>(
|
||||
'medium'
|
||||
)
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// 加载表情包列表
|
||||
const loadEmojiList = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getEmojiList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
is_registered:
|
||||
registeredFilter === 'all'
|
||||
? undefined
|
||||
: registeredFilter === 'registered',
|
||||
is_banned:
|
||||
bannedFilter === 'all' ? undefined : bannedFilter === 'banned',
|
||||
format: formatFilter === 'all' ? undefined : formatFilter,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
setEmojiList(response.data)
|
||||
setTotal(response.total)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '加载表情包列表失败'
|
||||
toast({
|
||||
title: '错误',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
page,
|
||||
pageSize,
|
||||
registeredFilter,
|
||||
bannedFilter,
|
||||
formatFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
toast,
|
||||
])
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await getEmojiStats()
|
||||
setStats(response.data)
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadEmojiList()
|
||||
}, [loadEmojiList])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (emoji: Emoji) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setDetailDialogOpen(true)
|
||||
}
|
||||
|
||||
// 编辑表情包
|
||||
const handleEdit = (emoji: Emoji) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
// 删除表情包
|
||||
const handleDelete = (emoji: Emoji) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedEmoji) return
|
||||
|
||||
try {
|
||||
await deleteEmoji(selectedEmoji.id)
|
||||
toast({
|
||||
title: '成功',
|
||||
description: '表情包已删除',
|
||||
})
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedEmoji(null)
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败'
|
||||
toast({
|
||||
title: '错误',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 快速注册
|
||||
const handleRegister = async (emoji: Emoji) => {
|
||||
try {
|
||||
await registerEmoji(emoji.id)
|
||||
toast({
|
||||
title: '成功',
|
||||
description: '表情包已注册',
|
||||
})
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '注册失败'
|
||||
toast({
|
||||
title: '错误',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 快速封禁
|
||||
const handleBan = async (emoji: Emoji) => {
|
||||
try {
|
||||
await banEmoji(emoji.id)
|
||||
toast({
|
||||
title: '成功',
|
||||
description: '表情包已封禁',
|
||||
})
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '封禁失败'
|
||||
toast({
|
||||
title: '错误',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSelected = new Set(selectedIds)
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id)
|
||||
} else {
|
||||
newSelected.add(id)
|
||||
}
|
||||
setSelectedIds(newSelected)
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
const result = await batchDeleteEmojis(Array.from(selectedIds))
|
||||
toast({
|
||||
title: '批量删除完成',
|
||||
description: result.message,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
setBatchDeleteDialogOpen(false)
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '批量删除失败',
|
||||
description:
|
||||
error instanceof Error ? error.message : '批量删除失败',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 页面跳转
|
||||
const handleJumpToPage = () => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
setJumpToPage('')
|
||||
} else {
|
||||
toast({
|
||||
title: '无效的页码',
|
||||
description: `请输入1-${totalPages}之间的页码`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取格式选项
|
||||
const formatOptions = stats?.formats ? Object.keys(stats.formats) : []
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">表情包管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理麦麦的表情包资源
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传表情包
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 sm:space-y-6 pr-4">
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>总数</CardDescription>
|
||||
<CardTitle className="text-2xl">{stats.total}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>已注册</CardDescription>
|
||||
<CardTitle className="text-2xl text-green-600">
|
||||
{stats.registered}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>已封禁</CardDescription>
|
||||
<CardTitle className="text-2xl text-red-600">
|
||||
{stats.banned}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>未注册</CardDescription>
|
||||
<CardTitle className="text-2xl text-gray-600">
|
||||
{stats.unregistered}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选和排序 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
筛选和排序
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>排序方式</Label>
|
||||
<Select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onValueChange={(value) => {
|
||||
const [newSortBy, newSortOrder] = value.split('-')
|
||||
setSortBy(newSortBy)
|
||||
setSortOrder(newSortOrder as 'desc' | 'asc')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="usage_count-desc">
|
||||
使用次数 (多→少)
|
||||
</SelectItem>
|
||||
<SelectItem value="usage_count-asc">
|
||||
使用次数 (少→多)
|
||||
</SelectItem>
|
||||
<SelectItem value="register_time-desc">
|
||||
注册时间 (新→旧)
|
||||
</SelectItem>
|
||||
<SelectItem value="register_time-asc">
|
||||
注册时间 (旧→新)
|
||||
</SelectItem>
|
||||
<SelectItem value="record_time-desc">
|
||||
记录时间 (新→旧)
|
||||
</SelectItem>
|
||||
<SelectItem value="record_time-asc">
|
||||
记录时间 (旧→新)
|
||||
</SelectItem>
|
||||
<SelectItem value="last_used_time-desc">
|
||||
最后使用 (新→旧)
|
||||
</SelectItem>
|
||||
<SelectItem value="last_used_time-asc">
|
||||
最后使用 (旧→新)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>注册状态</Label>
|
||||
<Select
|
||||
value={registeredFilter}
|
||||
onValueChange={(value) => {
|
||||
setRegisteredFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="registered">已注册</SelectItem>
|
||||
<SelectItem value="unregistered">未注册</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>封禁状态</Label>
|
||||
<Select
|
||||
value={bannedFilter}
|
||||
onValueChange={(value) => {
|
||||
setBannedFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="banned">已封禁</SelectItem>
|
||||
<SelectItem value="unbanned">未封禁</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>格式</Label>
|
||||
<Select
|
||||
value={formatFilter}
|
||||
onValueChange={(value) => {
|
||||
setFormatFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{formatOptions.map((format) => (
|
||||
<SelectItem key={format} value={format}>
|
||||
{format.toUpperCase()} ({stats?.formats[format]})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 border-t">
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
已选择 {selectedIds.size} 个表情包
|
||||
</span>
|
||||
)}
|
||||
{/* 卡片尺寸切换 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm whitespace-nowrap">
|
||||
卡片大小
|
||||
</Label>
|
||||
<Select
|
||||
value={cardSize}
|
||||
onValueChange={(
|
||||
value: 'small' | 'medium' | 'large'
|
||||
) => setCardSize(value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">小</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="large">大</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="emoji-page-size"
|
||||
className="text-sm whitespace-nowrap"
|
||||
>
|
||||
每页显示
|
||||
</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value))
|
||||
setPage(1)
|
||||
setSelectedIds(new Set())
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="emoji-page-size" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="40">40</SelectItem>
|
||||
<SelectItem value="60">60</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBatchDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadEmojiList}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 表情包卡片列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>表情包列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 个表情包,当前第 {page} 页
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmojiList
|
||||
emojiList={emojiList}
|
||||
loading={loading}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
selectedIds={selectedIds}
|
||||
cardSize={cardSize}
|
||||
jumpToPage={jumpToPage}
|
||||
onPageChange={setPage}
|
||||
onJumpToPage={handleJumpToPage}
|
||||
onJumpToPageChange={setJumpToPage}
|
||||
onToggleSelect={toggleSelect}
|
||||
onEdit={handleEdit}
|
||||
onViewDetail={handleViewDetail}
|
||||
onRegister={handleRegister}
|
||||
onBan={handleBan}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<EmojiDetailDialog
|
||||
emoji={selectedEmoji}
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<EmojiEditDialog
|
||||
emoji={selectedEmoji}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
onSuccess={() => {
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 上传对话框 */}
|
||||
<EmojiUploadDialog
|
||||
open={uploadDialogOpen}
|
||||
onOpenChange={setUploadDialogOpen}
|
||||
onSuccess={() => {
|
||||
loadEmojiList()
|
||||
loadStats()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 批量删除确认对话框 */}
|
||||
<AlertDialog
|
||||
open={batchDeleteDialogOpen}
|
||||
onOpenChange={setBatchDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
你确定要删除选中的 {selectedIds.size}{' '}
|
||||
个表情包吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBatchDelete}>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除这个表情包吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
dashboard/src/routes/resource/emoji/types.ts
Normal file
13
dashboard/src/routes/resource/emoji/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// 上传文件的元数据类型
|
||||
export interface UploadedFileInfo {
|
||||
id: string
|
||||
name: string
|
||||
previewUrl: string
|
||||
emotion: string
|
||||
description: string
|
||||
isRegistered: boolean
|
||||
file: File
|
||||
}
|
||||
|
||||
// 上传步骤类型
|
||||
export type UploadStep = 'select' | 'edit-single' | 'edit-multiple'
|
||||
File diff suppressed because it is too large
Load Diff
561
dashboard/src/routes/resource/expression/ExpressionDialogs.tsx
Normal file
561
dashboard/src/routes/resource/expression/ExpressionDialogs.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import { CheckCircle2, Circle, Clock, Hash, Info, XCircle } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { createExpression, updateExpression } from '@/lib/expression-api'
|
||||
|
||||
import type { Expression, ExpressionCreateRequest, ExpressionUpdateRequest, ChatInfo } from '@/types/expression'
|
||||
|
||||
/**
|
||||
* 表达方式详情对话框
|
||||
*/
|
||||
export function ExpressionDetailDialog({
|
||||
expression,
|
||||
open,
|
||||
onOpenChange,
|
||||
chatNameMap,
|
||||
}: {
|
||||
expression: Expression | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chatNameMap: Map<string, string>
|
||||
}) {
|
||||
if (!expression) return null
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>表达方式详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看表达方式的完整信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="情境" value={expression.situation} />
|
||||
<InfoItem label="风格" value={expression.style} />
|
||||
<InfoItem
|
||||
label="聊天"
|
||||
value={getChatName(expression.chat_id)}
|
||||
/>
|
||||
<InfoItem icon={Hash} label="记录ID" value={expression.id.toString()} mono />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem icon={Clock} label="创建时间" value={formatTime(expression.create_date)} />
|
||||
</div>
|
||||
|
||||
{/* 状态标记 */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<Label className="text-xs text-muted-foreground mb-3 block">状态标记</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full",
|
||||
expression.checked ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" : "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600"
|
||||
)}>
|
||||
{expression.checked ? (
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">已检查</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{expression.checked ? "已通过审核" : "未审核"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full",
|
||||
expression.rejected ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" : "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600"
|
||||
)}>
|
||||
{expression.rejected ? (
|
||||
<XCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">已拒绝</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{expression.rejected ? "不会被使用" : "正常"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息项组件
|
||||
*/
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
icon?: typeof Hash
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{label}
|
||||
</Label>
|
||||
<div className={cn('text-sm', mono && 'font-mono', !value && 'text-muted-foreground')}>
|
||||
{value || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达方式创建对话框
|
||||
*/
|
||||
export function ExpressionCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chatList,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chatList: ChatInfo[]
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ExpressionCreateRequest>({
|
||||
situation: '',
|
||||
style: '',
|
||||
chat_id: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.situation || !formData.style || !formData.chat_id) {
|
||||
toast({
|
||||
title: '验证失败',
|
||||
description: '请填写必填字段:情境、风格和聚天',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const result = await createExpression(formData)
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '创建成功',
|
||||
description: '表达方式已创建',
|
||||
})
|
||||
setFormData({
|
||||
situation: '',
|
||||
style: '',
|
||||
chat_id: '',
|
||||
})
|
||||
onSuccess()
|
||||
} else {
|
||||
toast({
|
||||
title: '创建失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '创建失败',
|
||||
description: error instanceof Error ? error.message : '无法创建表达方式',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增表达方式</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新的表达方式记录
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="situation">
|
||||
情境 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="situation"
|
||||
value={formData.situation}
|
||||
onChange={(e) => setFormData({ ...formData, situation: e.target.value })}
|
||||
placeholder="描述使用场景"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="style">
|
||||
风格 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="style"
|
||||
value={formData.style}
|
||||
onChange={(e) => setFormData({ ...formData, style: e.target.value })}
|
||||
placeholder="描述表达风格"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat_id">
|
||||
聊天 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.chat_id}
|
||||
onValueChange={(value) => setFormData({ ...formData, chat_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择关联的聊天" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chatList.map((chat) => (
|
||||
<SelectItem key={chat.chat_id} value={chat.chat_id}>
|
||||
<span className="truncate" style={{ wordBreak: 'keep-all' }}>
|
||||
{chat.chat_name}
|
||||
{chat.is_group && <span className="text-muted-foreground ml-1">(群聊)</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达方式编辑对话框
|
||||
*/
|
||||
export function ExpressionEditDialog({
|
||||
expression,
|
||||
open,
|
||||
onOpenChange,
|
||||
chatList,
|
||||
onSuccess,
|
||||
}: {
|
||||
expression: Expression | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chatList: ChatInfo[]
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ExpressionUpdateRequest>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (expression) {
|
||||
setFormData({
|
||||
situation: expression.situation,
|
||||
style: expression.style,
|
||||
chat_id: expression.chat_id,
|
||||
checked: expression.checked,
|
||||
rejected: expression.rejected,
|
||||
})
|
||||
}
|
||||
}, [expression])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!expression) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const result = await updateExpression(expression.id, formData)
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '表达方式已更新',
|
||||
})
|
||||
onSuccess()
|
||||
} else {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: error instanceof Error ? error.message : '无法更新表达方式',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!expression) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑表达方式</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改表达方式的信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_situation">情境</Label>
|
||||
<Input
|
||||
id="edit_situation"
|
||||
value={formData.situation || ''}
|
||||
onChange={(e) => setFormData({ ...formData, situation: e.target.value })}
|
||||
placeholder="描述使用场景"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_style">风格</Label>
|
||||
<Input
|
||||
id="edit_style"
|
||||
value={formData.style || ''}
|
||||
onChange={(e) => setFormData({ ...formData, style: e.target.value })}
|
||||
placeholder="描述表达风格"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_chat_id">聊天</Label>
|
||||
<Select
|
||||
value={formData.chat_id || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, chat_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择关联的聊天" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chatList.map((chat) => (
|
||||
<SelectItem key={chat.chat_id} value={chat.chat_id}>
|
||||
<span className="truncate" style={{ wordBreak: 'keep-all' }}>
|
||||
{chat.chat_name}
|
||||
{chat.is_group && <span className="text-muted-foreground ml-1">(群聊)</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 状态标记 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
<div className="space-y-1">
|
||||
<p><strong>状态标记说明:</strong></p>
|
||||
<p>• 已检查:表示该表达方式已通过审核(可由AI自动检查或人工审核)</p>
|
||||
<p>• 已拒绝:表示该表达方式被标记为不合适,将永远不会被使用</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
根据配置中"仅使用已审核通过的表达方式"设置:<br/>
|
||||
• 开启时:只有通过审核(已检查)的项目会被使用<br/>
|
||||
• 关闭时:未审核的项目也会被使用
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="edit_checked" className="text-sm font-medium">
|
||||
已检查
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已通过审核
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit_checked"
|
||||
checked={formData.checked ?? false}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="edit_rejected" className="text-sm font-medium">
|
||||
已拒绝
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
不会被使用
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit_rejected"
|
||||
checked={formData.rejected ?? false}
|
||||
onCheckedChange={(rejected) => setFormData({ ...formData, rejected })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除确认对话框
|
||||
*/
|
||||
export function BatchDeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
count,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您即将删除 {count} 个表达方式,此操作无法撤销。确定要继续吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个删除确认对话框
|
||||
*/
|
||||
export function DeleteConfirmDialog({
|
||||
expression,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
expression: Expression | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除表达方式 "{expression?.situation}" 吗?
|
||||
此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
361
dashboard/src/routes/resource/expression/ExpressionList.tsx
Normal file
361
dashboard/src/routes/resource/expression/ExpressionList.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit, Eye, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
import type { Expression } from '@/types/expression'
|
||||
|
||||
/**
|
||||
* 表达方式列表组件(桌面端Table + 移动端Card视图 + 分页)
|
||||
*/
|
||||
export function ExpressionList({
|
||||
expressions,
|
||||
loading,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
selectedIds,
|
||||
chatNameMap,
|
||||
onEdit,
|
||||
onViewDetail,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onPageChange,
|
||||
onJumpToPage,
|
||||
}: {
|
||||
expressions: Expression[]
|
||||
loading: boolean
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
selectedIds: Set<number>
|
||||
chatNameMap: Map<string, string>
|
||||
onEdit: (expression: Expression) => void
|
||||
onViewDetail: (expression: Expression) => void
|
||||
onDelete: (expression: Expression) => void
|
||||
onToggleSelect: (id: number) => void
|
||||
onToggleSelectAll: () => void
|
||||
onPageChange: (newPage: number) => void
|
||||
onJumpToPage: (targetPage: string) => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const getChatName = (chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const handleJumpToPage = (jumpToPage: string) => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
if (targetPage >= 1 && targetPage <= totalPages) {
|
||||
onJumpToPage(jumpToPage)
|
||||
} else {
|
||||
toast({
|
||||
title: '无效的页码',
|
||||
description: `请输入1-${totalPages}之间的页码`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === expressions.length && expressions.length > 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>情境</TableHead>
|
||||
<TableHead>风格</TableHead>
|
||||
<TableHead>聊天</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : expressions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
expressions.map((expression) => (
|
||||
<TableRow key={expression.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(expression.id)}
|
||||
onCheckedChange={() => onToggleSelect(expression.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-xs truncate">
|
||||
{expression.situation}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{expression.style}</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[200px] truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis block">
|
||||
{getChatName(expression.chat_id)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(expression)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewDetail(expression)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(expression)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片视图 */}
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
) : expressions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
expressions.map((expression) => (
|
||||
<div key={expression.id} className="rounded-lg border bg-card p-4 space-y-3 overflow-hidden">
|
||||
{/* 复选框和情境 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(expression.id)}
|
||||
onCheckedChange={() => onToggleSelect(expression.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-hidden space-y-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">情境</div>
|
||||
<h3 className="font-semibold text-sm line-clamp-2 w-full break-all" title={expression.situation}>
|
||||
{expression.situation}
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">风格</div>
|
||||
<p className="text-sm line-clamp-2 w-full break-all" title={expression.style}>
|
||||
{expression.style}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天名称 */}
|
||||
<div className="text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">聊天</div>
|
||||
<p
|
||||
className="text-sm truncate"
|
||||
title={getChatName(expression.chat_id)}
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
{getChatName(expression.chat_id)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-wrap gap-1 pt-2 border-t overflow-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(expression)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0"
|
||||
>
|
||||
<Edit className="h-3 w-3 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetail(expression)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(expression)}
|
||||
className="text-xs px-2 py-1 h-auto flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={onPageChange}
|
||||
onJumpToPage={handleJumpToPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页组件
|
||||
*/
|
||||
function Pagination({
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onJumpToPage,
|
||||
}: {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
onPageChange: (newPage: number) => void
|
||||
onJumpToPage: (targetPage: string) => void
|
||||
}) {
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const handleJump = () => {
|
||||
if (jumpToPage) {
|
||||
onJumpToPage(jumpToPage)
|
||||
setJumpToPage('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录,第 {page} / {totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 首页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 上一页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
|
||||
{/* 页码跳转 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={jumpToPage}
|
||||
onChange={(e) => setJumpToPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJump()}
|
||||
placeholder={page.toString()}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleJump}
|
||||
disabled={!jumpToPage}
|
||||
className="h-8"
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 下一页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
|
||||
{/* 末页 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1
dashboard/src/routes/resource/expression/index.ts
Normal file
1
dashboard/src/routes/resource/expression/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ExpressionManagementPage } from './index.tsx'
|
||||
467
dashboard/src/routes/resource/expression/index.tsx
Normal file
467
dashboard/src/routes/resource/expression/index.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { ClipboardCheck, MessageSquare, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExpressionReviewer } from '@/components/expression-reviewer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
import {
|
||||
batchDeleteExpressions,
|
||||
deleteExpression,
|
||||
getChatList,
|
||||
getExpressionDetail,
|
||||
getExpressionList,
|
||||
getExpressionStats,
|
||||
getReviewStats,
|
||||
} from '@/lib/expression-api'
|
||||
|
||||
import {
|
||||
BatchDeleteConfirmDialog,
|
||||
DeleteConfirmDialog,
|
||||
ExpressionCreateDialog,
|
||||
ExpressionDetailDialog,
|
||||
ExpressionEditDialog,
|
||||
} from './ExpressionDialogs'
|
||||
import { ExpressionList } from './ExpressionList'
|
||||
|
||||
import type { ChatInfo, Expression } from '@/types/expression'
|
||||
import type { StatsData } from './types'
|
||||
|
||||
/**
|
||||
* 表达方式管理主页面
|
||||
*/
|
||||
export function ExpressionManagementPage() {
|
||||
const [expressions, setExpressions] = useState<Expression[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedExpression, setSelectedExpression] = useState<Expression | null>(null)
|
||||
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [deleteConfirmExpression, setDeleteConfirmExpression] = useState<Expression | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [isBatchDeleteDialogOpen, setIsBatchDeleteDialogOpen] = useState(false)
|
||||
const [stats, setStats] = useState<StatsData>({ total: 0, recent_7days: 0, chat_count: 0, top_chats: {} })
|
||||
const [chatList, setChatList] = useState<ChatInfo[]>([])
|
||||
const [chatNameMap, setChatNameMap] = useState<Map<string, string>>(new Map())
|
||||
const [isReviewerOpen, setIsReviewerOpen] = useState(false)
|
||||
const [uncheckedCount, setUncheckedCount] = useState(0)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 加载表达方式列表
|
||||
const loadExpressions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getExpressionList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: search || undefined,
|
||||
})
|
||||
if (result.success) {
|
||||
setExpressions(result.data.data)
|
||||
setTotal(result.data.total)
|
||||
} else {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: error instanceof Error ? error.message : '无法加载表达方式',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await getExpressionStats()
|
||||
if (result.success) {
|
||||
setStats(result.data)
|
||||
} else {
|
||||
console.error('加载统计数据失败:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载审核统计
|
||||
const loadReviewStats = async () => {
|
||||
try {
|
||||
const result = await getReviewStats()
|
||||
if (result.success) {
|
||||
setUncheckedCount(result.data.unchecked)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载审核统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聚天列表
|
||||
const loadChatList = async () => {
|
||||
try {
|
||||
const result = await getChatList()
|
||||
if (result.success) {
|
||||
setChatList(result.data)
|
||||
const nameMap = new Map<string, string>()
|
||||
result.data.forEach((chat: ChatInfo) => {
|
||||
nameMap.set(chat.chat_id, chat.chat_name)
|
||||
})
|
||||
setChatNameMap(nameMap)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聚天列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadExpressions()
|
||||
loadReviewStats()
|
||||
loadStats()
|
||||
loadChatList()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, search])
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (expression: Expression) => {
|
||||
try {
|
||||
const result = await getExpressionDetail(expression.id)
|
||||
if (result.success) {
|
||||
setSelectedExpression(result.data)
|
||||
setIsDetailDialogOpen(true)
|
||||
} else {
|
||||
toast({
|
||||
title: '加载详情失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载详情失败',
|
||||
description: error instanceof Error ? error.message : '无法加载表达方式详情',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑表达方式
|
||||
const handleEdit = (expression: Expression) => {
|
||||
setSelectedExpression(expression)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
// 删除表达方式
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmExpression) return
|
||||
try {
|
||||
const result = await deleteExpression(deleteConfirmExpression.id)
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: `已删除表达方式: ${deleteConfirmExpression.situation}`,
|
||||
})
|
||||
setDeleteConfirmExpression(null)
|
||||
loadExpressions()
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: error instanceof Error ? error.message : '无法删除表达方式',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个选择
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSelected = new Set(selectedIds)
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id)
|
||||
} else {
|
||||
newSelected.add(id)
|
||||
}
|
||||
setSelectedIds(newSelected)
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === expressions.length && expressions.length > 0) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(expressions.map(e => e.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
const result = await batchDeleteExpressions(Array.from(selectedIds))
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '批量删除成功',
|
||||
description: `已删除 ${selectedIds.size} 个表达方式`,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
setIsBatchDeleteDialogOpen(false)
|
||||
loadExpressions()
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '批量删除失败',
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '批量删除失败',
|
||||
description: error instanceof Error ? error.message : '无法批量删除表达方式',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 页面跳转
|
||||
const handleJumpToPage = (jumpToPage: string) => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageSquare className="h-8 w-8" strokeWidth={2} />
|
||||
表达方式管理
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦的表达方式和话术模板
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsReviewerOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
人工审核
|
||||
{uncheckedCount > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-orange-500 text-white">
|
||||
{uncheckedCount > 99 ? '99+' : uncheckedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增表达方式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 sm:space-y-6 pr-4">
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">总数量</div>
|
||||
<div className="text-2xl font-bold mt-1">{stats.total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">近7天新增</div>
|
||||
<div className="text-2xl font-bold mt-1 text-green-600">{stats.recent_7days}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">关联聊天数</div>
|
||||
<div className="text-2xl font-bold mt-1 text-blue-600">{stats.chat_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和批量操作 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-1.5">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索情境、风格或上下文..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 && (
|
||||
<span>已选择 {selectedIds.size} 个表达方式</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="page-size" className="text-sm whitespace-nowrap">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value))
|
||||
setPage(1)
|
||||
setSelectedIds(new Set())
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="page-size" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsBatchDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表达方式列表 */}
|
||||
<ExpressionList
|
||||
expressions={expressions}
|
||||
loading={loading}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
selectedIds={selectedIds}
|
||||
chatNameMap={chatNameMap}
|
||||
onEdit={handleEdit}
|
||||
onViewDetail={handleViewDetail}
|
||||
onDelete={(expression) => setDeleteConfirmExpression(expression)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onPageChange={setPage}
|
||||
onJumpToPage={handleJumpToPage}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<ExpressionDetailDialog
|
||||
expression={selectedExpression}
|
||||
open={isDetailDialogOpen}
|
||||
onOpenChange={setIsDetailDialogOpen}
|
||||
chatNameMap={chatNameMap}
|
||||
/>
|
||||
|
||||
{/* 创建对话框 */}
|
||||
<ExpressionCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
chatList={chatList}
|
||||
onSuccess={() => {
|
||||
loadExpressions()
|
||||
loadStats()
|
||||
setIsCreateDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<ExpressionEditDialog
|
||||
expression={selectedExpression}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
chatList={chatList}
|
||||
onSuccess={() => {
|
||||
loadExpressions()
|
||||
loadStats()
|
||||
setIsEditDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
expression={deleteConfirmExpression}
|
||||
open={!!deleteConfirmExpression}
|
||||
onOpenChange={() => setDeleteConfirmExpression(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 批量删除确认对话框 */}
|
||||
<BatchDeleteConfirmDialog
|
||||
open={isBatchDeleteDialogOpen}
|
||||
onOpenChange={setIsBatchDeleteDialogOpen}
|
||||
onConfirm={handleBatchDelete}
|
||||
count={selectedIds.size}
|
||||
/>
|
||||
|
||||
{/* 表达方式审核器 */}
|
||||
<ExpressionReviewer
|
||||
open={isReviewerOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsReviewerOpen(open)
|
||||
if (!open) {
|
||||
loadExpressions()
|
||||
loadStats()
|
||||
loadReviewStats()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
dashboard/src/routes/resource/expression/types.ts
Normal file
47
dashboard/src/routes/resource/expression/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 表达方式管理页面内部类型定义
|
||||
*/
|
||||
|
||||
import type { Expression } from '@/types/expression'
|
||||
|
||||
/**
|
||||
* 删除确认状态
|
||||
*/
|
||||
export interface DeleteConfirmState {
|
||||
expression: Expression | null
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计数据
|
||||
*/
|
||||
export interface StatsData {
|
||||
total: number
|
||||
recent_7days: number
|
||||
chat_count: number
|
||||
top_chats: Record<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面状态
|
||||
*/
|
||||
export interface PageState {
|
||||
expressions: Expression[]
|
||||
loading: boolean
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
search: string
|
||||
selectedExpression: Expression | null
|
||||
isDetailDialogOpen: boolean
|
||||
isEditDialogOpen: boolean
|
||||
isCreateDialogOpen: boolean
|
||||
deleteConfirmExpression: Expression | null
|
||||
selectedIds: Set<number>
|
||||
isBatchDeleteDialogOpen: boolean
|
||||
jumpToPage: string
|
||||
stats: StatsData
|
||||
chatNameMap: Map<string, string>
|
||||
isReviewerOpen: boolean
|
||||
uncheckedCount: number
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
527
dashboard/src/routes/resource/jargon/JargonDialogs.tsx
Normal file
527
dashboard/src/routes/resource/jargon/JargonDialogs.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
import { Hash } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { createJargon, updateJargon } from '@/lib/jargon-api'
|
||||
|
||||
import type { Jargon, JargonChatInfo, JargonCreateRequest, JargonUpdateRequest } from '@/types/jargon'
|
||||
|
||||
// ====================
|
||||
// 信息项组件
|
||||
// ====================
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
icon?: typeof Hash
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{label}
|
||||
</Label>
|
||||
<div className={cn('text-sm', mono && 'font-mono', !value && 'text-muted-foreground')}>
|
||||
{value || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// 黑话详情对话框
|
||||
// ====================
|
||||
interface JargonDetailDialogProps {
|
||||
jargon: Jargon | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function JargonDetailDialog({
|
||||
jargon,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: JargonDetailDialogProps) {
|
||||
if (!jargon) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>黑话详情</DialogTitle>
|
||||
<DialogDescription>查看黑话的完整信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem icon={Hash} label="记录ID" value={jargon.id.toString()} mono />
|
||||
<InfoItem label="使用次数" value={jargon.count.toString()} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">内容</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded break-all whitespace-pre-wrap">{jargon.content}</div>
|
||||
</div>
|
||||
|
||||
{jargon.raw_content && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">原始内容</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded break-all">
|
||||
{(() => {
|
||||
try {
|
||||
const rawArray = JSON.parse(jargon.raw_content)
|
||||
if (Array.isArray(rawArray)) {
|
||||
return rawArray.map((item, index) => (
|
||||
<div key={index}>
|
||||
{index > 0 && <hr className="my-3 border-border" />}
|
||||
<div className="whitespace-pre-wrap">{item}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
return <div className="whitespace-pre-wrap">{jargon.raw_content}</div>
|
||||
} catch {
|
||||
return <div className="whitespace-pre-wrap">{jargon.raw_content}</div>
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">含义</Label>
|
||||
<div className="text-sm p-2 bg-muted rounded break-all">
|
||||
{jargon.meaning ? (
|
||||
<MarkdownRenderer content={jargon.meaning} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="聊天" value={jargon.chat_name || jargon.chat_id} />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">状态</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{jargon.is_jargon === true && <Badge variant="default" className="bg-green-600">是黑话</Badge>}
|
||||
{jargon.is_jargon === false && <Badge variant="secondary">非黑话</Badge>}
|
||||
{jargon.is_jargon === null && <Badge variant="outline">未判定</Badge>}
|
||||
{jargon.is_global && <Badge variant="outline" className="border-blue-500 text-blue-500">全局</Badge>}
|
||||
{jargon.is_complete && <Badge variant="outline" className="border-purple-500 text-purple-500">推断完成</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jargon.inference_with_context && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">上下文推断结果</Label>
|
||||
<div className="p-2 bg-muted rounded break-all whitespace-pre-wrap font-mono text-xs max-h-[200px] overflow-y-auto">{jargon.inference_with_context}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jargon.inference_content_only && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">纯词条推断结果</Label>
|
||||
<div className="p-2 bg-muted rounded break-all whitespace-pre-wrap font-mono text-xs max-h-[200px] overflow-y-auto">{jargon.inference_content_only}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// 黑话创建对话框
|
||||
// ====================
|
||||
interface JargonCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chatList: JargonChatInfo[]
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function JargonCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chatList,
|
||||
onSuccess,
|
||||
}: JargonCreateDialogProps) {
|
||||
const [formData, setFormData] = useState<JargonCreateRequest>({
|
||||
content: '',
|
||||
meaning: '',
|
||||
chat_id: '',
|
||||
is_global: false,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.content || !formData.chat_id) {
|
||||
toast({
|
||||
title: '验证失败',
|
||||
description: '请填写必填字段:内容和聊天',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await createJargon(formData)
|
||||
toast({
|
||||
title: '创建成功',
|
||||
description: '黑话已创建',
|
||||
})
|
||||
setFormData({ content: '', meaning: '', chat_id: '', is_global: false })
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '创建失败',
|
||||
description: error instanceof Error ? error.message : '无法创建黑话',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增黑话</DialogTitle>
|
||||
<DialogDescription>创建新的黑话记录</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">
|
||||
内容 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="content"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="输入黑话内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="meaning">含义</Label>
|
||||
<Textarea
|
||||
id="meaning"
|
||||
value={formData.meaning || ''}
|
||||
onChange={(e) => setFormData({ ...formData, meaning: e.target.value })}
|
||||
placeholder="输入黑话含义(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chat_id">
|
||||
聊天 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.chat_id}
|
||||
onValueChange={(value) => setFormData({ ...formData, chat_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择关联的聊天" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chatList.map((chat) => (
|
||||
<SelectItem key={chat.chat_id} value={chat.chat_id}>
|
||||
{chat.chat_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_global"
|
||||
checked={formData.is_global}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_global: checked })}
|
||||
/>
|
||||
<Label htmlFor="is_global">设为全局黑话</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// 黑话编辑对话框
|
||||
// ====================
|
||||
interface JargonEditDialogProps {
|
||||
jargon: Jargon | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chatList: JargonChatInfo[]
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function JargonEditDialog({
|
||||
jargon,
|
||||
open,
|
||||
onOpenChange,
|
||||
chatList,
|
||||
onSuccess,
|
||||
}: JargonEditDialogProps) {
|
||||
const [formData, setFormData] = useState<JargonUpdateRequest>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (jargon) {
|
||||
setFormData({
|
||||
content: jargon.content,
|
||||
meaning: jargon.meaning || '',
|
||||
chat_id: jargon.stream_id || jargon.chat_id,
|
||||
is_global: jargon.is_global,
|
||||
is_jargon: jargon.is_jargon,
|
||||
})
|
||||
}
|
||||
}, [jargon])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!jargon) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await updateJargon(jargon.id, formData)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '黑话已更新',
|
||||
})
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: error instanceof Error ? error.message : '无法更新黑话',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!jargon) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑黑话</DialogTitle>
|
||||
<DialogDescription>修改黑话的信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_content">内容</Label>
|
||||
<Input
|
||||
id="edit_content"
|
||||
value={formData.content || ''}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="输入黑话内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_meaning">含义</Label>
|
||||
<Textarea
|
||||
id="edit_meaning"
|
||||
value={formData.meaning || ''}
|
||||
onChange={(e) => setFormData({ ...formData, meaning: e.target.value })}
|
||||
placeholder="输入黑话含义"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit_chat_id">聊天</Label>
|
||||
<Select
|
||||
value={formData.chat_id || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, chat_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择关联的聊天" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chatList.map((chat) => (
|
||||
<SelectItem key={chat.chat_id} value={chat.chat_id}>
|
||||
{chat.chat_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>黑话状态</Label>
|
||||
<Select
|
||||
value={formData.is_jargon === null ? 'null' : formData.is_jargon?.toString() || 'null'}
|
||||
onValueChange={(value) => setFormData({ ...formData, is_jargon: value === 'null' ? null : value === 'true' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="null">未判定</SelectItem>
|
||||
<SelectItem value="true">是黑话</SelectItem>
|
||||
<SelectItem value="false">非黑话</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="edit_is_global"
|
||||
checked={formData.is_global}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_global: checked })}
|
||||
/>
|
||||
<Label htmlFor="edit_is_global">全局黑话</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// 删除确认对话框
|
||||
// ====================
|
||||
interface DeleteConfirmDialogProps {
|
||||
jargon: Jargon | null
|
||||
open: boolean
|
||||
onOpenChange: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
jargon,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除黑话 "{jargon?.content}" 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// 批量删除确认对话框
|
||||
// ====================
|
||||
interface BatchDeleteConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
count: number
|
||||
}
|
||||
|
||||
export function BatchDeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
count,
|
||||
}: BatchDeleteConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您即将删除 {count} 个黑话,此操作无法撤销。确定要继续吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
255
dashboard/src/routes/resource/jargon/JargonList.tsx
Normal file
255
dashboard/src/routes/resource/jargon/JargonList.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React from 'react'
|
||||
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit, Eye, Globe, HelpCircle, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
import type { Jargon } from '@/types/jargon'
|
||||
|
||||
interface JargonListProps {
|
||||
jargons: Jargon[]
|
||||
loading: boolean
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
selectedIds: Set<number>
|
||||
onEdit: (jargon: Jargon) => void
|
||||
onViewDetail: (jargon: Jargon) => void
|
||||
onDelete: (jargon: Jargon) => void
|
||||
onToggleSelect: (id: number) => void
|
||||
onToggleSelectAll: () => void
|
||||
onPageChange: (page: number) => void
|
||||
onJumpToPage: (page: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染黑话状态徽章
|
||||
*/
|
||||
function renderJargonStatus(isJargon: boolean | null) {
|
||||
if (isJargon === true) {
|
||||
return <Badge variant="default" className="bg-green-600 hover:bg-green-700"><Check className="h-3 w-3 mr-1" />是黑话</Badge>
|
||||
} else if (isJargon === false) {
|
||||
return <Badge variant="secondary"><X className="h-3 w-3 mr-1" />非黑话</Badge>
|
||||
} else {
|
||||
return <Badge variant="outline"><HelpCircle className="h-3 w-3 mr-1" />未判定</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑话列表组件(桁面端表格 + 移动端卡片 + 分页)
|
||||
*/
|
||||
export function JargonList({
|
||||
jargons,
|
||||
loading,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
selectedIds,
|
||||
onEdit,
|
||||
onViewDetail,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onPageChange,
|
||||
onJumpToPage,
|
||||
}: JargonListProps) {
|
||||
const [jumpToPage, setJumpToPage] = React.useState('')
|
||||
|
||||
const handleJumpToPage = () => {
|
||||
onJumpToPage(jumpToPage)
|
||||
setJumpToPage('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 桁面端表格视图 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === jargons.length && jargons.length > 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>含义</TableHead>
|
||||
<TableHead>聊天</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-center">次数</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : jargons.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
jargons.map((jargon) => (
|
||||
<TableRow key={jargon.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(jargon.id)}
|
||||
onCheckedChange={() => onToggleSelect(jargon.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px]">
|
||||
<div className="flex items-center gap-2">
|
||||
{jargon.is_global && <span title="全局黑话"><Globe className="h-4 w-4 text-blue-500 flex-shrink-0" /></span>}
|
||||
<span className="truncate" title={jargon.content}>{jargon.content}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate" title={jargon.meaning || ''}>
|
||||
{jargon.meaning || <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate" title={jargon.chat_name || jargon.chat_id}>
|
||||
{jargon.chat_name || jargon.chat_id}
|
||||
</TableCell>
|
||||
<TableCell>{renderJargonStatus(jargon.is_jargon)}</TableCell>
|
||||
<TableCell className="text-center">{jargon.count}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(jargon)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewDetail(jargon)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(jargon)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片视图 */}
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
) : jargons.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无数据</div>
|
||||
) : (
|
||||
jargons.map((jargon) => (
|
||||
<div key={jargon.id} className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(jargon.id)}
|
||||
onCheckedChange={() => onToggleSelect(jargon.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{jargon.is_global && <Globe className="h-4 w-4 text-blue-500 flex-shrink-0" />}
|
||||
<h3 className="font-semibold text-sm break-all">{jargon.content}</h3>
|
||||
</div>
|
||||
{jargon.meaning && (
|
||||
<p className="text-sm text-muted-foreground break-all">{jargon.meaning}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{renderJargonStatus(jargon.is_jargon)}
|
||||
<span className="text-muted-foreground">次数: {jargon.count}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
聊天: {jargon.chat_name || jargon.chat_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 pt-2 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(jargon)} className="text-xs px-2 py-1 h-auto">
|
||||
<Edit className="h-3 w-3 mr-1" />编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onViewDetail(jargon)} className="text-xs px-2 py-1 h-auto">
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onDelete(jargon)} className="text-xs px-2 py-1 h-auto text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3 mr-1" />删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {total} 条记录,第 {page} / {Math.ceil(total / pageSize)} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(1)} disabled={page === 1} className="hidden sm:flex">
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
<ChevronLeft className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={jumpToPage}
|
||||
onChange={(e) => setJumpToPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
|
||||
placeholder={page.toString()}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
max={Math.ceil(total / pageSize)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleJumpToPage} disabled={!jumpToPage} className="h-8">
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page >= Math.ceil(total / pageSize)}>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(Math.ceil(total / pageSize))} disabled={page >= Math.ceil(total / pageSize)} className="hidden sm:flex">
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
dashboard/src/routes/resource/jargon/index.ts
Normal file
1
dashboard/src/routes/resource/jargon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { JargonManagementPage } from './index.tsx'
|
||||
460
dashboard/src/routes/resource/jargon/index.tsx
Normal file
460
dashboard/src/routes/resource/jargon/index.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { Check, MessageCircle, Plus, Search, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
import {
|
||||
batchDeleteJargons,
|
||||
batchSetJargonStatus,
|
||||
deleteJargon,
|
||||
getJargonChatList,
|
||||
getJargonDetail,
|
||||
getJargonList,
|
||||
getJargonStats,
|
||||
} from '@/lib/jargon-api'
|
||||
|
||||
import {
|
||||
BatchDeleteConfirmDialog,
|
||||
DeleteConfirmDialog,
|
||||
JargonCreateDialog,
|
||||
JargonDetailDialog,
|
||||
JargonEditDialog,
|
||||
} from './JargonDialogs'
|
||||
import { JargonList } from './JargonList'
|
||||
|
||||
import type { Jargon, JargonChatInfo } from '@/types/jargon'
|
||||
import type { StatsData } from './types'
|
||||
|
||||
/**
|
||||
* 黑话管理主页面
|
||||
*/
|
||||
export function JargonManagementPage() {
|
||||
const [jargons, setJargons] = useState<Jargon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterChatId, setFilterChatId] = useState<string>('all')
|
||||
const [filterIsJargon, setFilterIsJargon] = useState<string>('all')
|
||||
const [selectedJargon, setSelectedJargon] = useState<Jargon | null>(null)
|
||||
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [deleteConfirmJargon, setDeleteConfirmJargon] = useState<Jargon | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [isBatchDeleteDialogOpen, setIsBatchDeleteDialogOpen] = useState(false)
|
||||
const [stats, setStats] = useState<StatsData>({
|
||||
total: 0,
|
||||
confirmed_jargon: 0,
|
||||
confirmed_not_jargon: 0,
|
||||
pending: 0,
|
||||
global_count: 0,
|
||||
complete_count: 0,
|
||||
chat_count: 0,
|
||||
top_chats: {},
|
||||
})
|
||||
const [chatList, setChatList] = useState<JargonChatInfo[]>([])
|
||||
const { toast } = useToast()
|
||||
|
||||
// 加载黑话列表
|
||||
const loadJargons = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getJargonList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: search || undefined,
|
||||
chat_id: filterChatId === 'all' ? undefined : filterChatId,
|
||||
is_jargon: filterIsJargon === 'all' ? undefined : filterIsJargon === 'true' ? true : filterIsJargon === 'false' ? false : undefined,
|
||||
})
|
||||
setJargons(response.data)
|
||||
setTotal(response.total)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: error instanceof Error ? error.message : '无法加载黑话列表',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await getJargonStats()
|
||||
if (response?.data) {
|
||||
setStats(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聊天列表
|
||||
const loadChatList = async () => {
|
||||
try {
|
||||
const response = await getJargonChatList()
|
||||
if (response?.data) {
|
||||
setChatList(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadJargons()
|
||||
loadStats()
|
||||
loadChatList()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, search, filterChatId, filterIsJargon])
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (jargon: Jargon) => {
|
||||
try {
|
||||
const response = await getJargonDetail(jargon.id)
|
||||
setSelectedJargon(response.data)
|
||||
setIsDetailDialogOpen(true)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载详情失败',
|
||||
description: error instanceof Error ? error.message : '无法加载黑话详情',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑黑话
|
||||
const handleEdit = (jargon: Jargon) => {
|
||||
setSelectedJargon(jargon)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
// 删除黑话
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmJargon) return
|
||||
try {
|
||||
await deleteJargon(deleteConfirmJargon.id)
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: `已删除黑话: ${deleteConfirmJargon.content}`,
|
||||
})
|
||||
setDeleteConfirmJargon(null)
|
||||
loadJargons()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: error instanceof Error ? error.message : '无法删除黑话',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个选择
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSelected = new Set(selectedIds)
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id)
|
||||
} else {
|
||||
newSelected.add(id)
|
||||
}
|
||||
setSelectedIds(newSelected)
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === jargons.length && jargons.length > 0) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(jargons.map(j => j.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
await batchDeleteJargons(Array.from(selectedIds))
|
||||
toast({
|
||||
title: '批量删除成功',
|
||||
description: `已删除 ${selectedIds.size} 个黑话`,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
setIsBatchDeleteDialogOpen(false)
|
||||
loadJargons()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '批量删除失败',
|
||||
description: error instanceof Error ? error.message : '无法批量删除黑话',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 批量设置为黑话
|
||||
const handleBatchSetJargon = async (isJargon: boolean) => {
|
||||
try {
|
||||
await batchSetJargonStatus(Array.from(selectedIds), isJargon)
|
||||
toast({
|
||||
title: '操作成功',
|
||||
description: `已将 ${selectedIds.size} 个词条设为${isJargon ? '黑话' : '非黑话'}`,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
loadJargons()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: error instanceof Error ? error.message : '批量设置失败',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 页面跳转
|
||||
const handleJumpToPage = (jumpToPage: string) => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
} else {
|
||||
toast({
|
||||
title: '无效的页码',
|
||||
description: `请输入1-${totalPages}之间的页码`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<MessageCircle className="h-8 w-8" strokeWidth={2} />
|
||||
黑话管理
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
管理麦麦学习到的黑话和俗语
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增黑话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 sm:space-y-6 pr-4">
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">总数量</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1">{stats.total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">已确认黑话</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1 text-green-600">{stats.confirmed_jargon}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">确认非黑话</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1 text-gray-500">{stats.confirmed_not_jargon}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">待判定</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1 text-yellow-600">{stats.pending}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">全局黑话</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1 text-blue-600">{stats.global_count}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">推断完成</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1 text-purple-600">{stats.complete_count}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">关联聊天数</div>
|
||||
<div className="text-xl sm:text-2xl font-bold mt-1">{stats.chat_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="search">搜索</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="搜索内容、含义..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>聊天筛选</Label>
|
||||
<Select value={filterChatId} onValueChange={setFilterChatId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部聊天" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部聊天</SelectItem>
|
||||
{chatList.map((chat) => (
|
||||
<SelectItem key={chat.chat_id} value={chat.chat_id}>
|
||||
{chat.chat_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>状态筛选</Label>
|
||||
<Select value={filterIsJargon} onValueChange={setFilterIsJargon}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="true">是黑话</SelectItem>
|
||||
<SelectItem value="false">非黑话</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="page-size">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value))
|
||||
setPage(1)
|
||||
setSelectedIds(new Set())
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="page-size">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作工具栏 */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t">
|
||||
<span className="text-sm text-muted-foreground">已选择 {selectedIds.size} 个</span>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBatchSetJargon(true)}>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
标记为黑话
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBatchSetJargon(false)}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
标记为非黑话
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setIsBatchDeleteDialogOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 黑话列表 */}
|
||||
<JargonList
|
||||
jargons={jargons}
|
||||
loading={loading}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
selectedIds={selectedIds}
|
||||
onEdit={handleEdit}
|
||||
onViewDetail={handleViewDetail}
|
||||
onDelete={(jargon) => setDeleteConfirmJargon(jargon)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onPageChange={setPage}
|
||||
onJumpToPage={handleJumpToPage}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<JargonDetailDialog
|
||||
jargon={selectedJargon}
|
||||
open={isDetailDialogOpen}
|
||||
onOpenChange={setIsDetailDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 创建对话框 */}
|
||||
<JargonCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
chatList={chatList}
|
||||
onSuccess={() => {
|
||||
loadJargons()
|
||||
loadStats()
|
||||
setIsCreateDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<JargonEditDialog
|
||||
jargon={selectedJargon}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
chatList={chatList}
|
||||
onSuccess={() => {
|
||||
loadJargons()
|
||||
loadStats()
|
||||
setIsEditDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
jargon={deleteConfirmJargon}
|
||||
open={!!deleteConfirmJargon}
|
||||
onOpenChange={() => setDeleteConfirmJargon(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 批量删除确认对话框 */}
|
||||
<BatchDeleteConfirmDialog
|
||||
open={isBatchDeleteDialogOpen}
|
||||
onOpenChange={setIsBatchDeleteDialogOpen}
|
||||
onConfirm={handleBatchDelete}
|
||||
count={selectedIds.size}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
dashboard/src/routes/resource/jargon/types.ts
Normal file
17
dashboard/src/routes/resource/jargon/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 黑话管理页面的内部类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 统计数据
|
||||
*/
|
||||
export interface StatsData {
|
||||
total: number
|
||||
confirmed_jargon: number
|
||||
confirmed_not_jargon: number
|
||||
pending: number
|
||||
global_count: number
|
||||
complete_count: number
|
||||
chat_count: number
|
||||
top_chats: Record<string, number>
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
import { useState, useCallback, useEffect, memo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import ReactFlow, {
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Panel,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import dagre from 'dagre'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Info,
|
||||
Database,
|
||||
Network,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { getKnowledgeGraph, getKnowledgeStats, searchKnowledgeNode, type KnowledgeNode, type KnowledgeEdge, type KnowledgeStats } from '@/lib/knowledge-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 自定义节点组件 - 实体节点
|
||||
const EntityNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-4 py-2 shadow-md rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700 min-w-[120px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-semibold text-white text-sm truncate max-w-[200px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EntityNode.displayName = 'EntityNode'
|
||||
|
||||
// 自定义节点组件 - 段落节点
|
||||
const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700 min-w-[100px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-medium text-white text-xs truncate max-w-[150px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ParagraphNode.displayName = 'ParagraphNode'
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
entity: EntityNode,
|
||||
paragraph: ParagraphNode,
|
||||
}
|
||||
|
||||
// 使用 dagre 进行自动布局
|
||||
function calculateLayout(nodes: KnowledgeNode[], edges: KnowledgeEdge[]): { nodes: Node[]; edges: Edge[] } {
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 100, nodesep: 80 })
|
||||
|
||||
const flowNodes: Node[] = []
|
||||
const flowEdges: Edge[] = []
|
||||
|
||||
// 设置节点到 dagre 图
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: 150, height: 50 })
|
||||
})
|
||||
|
||||
// 设置边到 dagre 图
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
// 执行布局计算
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
// 获取布局后的节点位置
|
||||
nodes.forEach((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
flowNodes.push({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: {
|
||||
x: nodeWithPosition.x - 75,
|
||||
y: nodeWithPosition.y - 25,
|
||||
},
|
||||
data: {
|
||||
label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''),
|
||||
content: node.content,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 创建边
|
||||
edges.forEach((edge, index) => {
|
||||
const flowEdge: Edge = {
|
||||
id: `edge-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
// 节点数超过200时禁用动画提升性能
|
||||
animated: nodes.length <= 200 && edge.weight > 5,
|
||||
style: {
|
||||
strokeWidth: Math.min(edge.weight / 2, 5),
|
||||
opacity: 0.6,
|
||||
},
|
||||
}
|
||||
// 只在节点数少于100时显示边的标签
|
||||
if (edge.weight > 10 && nodes.length < 100) {
|
||||
flowEdge.label = `${edge.weight.toFixed(0)}`
|
||||
}
|
||||
flowEdges.push(flowEdge)
|
||||
})
|
||||
|
||||
return { nodes: flowNodes, edges: flowEdges }
|
||||
}
|
||||
|
||||
export function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stats, setStats] = useState<KnowledgeStats | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [nodeType, setNodeType] = useState<'all' | 'entity' | 'paragraph'>('all')
|
||||
const [nodeLimit, setNodeLimit] = useState(50)
|
||||
const [customLimit, setCustomLimit] = useState('50')
|
||||
const [showCustomInput, setShowCustomInput] = useState(false)
|
||||
const [showInitialConfirm, setShowInitialConfirm] = useState(true)
|
||||
const [userConfirmedLoad, setUserConfirmedLoad] = useState(false) // 用户是否确认加载
|
||||
const [showHighNodeWarning, setShowHighNodeWarning] = useState(false)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
const [nodeCount, setNodeCount] = useState(0)
|
||||
const [selectedNodeData, setSelectedNodeData] = useState<KnowledgeNode | null>(null)
|
||||
const [selectedEdgeData, setSelectedEdgeData] = useState<{ source: KnowledgeNode; target: KnowledgeNode; edge: KnowledgeEdge } | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 缓存 MiniMap 的 nodeColor 函数
|
||||
const miniMapNodeColor = useCallback((node: Node) => {
|
||||
if (node.type === 'entity') return '#6366f1'
|
||||
if (node.type === 'paragraph') return '#10b981'
|
||||
return '#6b7280'
|
||||
}, [])
|
||||
|
||||
// 加载知识图谱数据
|
||||
const loadGraph = useCallback(async (skipWarning = false) => {
|
||||
try {
|
||||
// 检查是否需要警告用户
|
||||
if (!skipWarning && nodeLimit > 200) {
|
||||
setShowHighNodeWarning(true)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const [graphData, statsData] = await Promise.all([
|
||||
getKnowledgeGraph(nodeLimit, nodeType),
|
||||
getKnowledgeStats(),
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '知识库为空,请先导入知识数据',
|
||||
})
|
||||
setNodes([])
|
||||
setEdges([])
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges)
|
||||
setNodes(flowNodes)
|
||||
setEdges(flowEdges)
|
||||
setNodeCount(flowNodes.length)
|
||||
|
||||
if (statsData && statsData.total_nodes > nodeLimit) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: `知识图谱包含 ${statsData.total_nodes} 个节点,当前显示 ${flowNodes.length} 个`,
|
||||
})
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '加载成功',
|
||||
description: `已加载 ${flowNodes.length} 个节点,${flowEdges.length} 条边`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载知识图谱失败:', error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeLimit, nodeType, toast]) // setNodes 和 setEdges 是稳定的,不需要包含
|
||||
|
||||
// 搜索节点
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '请输入搜索关键词',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchKnowledgeNode(searchQuery)
|
||||
if (results.length === 0) {
|
||||
toast({
|
||||
title: '未找到',
|
||||
description: '没有找到匹配的节点',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 高亮搜索结果
|
||||
const resultIds = new Set(results.map(r => r.id))
|
||||
setNodes(nds =>
|
||||
nds.map(node => ({
|
||||
...node,
|
||||
style: {
|
||||
...node.style,
|
||||
opacity: resultIds.has(node.id) ? 1 : 0.3,
|
||||
filter: resultIds.has(node.id) ? 'brightness(1.2)' : 'brightness(0.8)',
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
toast({
|
||||
title: '搜索完成',
|
||||
description: `找到 ${results.length} 个匹配节点`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
toast({
|
||||
title: '搜索失败',
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, toast]) // setNodes 是稳定的
|
||||
|
||||
// 重置高亮
|
||||
const handleResetHighlight = useCallback(() => {
|
||||
setNodes(nds =>
|
||||
nds.map(node => ({
|
||||
...node,
|
||||
style: {
|
||||
...node.style,
|
||||
opacity: 1,
|
||||
filter: 'brightness(1)',
|
||||
},
|
||||
}))
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // setNodes 是稳定的
|
||||
|
||||
// 初始确认后加载
|
||||
const handleInitialConfirm = useCallback(() => {
|
||||
setShowInitialConfirm(false)
|
||||
setUserConfirmedLoad(true) // 设置用户确认标记
|
||||
loadGraph()
|
||||
}, [loadGraph])
|
||||
|
||||
// 高节点数确认后加载
|
||||
const handleHighNodeConfirm = useCallback(() => {
|
||||
setShowHighNodeWarning(false) // 立即关闭高节点数警告对话框
|
||||
// 使用 setTimeout 确保对话框关闭后再开始加载
|
||||
setTimeout(() => {
|
||||
loadGraph(true)
|
||||
}, 0)
|
||||
}, [loadGraph])
|
||||
|
||||
// 节点点击事件
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeData({
|
||||
id: node.id,
|
||||
type: node.type as 'entity' | 'paragraph',
|
||||
content: node.data.content,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 当节点数量或类型改变时自动刷新
|
||||
useEffect(() => {
|
||||
// 跳过初始确认对话框时的加载
|
||||
if (showInitialConfirm) return
|
||||
// 只有用户确认后才能自动刷新
|
||||
if (!userConfirmedLoad) return
|
||||
|
||||
// 参数变化时加载,会根据节点数自动判断是否需要警告
|
||||
loadGraph()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeLimit, nodeType, showInitialConfirm, userConfirmedLoad]) // 不依赖 loadGraph
|
||||
|
||||
// 边点击事件
|
||||
const onEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => {
|
||||
const sourceNode = nodes.find(n => n.id === edge.source)
|
||||
const targetNode = nodes.find(n => n.id === edge.target)
|
||||
const edgeData = edges.find(e => e.id === edge.id)
|
||||
|
||||
if (sourceNode && targetNode && edgeData) {
|
||||
setSelectedEdgeData({
|
||||
source: {
|
||||
id: sourceNode.id,
|
||||
type: sourceNode.type as 'entity' | 'paragraph',
|
||||
content: sourceNode.data.content,
|
||||
},
|
||||
target: {
|
||||
id: targetNode.id,
|
||||
type: targetNode.type as 'entity' | 'paragraph',
|
||||
content: targetNode.data.content,
|
||||
},
|
||||
edge: {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
weight: parseFloat(edge.label as string || '0'),
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [nodes, edges])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex-shrink-0 p-4 border-b bg-background">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">麦麦知识库图谱</h1>
|
||||
<p className="text-muted-foreground mt-1">可视化知识实体与关系网络</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
节点: {stats.total_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Network className="h-3 w-3" />
|
||||
边: {stats.total_edges}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
实体: {stats.entity_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
段落: {stats.paragraph_nodes}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索和控制栏 */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
placeholder="搜索节点内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleResetHighlight} variant="outline" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={nodeType} onValueChange={(v) => setNodeType(v as 'all' | 'entity' | 'paragraph')}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部节点</SelectItem>
|
||||
<SelectItem value="entity">仅实体</SelectItem>
|
||||
<SelectItem value="paragraph">仅段落</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={
|
||||
nodeLimit === 10000 ? 'all' :
|
||||
showCustomInput ? 'custom' :
|
||||
nodeLimit.toString()
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'custom') {
|
||||
setShowCustomInput(true)
|
||||
setCustomLimit(nodeLimit.toString())
|
||||
} else if (v === 'all') {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(10000)
|
||||
} else {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(Number(v))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50 节点</SelectItem>
|
||||
<SelectItem value="100">100 节点</SelectItem>
|
||||
<SelectItem value="200">200 节点</SelectItem>
|
||||
<SelectItem value="500">500 节点</SelectItem>
|
||||
<SelectItem value="1000">1000 节点</SelectItem>
|
||||
<SelectItem value="all">全部 (最多10000)</SelectItem>
|
||||
<SelectItem value="custom">自定义...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
type="number"
|
||||
min="50"
|
||||
value={customLimit}
|
||||
onChange={(e) => setCustomLimit(e.target.value)}
|
||||
onBlur={() => {
|
||||
const num = parseInt(customLimit)
|
||||
if (!isNaN(num) && num >= 50) {
|
||||
setNodeLimit(num)
|
||||
} else {
|
||||
setCustomLimit('50')
|
||||
setNodeLimit(50)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const num = parseInt(customLimit)
|
||||
if (!isNaN(num) && num >= 50) {
|
||||
setNodeLimit(num)
|
||||
} else {
|
||||
setCustomLimit('50')
|
||||
setNodeLimit(50)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="最少50个"
|
||||
className="w-[120px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={() => loadGraph()} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">加载知识图谱中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">知识库为空</h3>
|
||||
<p className="text-muted-foreground">请先导入知识数据</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{/* 节点数超过500时禁用MiniMap提升性能 */}
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图例 */}
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节点详情对话框 */}
|
||||
<Dialog open={!!selectedNodeData} onOpenChange={(open) => !open && setSelectedNodeData(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">类型</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">内容</label>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
💡 <strong>提示:</strong>段落内容显示不完整?
|
||||
<br />
|
||||
您可以在 <strong>配置 → WebUI 服务配置</strong> 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。
|
||||
<br />
|
||||
注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 边详情对话框 */}
|
||||
<Dialog open={!!selectedEdgeData} onOpenChange={(open) => !open && setSelectedEdgeData(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">源节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.source.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-muted-foreground flex-shrink-0">→</div>
|
||||
|
||||
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">目标节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.target.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">权重</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 初始加载确认对话框 */}
|
||||
<AlertDialog open={showInitialConfirm} onOpenChange={setShowInitialConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>加载知识图谱</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
知识图谱的动态展示会消耗较多系统资源。
|
||||
<br />
|
||||
确定要加载知识图谱吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => navigate({ to: '/' })}>
|
||||
取消 (返回首页)
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleInitialConfirm}>
|
||||
确认加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 高节点数警告对话框 */}
|
||||
<AlertDialog open={showHighNodeWarning} onOpenChange={setShowHighNodeWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>⚠️ 节点数量较多</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>
|
||||
您正在尝试加载 <strong className="text-orange-600">{nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit}</strong> 个节点。
|
||||
</p>
|
||||
<p className="mt-4">节点数量过多可能导致:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>页面加载时间较长</li>
|
||||
<li>浏览器卡顿或崩溃</li>
|
||||
<li>系统资源占用过高</li>
|
||||
</ul>
|
||||
<p className="mt-4">建议先选择较少的节点数量 (50-200 个)。</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setShowHighNodeWarning(false)
|
||||
// 将节点数重置为安全值
|
||||
if (nodeLimit > 200) {
|
||||
setNodeLimit(50)
|
||||
setShowCustomInput(false)
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleHighNodeConfirm} className="bg-orange-600 hover:bg-orange-700">
|
||||
我了解风险,继续加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx
Normal file
122
dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import type { GraphNode, SelectedEdgeData } from './types'
|
||||
|
||||
interface NodeDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedNodeData: GraphNode | null
|
||||
}
|
||||
|
||||
export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeDetailDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">类型</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">内容</label>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
💡 <strong>提示:</strong>段落内容显示不完整?
|
||||
<br />
|
||||
您可以在 <strong>配置 → WebUI 服务配置</strong> 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。
|
||||
<br />
|
||||
注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface EdgeDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedEdgeData: SelectedEdgeData | null
|
||||
}
|
||||
|
||||
export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeDetailDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">源节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.source.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-muted-foreground flex-shrink-0">→</div>
|
||||
|
||||
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">目标节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.target.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">权重</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
Panel,
|
||||
Position,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
} from 'reactflow'
|
||||
|
||||
import 'reactflow/dist/style.css'
|
||||
import dagre from 'dagre'
|
||||
|
||||
import type { FlowEdge, FlowNode, GraphEdge, GraphNode } from './types'
|
||||
|
||||
const EntityNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-4 py-2 shadow-md rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700 min-w-[120px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-semibold text-white text-sm truncate max-w-[200px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EntityNode.displayName = 'EntityNode'
|
||||
|
||||
const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700 min-w-[100px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-medium text-white text-xs truncate max-w-[150px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ParagraphNode.displayName = 'ParagraphNode'
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
entity: EntityNode,
|
||||
paragraph: ParagraphNode,
|
||||
}
|
||||
|
||||
function calculateLayout(nodes: GraphNode[], edges: GraphEdge[]): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 100, nodesep: 80 })
|
||||
|
||||
const flowNodes: FlowNode[] = []
|
||||
const flowEdges: FlowEdge[] = []
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: 150, height: 50 })
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
flowNodes.push({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: {
|
||||
x: nodeWithPosition.x - 75,
|
||||
y: nodeWithPosition.y - 25,
|
||||
},
|
||||
data: {
|
||||
label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''),
|
||||
content: node.content,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
edges.forEach((edge, index) => {
|
||||
const flowEdge: FlowEdge = {
|
||||
id: `edge-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
animated: nodes.length <= 200 && edge.weight > 5,
|
||||
style: {
|
||||
strokeWidth: Math.min(edge.weight / 2, 5),
|
||||
opacity: 0.6,
|
||||
},
|
||||
}
|
||||
if (edge.weight > 10 && nodes.length < 100) {
|
||||
flowEdge.label = `${edge.weight.toFixed(0)}`
|
||||
}
|
||||
flowEdges.push(flowEdge)
|
||||
})
|
||||
|
||||
return { nodes: flowNodes, edges: flowEdges }
|
||||
}
|
||||
|
||||
interface GraphVisualizationProps {
|
||||
graphData: { nodes: GraphNode[]; edges: GraphEdge[] }
|
||||
onNodeClick: (event: React.MouseEvent, node: Node) => void
|
||||
onEdgeClick: (event: React.MouseEvent, edge: Edge) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loading = false }: GraphVisualizationProps) {
|
||||
const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges)
|
||||
const [nodes, , onNodesChange] = useNodesState(flowNodes)
|
||||
const [edges, , onEdgesChange] = useEdgesState(flowEdges)
|
||||
const nodeCount = nodes.length
|
||||
|
||||
const miniMapNodeColor = useCallback((node: Node) => {
|
||||
if (node.type === 'entity') return '#6366f1'
|
||||
if (node.type === 'paragraph') return '#10b981'
|
||||
return '#6b7280'
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
1
dashboard/src/routes/resource/knowledge-graph/index.ts
Normal file
1
dashboard/src/routes/resource/knowledge-graph/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { KnowledgeGraphPage } from './index.tsx'
|
||||
427
dashboard/src/routes/resource/knowledge-graph/index.tsx
Normal file
427
dashboard/src/routes/resource/knowledge-graph/index.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
|
||||
import { Database, FileText, Info, Network, RefreshCw, Search } from 'lucide-react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getKnowledgeGraph,
|
||||
getKnowledgeStats,
|
||||
searchKnowledgeNode,
|
||||
type KnowledgeStats,
|
||||
} from '@/lib/knowledge-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { EdgeDetailDialog, NodeDetailDialog } from './GraphDialogs'
|
||||
import { GraphVisualization } from './GraphVisualization'
|
||||
import type { GraphData, GraphNode, SelectedEdgeData } from './types'
|
||||
|
||||
export function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stats, setStats] = useState<KnowledgeStats | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [nodeType, setNodeType] = useState<'all' | 'entity' | 'paragraph'>('all')
|
||||
const [nodeLimit, setNodeLimit] = useState(50)
|
||||
const [customLimit, setCustomLimit] = useState('50')
|
||||
const [showCustomInput, setShowCustomInput] = useState(false)
|
||||
const [showInitialConfirm, setShowInitialConfirm] = useState(true)
|
||||
const [userConfirmedLoad, setUserConfirmedLoad] = useState(false)
|
||||
const [showHighNodeWarning, setShowHighNodeWarning] = useState(false)
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] })
|
||||
const [selectedNodeData, setSelectedNodeData] = useState<GraphNode | null>(null)
|
||||
const [selectedEdgeData, setSelectedEdgeData] = useState<SelectedEdgeData | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const loadGraph = useCallback(async (skipWarning = false) => {
|
||||
try {
|
||||
if (!skipWarning && nodeLimit > 200) {
|
||||
setShowHighNodeWarning(true)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const [graphResult, statsData] = await Promise.all([
|
||||
getKnowledgeGraph(nodeLimit, nodeType),
|
||||
getKnowledgeStats(),
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
|
||||
if (graphResult.nodes.length === 0) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '知识库为空,请先导入知识数据',
|
||||
})
|
||||
setGraphData({ nodes: [], edges: [] })
|
||||
return
|
||||
}
|
||||
|
||||
setGraphData({ nodes: graphResult.nodes, edges: graphResult.edges })
|
||||
|
||||
if (statsData && statsData.total_nodes > nodeLimit) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: `知识图谱包含 ${statsData.total_nodes} 个节点,当前显示 ${graphResult.nodes.length} 个`,
|
||||
})
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '加载成功',
|
||||
description: `已加载 ${graphResult.nodes.length} 个节点,${graphResult.edges.length} 条边`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载知识图谱失败:', error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [nodeLimit, nodeType, toast])
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '请输入搜索关键词',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchKnowledgeNode(searchQuery)
|
||||
if (results.length === 0) {
|
||||
toast({
|
||||
title: '未找到',
|
||||
description: '没有找到匹配的节点',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '搜索完成',
|
||||
description: `找到 ${results.length} 个匹配节点`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
toast({
|
||||
title: '搜索失败',
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}, [searchQuery, toast])
|
||||
|
||||
const handleResetHighlight = useCallback(() => {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: '已重置高亮',
|
||||
})
|
||||
}, [toast])
|
||||
|
||||
const handleInitialConfirm = useCallback(() => {
|
||||
setShowInitialConfirm(false)
|
||||
setUserConfirmedLoad(true)
|
||||
loadGraph()
|
||||
}, [loadGraph])
|
||||
|
||||
const handleHighNodeConfirm = useCallback(() => {
|
||||
setShowHighNodeWarning(false)
|
||||
setTimeout(() => {
|
||||
loadGraph(true)
|
||||
}, 0)
|
||||
}, [loadGraph])
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeData({
|
||||
id: node.id,
|
||||
type: node.type as 'entity' | 'paragraph',
|
||||
content: node.data.content,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showInitialConfirm) return
|
||||
if (!userConfirmedLoad) return
|
||||
|
||||
loadGraph()
|
||||
}, [nodeLimit, nodeType, showInitialConfirm, userConfirmedLoad])
|
||||
|
||||
const onEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => {
|
||||
const sourceNode = graphData.nodes.find(n => n.id === edge.source)
|
||||
const targetNode = graphData.nodes.find(n => n.id === edge.target)
|
||||
const edgeData = graphData.edges.find(e => e.source === edge.source && e.target === edge.target)
|
||||
|
||||
if (sourceNode && targetNode && edgeData) {
|
||||
setSelectedEdgeData({
|
||||
source: {
|
||||
id: sourceNode.id,
|
||||
type: sourceNode.type as 'entity' | 'paragraph',
|
||||
content: sourceNode.content,
|
||||
},
|
||||
target: {
|
||||
id: targetNode.id,
|
||||
type: targetNode.type as 'entity' | 'paragraph',
|
||||
content: targetNode.content,
|
||||
},
|
||||
edge: {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
weight: parseFloat(edge.label as string || '0'),
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [graphData])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-shrink-0 p-4 border-b bg-background">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">麦麦知识库图谱</h1>
|
||||
<p className="text-muted-foreground mt-1">可视化知识实体与关系网络</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
节点: {stats.total_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Network className="h-3 w-3" />
|
||||
边: {stats.total_edges}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
实体: {stats.entity_nodes}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
段落: {stats.paragraph_nodes}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
placeholder="搜索节点内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleResetHighlight} variant="outline" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={nodeType} onValueChange={(v) => setNodeType(v as 'all' | 'entity' | 'paragraph')}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部节点</SelectItem>
|
||||
<SelectItem value="entity">仅实体</SelectItem>
|
||||
<SelectItem value="paragraph">仅段落</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={
|
||||
nodeLimit === 10000 ? 'all' :
|
||||
showCustomInput ? 'custom' :
|
||||
nodeLimit.toString()
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'custom') {
|
||||
setShowCustomInput(true)
|
||||
setCustomLimit(nodeLimit.toString())
|
||||
} else if (v === 'all') {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(10000)
|
||||
} else {
|
||||
setShowCustomInput(false)
|
||||
setNodeLimit(Number(v))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50 节点</SelectItem>
|
||||
<SelectItem value="100">100 节点</SelectItem>
|
||||
<SelectItem value="200">200 节点</SelectItem>
|
||||
<SelectItem value="500">500 节点</SelectItem>
|
||||
<SelectItem value="1000">1000 节点</SelectItem>
|
||||
<SelectItem value="all">全部 (最多10000)</SelectItem>
|
||||
<SelectItem value="custom">自定义...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
type="number"
|
||||
min="50"
|
||||
value={customLimit}
|
||||
onChange={(e) => setCustomLimit(e.target.value)}
|
||||
onBlur={() => {
|
||||
const num = parseInt(customLimit)
|
||||
if (!isNaN(num) && num >= 50) {
|
||||
setNodeLimit(num)
|
||||
} else {
|
||||
setCustomLimit('50')
|
||||
setNodeLimit(50)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const num = parseInt(customLimit)
|
||||
if (!isNaN(num) && num >= 50) {
|
||||
setNodeLimit(num)
|
||||
} else {
|
||||
setCustomLimit('50')
|
||||
setNodeLimit(50)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="最少50个"
|
||||
className="w-[120px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={() => loadGraph()} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">加载知识图谱中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : graphData.nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">知识库为空</h3>
|
||||
<p className="text-muted-foreground">请先导入知识数据</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GraphVisualization
|
||||
graphData={graphData}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NodeDetailDialog
|
||||
open={!!selectedNodeData}
|
||||
onOpenChange={(open) => !open && setSelectedNodeData(null)}
|
||||
selectedNodeData={selectedNodeData}
|
||||
/>
|
||||
|
||||
<EdgeDetailDialog
|
||||
open={!!selectedEdgeData}
|
||||
onOpenChange={(open) => !open && setSelectedEdgeData(null)}
|
||||
selectedEdgeData={selectedEdgeData}
|
||||
/>
|
||||
|
||||
<AlertDialog open={showInitialConfirm} onOpenChange={setShowInitialConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>加载知识图谱</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
知识图谱的动态展示会消耗较多系统资源。
|
||||
<br />
|
||||
确定要加载知识图谱吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => navigate({ to: '/' })}>
|
||||
取消 (返回首页)
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleInitialConfirm}>
|
||||
确认加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showHighNodeWarning} onOpenChange={setShowHighNodeWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>⚠️ 节点数量较多</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>
|
||||
您正在尝试加载 <strong className="text-orange-600">{nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit}</strong> 个节点。
|
||||
</p>
|
||||
<p className="mt-4">节点数量过多可能导致:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>页面加载时间较长</li>
|
||||
<li>浏览器卡顿或崩溃</li>
|
||||
<li>系统资源占用过高</li>
|
||||
</ul>
|
||||
<p className="mt-4">建议先选择较少的节点数量 (50-200 个)。</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setShowHighNodeWarning(false)
|
||||
if (nodeLimit > 200) {
|
||||
setNodeLimit(50)
|
||||
setShowCustomInput(false)
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleHighNodeConfirm} className="bg-orange-600 hover:bg-orange-700">
|
||||
我了解风险,继续加载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
dashboard/src/routes/resource/knowledge-graph/types.ts
Normal file
39
dashboard/src/routes/resource/knowledge-graph/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Node, Edge } from 'reactflow'
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: 'entity' | 'paragraph'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
total_nodes: number
|
||||
total_edges: number
|
||||
entity_nodes: number
|
||||
paragraph_nodes: number
|
||||
}
|
||||
|
||||
export interface FlowNodeData {
|
||||
label: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type FlowNode = Node<FlowNodeData>
|
||||
export type FlowEdge = Edge
|
||||
|
||||
export interface SelectedEdgeData {
|
||||
source: GraphNode
|
||||
target: GraphNode
|
||||
edge: GraphEdge
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user