refactor(routes): split expression.tsx into modular expression/ directory

- Split 1212-line monolithic file into 6 modular files
- Extracted types, dialogs, list view into separate modules
- Main index.tsx (468 lines) handles state management and API integration
- All CRUD operations, stats, review, search, pagination preserved
- Build passes with zero TypeScript errors (3.77s)
This commit is contained in:
DrSmoothl
2026-03-01 20:23:53 +08:00
parent 7866443c9c
commit bacb5b19cd
6 changed files with 1437 additions and 1211 deletions

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
}