Merge branch 'r-dev' of github.com:Mai-with-u/MaiBot into r-dev

This commit is contained in:
UnCLAS-Prommer
2026-03-02 17:23:45 +08:00
113 changed files with 14481 additions and 11835 deletions

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -50,5 +50,5 @@ export function AnimationProvider({
setEnableWavesBackground,
}
return <AnimationContext.Provider value={value}>{children}</AnimationContext.Provider>
return <AnimationContext value={value}>{children}</AnimationContext>
}

View File

@@ -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() {

View File

@@ -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" />

View File

@@ -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',
})
// 清空选择并刷新

View File

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

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

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

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

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

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

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

View File

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

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

View File

@@ -90,8 +90,8 @@ export function ThemeProvider({
)
return (
<ThemeProviderContext.Provider value={value}>
<ThemeProviderContext value={value}>
{children}
</ThemeProviderContext.Provider>
</ThemeProviderContext>
)
}

View File

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

View File

@@ -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"

View File

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

View File

@@ -191,9 +191,8 @@
--chart-4: var(--color-chart-4);
--chart-5: var(--color-chart-5);
}
}
@layer base {
* {
@apply border-border;
}

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

View File

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

View File

@@ -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()
}

View File

@@ -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
}
/**

View File

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

View File

@@ -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()
}

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

View File

@@ -0,0 +1,5 @@
export * from './types'
export * from './marketplace'
export * from './installed'
export * from './install-flow'
export * from './config'

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

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

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

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

View File

@@ -310,9 +310,9 @@ export function RestartProvider({
}
return (
<RestartContext.Provider value={contextValue}>
<RestartContext value={contextValue}>
{children}
</RestartContext.Provider>
</RestartContext>
)
}

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

View File

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

View File

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

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

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

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

View File

@@ -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">

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

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

View File

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

View File

@@ -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)}
/>
)

View File

@@ -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)}
/>
)

View File

@@ -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)}
/>
)

View File

@@ -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)}
/>
)

View File

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

View File

@@ -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)
* })

View File

@@ -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 112</li>
<li> <strong></strong> 0-10 1 </li>
</ul>
</div>
</div>
<RuleList
rules={config.talk_value_rules}
onAdd={addTalkValueRule}
onUpdate={updateTalkValueRule}
onRemove={removeTalkValueRule}
/>
)}
</div>
)
})
}

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

View 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 112</li>
<li> <strong></strong> 0-10 1 </li>
</ul>
</div>
</div>
)
}

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

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

View File

@@ -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: '保存成功',

View File

@@ -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)

View File

@@ -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

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

View 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>使 DeepSeekOpenAI</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>
)
}

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

View File

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

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

View File

@@ -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)

View File

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

View File

@@ -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: '人物信息已更新',

View File

@@ -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: '加载插件列表失败',

View File

@@ -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: '更新失败',

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

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

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

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

View File

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

View 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

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

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

View File

@@ -0,0 +1 @@
export { EmojiManagementPage } from './index.tsx'

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

View 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

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

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

View File

@@ -0,0 +1 @@
export { ExpressionManagementPage } from './index.tsx'

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

View 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

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

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

View File

@@ -0,0 +1 @@
export { JargonManagementPage } from './index.tsx'

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

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

View File

@@ -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 storeMB内存
</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>
)
}

View 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 storeMB内存
</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>
)
}

View File

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

View File

@@ -0,0 +1 @@
export { KnowledgeGraphPage } from './index.tsx'

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

View 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