1727 lines
69 KiB
TypeScript
1727 lines
69 KiB
TypeScript
/**
|
||
* 表达方式审核器弹窗组件
|
||
*
|
||
* 功能:
|
||
* 1. 分页显示待审核/已通过/已拒绝的表达方式
|
||
* 2. 支持单条通过/拒绝
|
||
* 3. 支持批量操作
|
||
* 4. 冲突检测(防止与AI自动检查冲突)
|
||
*/
|
||
|
||
import { animated, useSpring } from '@react-spring/web'
|
||
|
||
const AnimatedDiv = animated('div')
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import {
|
||
Pagination,
|
||
PaginationContent,
|
||
PaginationEllipsis,
|
||
PaginationItem,
|
||
PaginationLink,
|
||
} from '@/components/ui/pagination'
|
||
import { useToast } from '@/hooks/use-toast'
|
||
import {
|
||
CheckCircle2,
|
||
XCircle,
|
||
Clock,
|
||
Search,
|
||
RefreshCw,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Bot,
|
||
User,
|
||
AlertCircle,
|
||
List,
|
||
Zap,
|
||
X,
|
||
Ban,
|
||
} from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import {
|
||
getReviewStats,
|
||
getReviewList,
|
||
batchReviewExpressions,
|
||
getChatList,
|
||
} from '@/lib/expression-api'
|
||
import type { Expression, ReviewStats, ChatInfo, BatchReviewItem } from '@/types/expression'
|
||
|
||
interface ExpressionReviewerProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
}
|
||
|
||
export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerProps) {
|
||
// 审核模式:list(列表模式)或 quick(快速审核模式)
|
||
const [reviewMode, setReviewMode] = useState<'list' | 'quick'>('list')
|
||
const [stats, setStats] = useState<ReviewStats | null>(null)
|
||
const [expressions, setExpressions] = useState<Expression[]>([])
|
||
|
||
// 快速审核模式状态
|
||
const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked')
|
||
const [quickExpressions, setQuickExpressions] = useState<Expression[]>([])
|
||
const quickExpressionsRef = useRef<Expression[]>([])
|
||
const [quickCurrentIndex, setQuickCurrentIndex] = useState(0)
|
||
const [quickLoading, setQuickLoading] = useState(false)
|
||
const [quickTotal, setQuickTotal] = useState(0)
|
||
const [quickPage, setQuickPage] = useState(1)
|
||
const swipeDirectionRef = useRef<'left' | 'right' | null>(null)
|
||
const isAnimatingRef = useRef(false)
|
||
const [cardSpring, cardApi] = useSpring(() => ({ x: 0, opacity: 1, rotate: 0, config: { tension: 300, friction: 30 } }))
|
||
const swipeOffsetRef = useRef(0)
|
||
const [conflictId, setConflictId] = useState<number | null>(null)
|
||
const cardRef = useRef<HTMLDivElement>(null)
|
||
const dragStartRef = useRef<{ x: number; y: number } | null>(null)
|
||
const isDraggingRef = useRef(false)
|
||
|
||
useEffect(() => {
|
||
quickExpressionsRef.current = quickExpressions
|
||
}, [quickExpressions])
|
||
const [loading, setLoading] = useState(false)
|
||
const [statsLoading, setStatsLoading] = useState(false)
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const [jumpPage, setJumpPage] = useState('')
|
||
const [filterType, setFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked')
|
||
const [search, setSearch] = useState('')
|
||
const [searchInput, setSearchInput] = useState('')
|
||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set())
|
||
const [chatNameMap, setChatNameMap] = useState<Map<string, string>>(new Map())
|
||
const { toast } = useToast()
|
||
|
||
// 加载统计数据
|
||
const loadStats = useCallback(async () => {
|
||
try {
|
||
setStatsLoading(true)
|
||
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 result = await getReviewList({
|
||
page,
|
||
page_size: pageSize,
|
||
filter_type: filterType,
|
||
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)
|
||
}
|
||
}, [page, pageSize, filterType, search, toast])
|
||
|
||
// 加载聚天名称映射
|
||
const loadChatNames = useCallback(async () => {
|
||
try {
|
||
const result = await getChatList()
|
||
if (result.success) {
|
||
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)
|
||
}
|
||
}, [])
|
||
|
||
// 快速审核模式 - 加载数据
|
||
const loadQuickList = useCallback(async (resetIndex = true, append = false) => {
|
||
try {
|
||
setQuickLoading(true)
|
||
const pageToLoad = append ? quickPage + 1 : quickPage
|
||
const result = await getReviewList({
|
||
page: quickFilterType === 'unchecked' ? 1 : pageToLoad,
|
||
page_size: 20,
|
||
filter_type: quickFilterType,
|
||
order: quickFilterType === 'unchecked' ? 'random' : 'latest',
|
||
exclude_ids: quickFilterType === 'unchecked' && append
|
||
? quickExpressionsRef.current.map((expr) => expr.id)
|
||
: undefined,
|
||
})
|
||
|
||
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 {
|
||
toast({
|
||
title: '加载失败',
|
||
description: result.error,
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: '加载失败',
|
||
description: error instanceof Error ? error.message : '无法加载列表',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setQuickLoading(false)
|
||
}
|
||
}, [quickPage, quickFilterType, toast])
|
||
|
||
// 快速审核模式 - 切换筛选时重置
|
||
useEffect(() => {
|
||
if (reviewMode === 'quick') {
|
||
setQuickPage(1)
|
||
setQuickCurrentIndex(0)
|
||
}
|
||
}, [quickFilterType, reviewMode])
|
||
|
||
// 快速审核模式 - 加载数据
|
||
useEffect(() => {
|
||
if (open && reviewMode === 'quick') {
|
||
loadQuickList()
|
||
loadStats()
|
||
}
|
||
}, [open, reviewMode, quickPage, quickFilterType, loadQuickList, loadStats])
|
||
|
||
// 获取当前卡片允许的滑动方向
|
||
const getAllowedDirections = useCallback((expr: Expression | undefined) => {
|
||
if (!expr) return { left: false, right: false }
|
||
|
||
if (quickFilterType === 'unchecked') {
|
||
// 待审核:左拒绝,右通过
|
||
return { left: true, right: true }
|
||
} else if (quickFilterType === 'passed') {
|
||
// 已通过:只能左滑改为拒绝
|
||
return { left: true, right: false }
|
||
} else if (quickFilterType === 'rejected') {
|
||
// 已拒绝:只能右滑改为通过
|
||
return { left: false, right: true }
|
||
} else {
|
||
// 全部:智能判断
|
||
if (!expr.checked) {
|
||
// 未审核:双向
|
||
return { left: true, right: true }
|
||
} else if (expr.rejected) {
|
||
// 已拒绝:只能右滑
|
||
return { left: false, right: true }
|
||
} else {
|
||
// 已通过:只能左滑
|
||
return { left: true, right: false }
|
||
}
|
||
}
|
||
}, [quickFilterType])
|
||
|
||
// 快速审核 - 执行审核操作
|
||
const handleQuickReview = useCallback(async (rejected: boolean) => {
|
||
const currentExpr = quickExpressions[quickCurrentIndex]
|
||
if (!currentExpr || isAnimatingRef.current) return
|
||
|
||
const directions = getAllowedDirections(currentExpr)
|
||
if ((rejected && !directions.left) || (!rejected && !directions.right)) {
|
||
return
|
||
}
|
||
|
||
isAnimatingRef.current = true
|
||
swipeDirectionRef.current = rejected ? 'left' : 'right'
|
||
cardApi.start({ x: rejected ? -400 : 400, rotate: rejected ? -20 : 20, opacity: 0 })
|
||
|
||
try {
|
||
const result = await batchReviewExpressions([{
|
||
id: currentExpr.id,
|
||
rejected,
|
||
require_unchecked: quickFilterType === 'unchecked',
|
||
}])
|
||
|
||
if (!result.success) {
|
||
toast({
|
||
title: '操作失败',
|
||
description: result.error,
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
if (result.data.results[0]?.success) {
|
||
toast({
|
||
title: rejected ? '已拒绝' : '已通过',
|
||
description: `表达方式 #${currentExpr.id} ${rejected ? '已拒绝' : '已通过'}`,
|
||
})
|
||
|
||
// 从列表中移除当前项
|
||
setTimeout(() => {
|
||
setQuickExpressions(prev => prev.filter((_, i) => i !== quickCurrentIndex))
|
||
setQuickTotal(prev => prev - 1)
|
||
|
||
// 如果当前索引超出范围,调整索引
|
||
if (quickCurrentIndex >= quickExpressions.length - 1) {
|
||
setQuickCurrentIndex(Math.max(0, quickCurrentIndex - 1))
|
||
}
|
||
|
||
// 重置状态
|
||
swipeDirectionRef.current = null
|
||
swipeOffsetRef.current = 0
|
||
cardApi.set({ x: 0, opacity: 1, rotate: 0 })
|
||
isAnimatingRef.current = false
|
||
|
||
// 刷新统计
|
||
loadStats()
|
||
|
||
// 如果列表为空且还有更多数据,加载下一页
|
||
if (quickExpressions.length <= 1 && quickTotal > 1) {
|
||
loadQuickList(false)
|
||
}
|
||
}, 300)
|
||
} else {
|
||
// 冲突处理
|
||
setConflictId(currentExpr.id)
|
||
toast({
|
||
title: '数据冲突',
|
||
description: '该条目已被后台任务处理,正在刷新数据...',
|
||
variant: 'destructive',
|
||
})
|
||
|
||
// 播放冲突动画后刷新
|
||
setTimeout(() => {
|
||
setConflictId(null)
|
||
swipeDirectionRef.current = null
|
||
swipeOffsetRef.current = 0
|
||
cardApi.set({ x: 0, opacity: 1, rotate: 0 })
|
||
isAnimatingRef.current = false
|
||
loadQuickList(false) // 重新加载当前页
|
||
loadStats()
|
||
}, 1500)
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: '操作失败',
|
||
description: error instanceof Error ? error.message : '未知错误',
|
||
variant: 'destructive',
|
||
})
|
||
swipeDirectionRef.current = null
|
||
swipeOffsetRef.current = 0
|
||
cardApi.set({ x: 0, opacity: 1, rotate: 0 })
|
||
isAnimatingRef.current = false
|
||
}
|
||
}, [quickExpressions, quickCurrentIndex, isAnimatingRef, getAllowedDirections, quickFilterType, toast, loadStats, quickTotal, loadQuickList])
|
||
|
||
// 拖拽开始
|
||
const handleDragStart = useCallback((clientX: number, clientY: number) => {
|
||
if (isAnimatingRef.current) return
|
||
dragStartRef.current = { x: clientX, y: clientY }
|
||
isDraggingRef.current = false
|
||
}, [isAnimatingRef])
|
||
|
||
// 触发无效操作动画
|
||
const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => {
|
||
if (isAnimatingRef.current) return
|
||
isAnimatingRef.current = true
|
||
// 模拟向该方向移动一点
|
||
cardApi.start({ x: direction === 'left' ? -30 : 30, immediate: true })
|
||
|
||
setTimeout(() => {
|
||
cardApi.start({ x: 0 })
|
||
setTimeout(() => { isAnimatingRef.current = false }, 300)
|
||
}, 150)
|
||
}, [cardApi])
|
||
|
||
// 拖拽移动
|
||
const handleDragMove = useCallback((clientX: number) => {
|
||
if (!dragStartRef.current || isAnimatingRef.current) return
|
||
|
||
const deltaX = clientX - dragStartRef.current.x
|
||
const currentExpr = quickExpressions[quickCurrentIndex]
|
||
const directions = getAllowedDirections(currentExpr)
|
||
|
||
// 检查方向限制
|
||
if (deltaX < 0 && !directions.left) {
|
||
cardApi.start({ x: deltaX * 0.2, immediate: true }) // 提供阻力反馈
|
||
swipeOffsetRef.current = deltaX * 0.2
|
||
swipeDirectionRef.current = null
|
||
return
|
||
}
|
||
if (deltaX > 0 && !directions.right) {
|
||
cardApi.start({ x: deltaX * 0.2, immediate: true })
|
||
swipeOffsetRef.current = deltaX * 0.2
|
||
swipeDirectionRef.current = null
|
||
return
|
||
}
|
||
|
||
isDraggingRef.current = true
|
||
swipeOffsetRef.current = deltaX
|
||
cardApi.start({ x: deltaX, rotate: deltaX * 0.05, opacity: Math.max(0, 1 - Math.abs(deltaX) / 500), immediate: true })
|
||
cardApi.start({ x: deltaX, rotate: deltaX * 0.05, opacity: Math.max(0, 1 - Math.abs(deltaX) / 500), immediate: true })
|
||
|
||
if (Math.abs(deltaX) > 50) {
|
||
swipeDirectionRef.current = deltaX > 0 ? 'right' : 'left'
|
||
} else {
|
||
swipeDirectionRef.current = null
|
||
}
|
||
}, [quickExpressions, quickCurrentIndex, getAllowedDirections, cardApi])
|
||
|
||
// 拖拽结束
|
||
const handleDragEnd = useCallback(() => {
|
||
if (!dragStartRef.current) return
|
||
|
||
const threshold = 100
|
||
const currentX = cardSpring.x.get()
|
||
if (Math.abs(currentX) > threshold && swipeDirectionRef.current) {
|
||
handleQuickReview(swipeDirectionRef.current === 'left')
|
||
} else {
|
||
// 回弹
|
||
cardApi.start({ x: 0, rotate: 0, opacity: 1 })
|
||
swipeOffsetRef.current = 0
|
||
swipeDirectionRef.current = null
|
||
}
|
||
|
||
dragStartRef.current = null
|
||
isDraggingRef.current = false
|
||
}, [cardSpring.x, handleQuickReview, cardApi])
|
||
|
||
// 鼠标事件处理
|
||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
handleDragStart(e.clientX, e.clientY)
|
||
}, [handleDragStart])
|
||
|
||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||
if (dragStartRef.current) {
|
||
e.preventDefault()
|
||
handleDragMove(e.clientX)
|
||
}
|
||
}, [handleDragMove])
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
handleDragEnd()
|
||
}, [handleDragEnd])
|
||
|
||
const handleMouseLeave = useCallback(() => {
|
||
if (dragStartRef.current) {
|
||
handleDragEnd()
|
||
}
|
||
}, [handleDragEnd])
|
||
|
||
// 触摸事件处理
|
||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||
const touch = e.touches[0]
|
||
handleDragStart(touch.clientX, touch.clientY)
|
||
}, [handleDragStart])
|
||
|
||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||
const touch = e.touches[0]
|
||
handleDragMove(touch.clientX)
|
||
}, [handleDragMove])
|
||
|
||
const handleTouchEnd = useCallback(() => {
|
||
handleDragEnd()
|
||
}, [handleDragEnd])
|
||
|
||
// 键盘事件处理
|
||
useEffect(() => {
|
||
if (!open || reviewMode !== 'quick') return
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// 只处理方向键
|
||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
|
||
|
||
// 阻止事件继续传播,避免被 Tabs 组件捕获
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
e.stopImmediatePropagation()
|
||
|
||
if (isAnimatingRef.current || quickLoading) return
|
||
|
||
const currentExpr = quickExpressions[quickCurrentIndex]
|
||
const directions = getAllowedDirections(currentExpr)
|
||
|
||
if (e.key === 'ArrowLeft') {
|
||
if (directions.left) {
|
||
handleQuickReview(true) // 拒绝
|
||
} else {
|
||
triggerInvalidAnimation('left')
|
||
}
|
||
} else if (e.key === 'ArrowRight') {
|
||
if (directions.right) {
|
||
handleQuickReview(false) // 通过
|
||
} else {
|
||
triggerInvalidAnimation('right')
|
||
}
|
||
} else if (e.key === 'ArrowDown') {
|
||
// 跳过当前项
|
||
if (quickCurrentIndex < quickExpressions.length - 1) {
|
||
setQuickCurrentIndex(prev => prev + 1)
|
||
}
|
||
} else if (e.key === 'ArrowUp') {
|
||
// 返回上一项
|
||
if (quickCurrentIndex > 0) {
|
||
setQuickCurrentIndex(prev => prev - 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用 capture 模式,在事件到达 Tabs 之前拦截
|
||
window.addEventListener('keydown', handleKeyDown, true)
|
||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||
}, [open, reviewMode, quickExpressions, quickCurrentIndex, isAnimatingRef, quickLoading, getAllowedDirections, handleQuickReview, triggerInvalidAnimation])
|
||
|
||
// 动态加载更多数据 - 当接近列表末尾时自动加载
|
||
useEffect(() => {
|
||
if (!open || reviewMode !== 'quick' || quickLoading) return
|
||
|
||
// 距离末尾还有5个或更少时,且还有更多数据时,自动加载
|
||
const remaining = quickExpressions.length - quickCurrentIndex - 1
|
||
const hasMoreData = quickExpressions.length < quickTotal
|
||
|
||
if (remaining <= 5 && hasMoreData) {
|
||
loadQuickList(false, true) // 追加模式
|
||
}
|
||
}, [open, reviewMode, quickCurrentIndex, quickExpressions.length, quickTotal, quickLoading, loadQuickList])
|
||
|
||
// 初始加载
|
||
useEffect(() => {
|
||
if (open) {
|
||
loadStats()
|
||
loadList()
|
||
loadChatNames()
|
||
}
|
||
}, [open, loadStats, loadList, loadChatNames])
|
||
|
||
// 切换筛选时重置页码
|
||
useEffect(() => {
|
||
setPage(1)
|
||
setSelectedIds(new Set())
|
||
}, [filterType, search])
|
||
|
||
// 列表加载时清空选择
|
||
useEffect(() => {
|
||
setSelectedIds(new Set())
|
||
}, [expressions])
|
||
|
||
// 搜索处理
|
||
const handleSearch = () => {
|
||
setSearch(searchInput)
|
||
setPage(1)
|
||
}
|
||
|
||
// 获取聊天名称
|
||
const getChatName = (expression: Expression): string => {
|
||
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
|
||
}
|
||
|
||
// 单条审核
|
||
const handleReview = async (id: number, rejected: boolean) => {
|
||
try {
|
||
setProcessingIds((prev) => new Set(prev).add(id))
|
||
|
||
const result = await batchReviewExpressions([
|
||
{ id, rejected, require_unchecked: filterType === 'unchecked' }
|
||
])
|
||
|
||
if (!result.success) {
|
||
toast({
|
||
title: '操作失败',
|
||
description: result.error,
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
if (result.data.results[0]?.success) {
|
||
toast({
|
||
title: rejected ? '已拒绝' : '已通过',
|
||
description: `表达方式 #${id} ${rejected ? '已拒绝' : '已通过'}`,
|
||
})
|
||
// 刷新列表和统计
|
||
loadList()
|
||
loadStats()
|
||
} else {
|
||
toast({
|
||
title: '操作失败',
|
||
description: result.data.results[0]?.message || '未知错误',
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: '操作失败',
|
||
description: error instanceof Error ? error.message : '未知错误',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setProcessingIds((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(id)
|
||
return next
|
||
})
|
||
}
|
||
}
|
||
|
||
// 批量审核
|
||
const handleBatchReview = async (rejected: boolean) => {
|
||
if (selectedIds.size === 0) {
|
||
toast({
|
||
title: '请选择',
|
||
description: '请先选择要审核的表达方式',
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
setLoading(true)
|
||
|
||
const items: BatchReviewItem[] = Array.from(selectedIds).map((id) => ({
|
||
id,
|
||
rejected,
|
||
require_unchecked: filterType === 'unchecked',
|
||
}))
|
||
|
||
const result = await batchReviewExpressions(items)
|
||
|
||
if (!result.success) {
|
||
toast({
|
||
title: '批量审核失败',
|
||
description: result.error,
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
toast({
|
||
title: '批量审核完成',
|
||
description: `成功 ${result.data.succeeded} 条,失败 ${result.data.failed} 条`,
|
||
variant: result.data.failed > 0 ? 'destructive' : 'default',
|
||
})
|
||
|
||
// 清空选择并刷新
|
||
setSelectedIds(new Set())
|
||
loadList()
|
||
loadStats()
|
||
} catch (error) {
|
||
toast({
|
||
title: '批量审核失败',
|
||
description: error instanceof Error ? error.message : '未知错误',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// 全选/取消全选
|
||
const handleSelectAll = () => {
|
||
if (selectedIds.size === expressions.length) {
|
||
setSelectedIds(new Set())
|
||
} else {
|
||
setSelectedIds(new Set(expressions.map((e) => e.id)))
|
||
}
|
||
}
|
||
|
||
// 切换选择
|
||
const toggleSelect = (id: number) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) {
|
||
next.delete(id)
|
||
} else {
|
||
next.add(id)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (timestamp: number | null) => {
|
||
if (!timestamp) return '-'
|
||
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 获取状态标签
|
||
const getStatusBadge = (expr: Expression) => {
|
||
if (!expr.checked) {
|
||
return (
|
||
<Badge variant="outline" className="gap-1">
|
||
<Clock className="h-3 w-3" />
|
||
待审核
|
||
</Badge>
|
||
)
|
||
}
|
||
if (expr.rejected) {
|
||
return (
|
||
<Badge variant="destructive" className="gap-1">
|
||
<XCircle className="h-3 w-3" />
|
||
已拒绝
|
||
</Badge>
|
||
)
|
||
}
|
||
return (
|
||
<Badge variant="default" className="gap-1 bg-green-600">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
已通过
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
// 获取修改者标签
|
||
const getModifierBadge = (modifier: string | null) => {
|
||
if (!modifier) return null
|
||
if (modifier === 'ai') {
|
||
return (
|
||
<Badge variant="secondary" className="gap-1 text-xs">
|
||
<Bot className="h-3 w-3" />
|
||
AI
|
||
</Badge>
|
||
)
|
||
}
|
||
return (
|
||
<Badge variant="secondary" className="gap-1 text-xs">
|
||
<User className="h-3 w-3" />
|
||
人工
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / pageSize)
|
||
|
||
// 生成页码数组
|
||
const getPageNumbers = () => {
|
||
const pages: (number | 'ellipsis')[] = []
|
||
if (totalPages <= 7) {
|
||
// 总页数不多,全部显示
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
pages.push(i)
|
||
}
|
||
} else {
|
||
// 总是显示第一页
|
||
pages.push(1)
|
||
|
||
if (page > 3) {
|
||
pages.push('ellipsis')
|
||
}
|
||
|
||
// 当前页附近的页码
|
||
const start = Math.max(2, page - 1)
|
||
const end = Math.min(totalPages - 1, page + 1)
|
||
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i)
|
||
}
|
||
|
||
if (page < totalPages - 2) {
|
||
pages.push('ellipsis')
|
||
}
|
||
|
||
// 总是显示最后一页
|
||
if (totalPages > 1) {
|
||
pages.push(totalPages)
|
||
}
|
||
}
|
||
return pages
|
||
}
|
||
|
||
// 处理页码跳转
|
||
const handleJumpPage = () => {
|
||
const targetPage = parseInt(jumpPage, 10)
|
||
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) {
|
||
setPage(targetPage)
|
||
setJumpPage('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-5xl w-[95vw] sm:w-full h-[90vh] sm:h-[85vh] flex flex-col p-0" hideCloseButton>
|
||
{/* 浏览器标签页风格的模式切换器 */}
|
||
<div className="flex items-end bg-muted/30 px-2 pt-2 shrink-0">
|
||
{/* 列表模式标签 */}
|
||
<button
|
||
onClick={() => setReviewMode('list')}
|
||
className={cn(
|
||
'group relative flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-all',
|
||
'hover:bg-background/50',
|
||
reviewMode === 'list'
|
||
? 'bg-background text-foreground shadow-sm border border-b-0 border-border'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
)}
|
||
>
|
||
<List className="h-4 w-4" />
|
||
<span>列表模式</span>
|
||
{reviewMode === 'list' && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-background" />
|
||
)}
|
||
</button>
|
||
|
||
{/* 快速审核标签 */}
|
||
<button
|
||
onClick={() => setReviewMode('quick')}
|
||
className={cn(
|
||
'group relative flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-all',
|
||
'hover:bg-background/50',
|
||
reviewMode === 'quick'
|
||
? 'bg-background text-foreground shadow-sm border border-b-0 border-border'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
)}
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
<span>快速审核</span>
|
||
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
|
||
新
|
||
</Badge>
|
||
{reviewMode === 'quick' && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-background" />
|
||
)}
|
||
</button>
|
||
|
||
{/* 右侧空白区域和关闭按钮 */}
|
||
<div className="flex-1 border-b border-border" />
|
||
<button
|
||
onClick={() => onOpenChange(false)}
|
||
className="mb-[1px] p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 列表模式内容 */}
|
||
{reviewMode === 'list' && (
|
||
<>
|
||
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-6 pb-4 border-b shrink-0">
|
||
<DialogTitle className="text-lg sm:text-xl">表达方式审核</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">
|
||
审核麦麦学习到的表达方式。通过审核的项目才会被使用(可在配置中调整),被拒绝的项目永远不会被使用。
|
||
</DialogDescription>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 mt-4">
|
||
<div className="rounded-lg border p-2 sm:p-3 text-center">
|
||
<div className="text-xl sm:text-2xl font-bold text-orange-500">
|
||
{statsLoading ? '-' : stats?.unchecked ?? 0}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">待审核</div>
|
||
</div>
|
||
<div className="rounded-lg border p-2 sm:p-3 text-center">
|
||
<div className="text-xl sm:text-2xl font-bold text-green-500">
|
||
{statsLoading ? '-' : stats?.passed ?? 0}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">已通过</div>
|
||
</div>
|
||
<div className="rounded-lg border p-2 sm:p-3 text-center">
|
||
<div className="text-xl sm:text-2xl font-bold text-red-500">
|
||
{statsLoading ? '-' : stats?.rejected ?? 0}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">已拒绝</div>
|
||
</div>
|
||
<div className="rounded-lg border p-2 sm:p-3 text-center">
|
||
<div className="text-xl sm:text-2xl font-bold text-blue-500">
|
||
{statsLoading ? '-' : stats?.total ?? 0}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">总计</div>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
{/* 筛选和操作栏 */}
|
||
<div className="px-4 sm:px-6 py-3 border-b shrink-0 space-y-3">
|
||
<Tabs
|
||
value={filterType}
|
||
onValueChange={(v) => setFilterType(v as typeof filterType)}
|
||
className="w-full"
|
||
>
|
||
<TabsList className="grid w-full grid-cols-4">
|
||
<TabsTrigger value="unchecked" className="gap-1 text-xs sm:text-sm px-1 sm:px-3">
|
||
<Clock className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">待审核</span>
|
||
<span className="sm:hidden">待审</span>
|
||
<span className="hidden sm:inline">({stats?.unchecked ?? 0})</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="passed" className="gap-1 text-xs sm:text-sm px-1 sm:px-3">
|
||
<CheckCircle2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">已通过</span>
|
||
<span className="sm:hidden">通过</span>
|
||
<span className="hidden sm:inline">({stats?.passed ?? 0})</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="rejected" className="gap-1 text-xs sm:text-sm px-1 sm:px-3">
|
||
<XCircle className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">已拒绝</span>
|
||
<span className="sm:hidden">拒绝</span>
|
||
<span className="hidden sm:inline">({stats?.rejected ?? 0})</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="all" className="gap-1 text-xs sm:text-sm px-1 sm:px-3">
|
||
<span>全部</span>
|
||
<span className="hidden sm:inline">({stats?.total ?? 0})</span>
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索情景或风格..."
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||
<Search className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => {
|
||
loadList()
|
||
loadStats()
|
||
}}
|
||
disabled={loading}
|
||
>
|
||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 批量操作按钮 */}
|
||
{selectedIds.size > 0 && (
|
||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||
{filterType === 'unchecked' ? (
|
||
// 待审核:显示批量通过和批量拒绝
|
||
<>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="bg-green-600 hover:bg-green-700 flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(false)}
|
||
disabled={loading}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量通过</span>
|
||
<span className="sm:hidden">通过</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(true)}
|
||
disabled={loading}
|
||
>
|
||
<XCircle className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量拒绝</span>
|
||
<span className="sm:hidden">拒绝</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
</>
|
||
) : filterType === 'passed' ? (
|
||
// 已通过:只显示批量改为拒绝
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(true)}
|
||
disabled={loading}
|
||
>
|
||
<XCircle className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量改为拒绝</span>
|
||
<span className="sm:hidden">改为拒绝</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
) : filterType === 'rejected' ? (
|
||
// 已拒绝:只显示批量改为通过
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="bg-green-600 hover:bg-green-700 flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(false)}
|
||
disabled={loading}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量改为通过</span>
|
||
<span className="sm:hidden">改为通过</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
) : (
|
||
// 全部:显示两个按钮
|
||
<>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="bg-green-600 hover:bg-green-700 flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(false)}
|
||
disabled={loading}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量通过</span>
|
||
<span className="sm:hidden">通过</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="flex-1 sm:flex-none"
|
||
onClick={() => handleBatchReview(true)}
|
||
disabled={loading}
|
||
>
|
||
<XCircle className="h-4 w-4 mr-1" />
|
||
<span className="hidden sm:inline">批量拒绝</span>
|
||
<span className="sm:hidden">拒绝</span>
|
||
({selectedIds.size})
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 列表区域 */}
|
||
<ScrollArea className="flex-1 px-4 sm:px-6">
|
||
{loading && expressions.length === 0 ? (
|
||
<div className="flex items-center justify-center h-40">
|
||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : expressions.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
|
||
<AlertCircle className="h-8 w-8 mb-2" />
|
||
<p>没有找到表达方式</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2 py-2">
|
||
{/* 全选 */}
|
||
{expressions.length > 0 && (
|
||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={selectedIds.size === expressions.length && expressions.length > 0}
|
||
onCheckedChange={handleSelectAll}
|
||
/>
|
||
<span className="text-sm text-muted-foreground">
|
||
{selectedIds.size === expressions.length && expressions.length > 0
|
||
? `已全选当前页 (${expressions.length} 条)`
|
||
: `全选当前页 (${expressions.length} 条)`}
|
||
</span>
|
||
</div>
|
||
{selectedIds.size > 0 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setSelectedIds(new Set())}
|
||
className="h-7 text-xs"
|
||
>
|
||
取消选择
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 表达方式列表 */}
|
||
{expressions.map((expr) => (
|
||
<div
|
||
key={expr.id}
|
||
className={cn(
|
||
'rounded-lg border p-3 sm:p-4 space-y-2 sm:space-y-3 transition-colors',
|
||
selectedIds.has(expr.id) && 'bg-accent border-primary',
|
||
processingIds.has(expr.id) && 'opacity-50'
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2 sm:gap-3">
|
||
{/* 选择框 */}
|
||
<Checkbox
|
||
checked={selectedIds.has(expr.id)}
|
||
onCheckedChange={() => toggleSelect(expr.id)}
|
||
disabled={processingIds.has(expr.id)}
|
||
className="mt-1"
|
||
/>
|
||
|
||
{/* 内容 */}
|
||
<div className="flex-1 min-w-0 space-y-2">
|
||
{/* 情景 */}
|
||
<div>
|
||
<span className="text-xs text-muted-foreground">情景:</span>
|
||
<p className="text-sm font-medium break-words">{expr.situation}</p>
|
||
</div>
|
||
|
||
{/* 风格 */}
|
||
<div>
|
||
<span className="text-xs text-muted-foreground">风格:</span>
|
||
<p className="text-sm text-muted-foreground break-words">{expr.style}</p>
|
||
</div>
|
||
|
||
{/* 元信息 */}
|
||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-muted-foreground">
|
||
<span>#{expr.id}</span>
|
||
<span>·</span>
|
||
<span title={getChatName(expr)} className="truncate max-w-24 sm:max-w-32">
|
||
{getChatName(expr)}
|
||
</span>
|
||
<span>·</span>
|
||
<span>{formatTime(expr.create_date)}</span>
|
||
<div className="flex items-center gap-1">
|
||
{getStatusBadge(expr)}
|
||
{getModifierBadge(expr.modified_by)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex flex-col gap-1 sm:gap-2 shrink-0">
|
||
{filterType === 'unchecked' ? (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-green-600 hover:text-green-700 hover:bg-green-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, false)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">通过</span>
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, true)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<XCircle className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">拒绝</span>
|
||
</Button>
|
||
</>
|
||
) : filterType === 'passed' ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, true)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<XCircle className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">改为拒绝</span>
|
||
</Button>
|
||
) : filterType === 'rejected' ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-green-600 hover:text-green-700 hover:bg-green-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, false)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">改为通过</span>
|
||
</Button>
|
||
) : (
|
||
// all 模式下显示两个按钮
|
||
<>
|
||
{expr.rejected ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-green-600 hover:text-green-700 hover:bg-green-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, false)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">改为通过</span>
|
||
</Button>
|
||
) : expr.checked ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, true)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<XCircle className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">改为拒绝</span>
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-green-600 hover:text-green-700 hover:bg-green-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, false)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">通过</span>
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 sm:h-9 px-2 sm:px-3"
|
||
onClick={() => handleReview(expr.id, true)}
|
||
disabled={processingIds.has(expr.id)}
|
||
>
|
||
<XCircle className="h-4 w-4 sm:mr-1" />
|
||
<span className="hidden sm:inline">拒绝</span>
|
||
</Button>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
|
||
{/* 分页 */}
|
||
<div className="px-4 sm:px-6 py-3 border-t shrink-0 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||
{/* 左侧:每页显示数量 */}
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<span className="hidden sm:inline">每页</span>
|
||
<Select
|
||
value={pageSize.toString()}
|
||
onValueChange={(v) => {
|
||
setPageSize(parseInt(v, 10))
|
||
setPage(1) // 切换每页数量时重置到第一页
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-[70px] h-8">
|
||
<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="hidden sm:inline">条</span>
|
||
<span className="text-muted-foreground">共 {total} 条</span>
|
||
</div>
|
||
|
||
{/* 中间:页码导航 */}
|
||
<Pagination className="mx-0 w-auto">
|
||
<PaginationContent>
|
||
<PaginationItem>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={page <= 1 || loading}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
</PaginationItem>
|
||
|
||
{getPageNumbers().map((pageNum, idx) => (
|
||
<PaginationItem key={idx}>
|
||
{pageNum === 'ellipsis' ? (
|
||
<PaginationEllipsis />
|
||
) : (
|
||
<PaginationLink
|
||
href="#"
|
||
isActive={pageNum === page}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
setPage(pageNum)
|
||
}}
|
||
className="h-8 w-8 cursor-pointer"
|
||
>
|
||
{pageNum}
|
||
</PaginationLink>
|
||
)}
|
||
</PaginationItem>
|
||
))}
|
||
|
||
<PaginationItem>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={page >= totalPages || loading}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</PaginationItem>
|
||
</PaginationContent>
|
||
</Pagination>
|
||
|
||
{/* 右侧:跳转 */}
|
||
<div className="hidden sm:flex items-center gap-2 text-sm">
|
||
<span className="text-muted-foreground">跳至</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={totalPages}
|
||
value={jumpPage}
|
||
onChange={(e) => setJumpPage(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleJumpPage()}
|
||
className="w-16 h-8 text-center"
|
||
placeholder={page.toString()}
|
||
/>
|
||
<span className="text-muted-foreground">页</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={handleJumpPage}
|
||
disabled={loading}
|
||
>
|
||
跳转
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 快速审核模式内容 */}
|
||
{reviewMode === 'quick' && (
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{/* 顶部筛选和统计 */}
|
||
<div className="px-4 sm:px-6 py-3 border-b shrink-0 space-y-3">
|
||
{/* 统计信息 */}
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-4">
|
||
<span className="text-muted-foreground">
|
||
待审核: <span className="font-medium text-orange-500">{stats?.unchecked ?? 0}</span>
|
||
</span>
|
||
<span className="text-muted-foreground">
|
||
已通过: <span className="font-medium text-green-500">{stats?.passed ?? 0}</span>
|
||
</span>
|
||
<span className="text-muted-foreground">
|
||
已拒绝: <span className="font-medium text-red-500">{stats?.rejected ?? 0}</span>
|
||
</span>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
loadQuickList()
|
||
loadStats()
|
||
}}
|
||
disabled={quickLoading}
|
||
>
|
||
<RefreshCw className={cn('h-4 w-4 mr-1', quickLoading && 'animate-spin')} />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 筛选标签 */}
|
||
<Tabs
|
||
value={quickFilterType}
|
||
onValueChange={(v) => setQuickFilterType(v as typeof quickFilterType)}
|
||
className="w-full"
|
||
>
|
||
<TabsList className="grid w-full grid-cols-4">
|
||
<TabsTrigger value="unchecked" className="gap-1 text-xs sm:text-sm">
|
||
<Clock className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">待审核</span>
|
||
<span className="sm:hidden">待审</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="passed" className="gap-1 text-xs sm:text-sm">
|
||
<CheckCircle2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">已通过</span>
|
||
<span className="sm:hidden">通过</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="rejected" className="gap-1 text-xs sm:text-sm">
|
||
<XCircle className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">已拒绝</span>
|
||
<span className="sm:hidden">拒绝</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="all" className="gap-1 text-xs sm:text-sm">
|
||
全部
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
|
||
{/* 卡片区域 */}
|
||
<div className="flex-1 flex flex-col items-center justify-center p-4 sm:p-8 relative overflow-hidden">
|
||
{quickLoading && quickExpressions.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center">
|
||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground mb-4" />
|
||
<p className="text-muted-foreground">加载中...</p>
|
||
</div>
|
||
) : quickExpressions.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center text-center">
|
||
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center mb-6">
|
||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||
</div>
|
||
<h3 className="text-xl font-semibold mb-2">全部审核完成!</h3>
|
||
<p className="text-muted-foreground">当前筛选条件下没有待处理的项目</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 进度提示 */}
|
||
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-sm text-muted-foreground z-50">
|
||
{quickCurrentIndex + 1} / {quickExpressions.length}
|
||
{quickTotal > quickExpressions.length && (
|
||
<span className="ml-1">(共 {quickTotal} 条)</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 方向提示 (仅针对当前卡片) */}
|
||
<div className="absolute inset-x-4 top-1/2 -translate-y-1/2 flex justify-between pointer-events-none z-40">
|
||
{(() => {
|
||
const currentExpr = quickExpressions[quickCurrentIndex]
|
||
const directions = getAllowedDirections(currentExpr)
|
||
return (
|
||
<>
|
||
<div className={cn(
|
||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300',
|
||
swipeDirectionRef.current === 'left' ? 'bg-red-500/20 text-red-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0',
|
||
!directions.left && 'invisible'
|
||
)}>
|
||
<XCircle className="h-8 w-8" />
|
||
<span className="font-bold text-lg hidden sm:inline">拒绝</span>
|
||
</div>
|
||
<div className={cn(
|
||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-300',
|
||
swipeDirectionRef.current === 'right' ? 'bg-green-500/20 text-green-500 scale-110' : 'bg-muted/50 text-muted-foreground opacity-0',
|
||
!directions.right && 'invisible'
|
||
)}>
|
||
<span className="font-bold text-lg hidden sm:inline">通过</span>
|
||
<CheckCircle2 className="h-8 w-8" />
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* 堆叠卡片 */}
|
||
<div
|
||
className="relative w-full max-w-md h-[400px] flex items-center justify-center"
|
||
role="listbox"
|
||
tabIndex={0}
|
||
aria-label="待审核的表达方式"
|
||
aria-activedescendant={quickExpressions[quickCurrentIndex] ? `quick-expr-${quickExpressions[quickCurrentIndex].id}` : undefined}
|
||
>
|
||
{quickExpressions
|
||
.slice(quickCurrentIndex, quickCurrentIndex + 5)
|
||
.reverse()
|
||
.map((expr, reverseIndex, array) => {
|
||
const index = array.length - 1 - reverseIndex // 0 is current, 1 is next...
|
||
const isCurrent = index === 0
|
||
|
||
// 计算样式
|
||
let style: React.CSSProperties = {
|
||
zIndex: 5 - index,
|
||
position: 'absolute',
|
||
width: '100%',
|
||
transition: isCurrent && !isDraggingRef.current ? 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' : 'none',
|
||
}
|
||
|
||
if (isCurrent) {
|
||
// 当前卡片:样式由 useSpring 控制,通过 animated.div 渲染
|
||
// style 仅保留非动画属性
|
||
style = {
|
||
...style,
|
||
cursor: 'grab',
|
||
}
|
||
} else {
|
||
// 后方卡片样式
|
||
const progress = Math.min(Math.abs(swipeOffsetRef.current) / 200, 1) // 0 to 1
|
||
|
||
// 计算指定索引的样式属性
|
||
const getStyleForIndex = (i: number) => {
|
||
// 增加一些伪随机的错位感,让堆叠看起来不那么死板
|
||
const randomRotate = (i * 7) % 5
|
||
const randomX = (i * 13) % 7
|
||
|
||
return {
|
||
scale: 1 - i * 0.05,
|
||
translateY: i * 12,
|
||
// 错位效果:奇偶交替旋转 + 伪随机偏移
|
||
rotate: (i % 2 === 0 ? 1 : -1) * (i * 2) + randomRotate,
|
||
translateX: (i % 2 === 0 ? -1 : 1) * (i * 4) + randomX,
|
||
}
|
||
}
|
||
|
||
const base = getStyleForIndex(index)
|
||
const target = getStyleForIndex(index - 1)
|
||
|
||
// 插值计算:所有后方卡片都会跟随第一张卡片的滑动而向前移动
|
||
const currentScale = base.scale + (target.scale - base.scale) * progress
|
||
const currentTranslateY = base.translateY + (target.translateY - base.translateY) * progress
|
||
const currentRotate = base.rotate + (target.rotate - base.rotate) * progress
|
||
const currentTranslateX = base.translateX + (target.translateX - base.translateX) * progress
|
||
|
||
style = {
|
||
...style,
|
||
transform: `translate3d(${currentTranslateX}px, ${currentTranslateY}px, 0) scale(${currentScale}) rotate(${currentRotate}deg)`,
|
||
opacity: 1 - index * 0.15,
|
||
filter: `blur(${Math.max(0, index * 1 - progress)}px)`, // 模糊度也随之减小
|
||
pointerEvents: 'none',
|
||
}
|
||
}
|
||
|
||
return isCurrent ? (
|
||
<AnimatedDiv
|
||
key={expr.id}
|
||
ref={cardRef}
|
||
role="option"
|
||
id={`quick-expr-${expr.id}`}
|
||
aria-selected={true}
|
||
className={cn(
|
||
'bg-card border rounded-xl shadow-xl p-6 select-none h-full flex flex-col',
|
||
'active:cursor-grabbing shadow-2xl ring-1 ring-border/50',
|
||
// 冲突动效
|
||
conflictId === expr.id && 'ring-4 ring-orange-500/50 bg-orange-50/10'
|
||
)}
|
||
style={{ ...style, ...cardSpring }}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseLeave}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchMove={handleTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
>
|
||
{/* 冲突提示遮罩 */}
|
||
{conflictId === expr.id && (
|
||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm animate-in fade-in duration-300 rounded-xl">
|
||
<div className="relative">
|
||
<div className="absolute inset-0 bg-orange-500/20 rounded-full animate-ping" />
|
||
<RefreshCw className="relative h-16 w-16 text-orange-500 mb-4 animate-spin duration-1000" />
|
||
</div>
|
||
<h3 className="text-xl font-bold text-foreground animate-in slide-in-from-bottom-2 fade-in duration-500">数据已更新</h3>
|
||
<p className="text-muted-foreground mt-2 animate-in slide-in-from-bottom-3 fade-in duration-700">后台任务已处理此条目</p>
|
||
</div>
|
||
)}
|
||
{/* 无效操作提示 */}
|
||
<div className={cn(
|
||
"absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-opacity duration-200",
|
||
((swipeOffsetRef.current < -10 && !getAllowedDirections(expr).left) || (swipeOffsetRef.current > 10 && !getAllowedDirections(expr).right))
|
||
? "opacity-100"
|
||
: "opacity-0"
|
||
)}>
|
||
<div className="bg-background/80 backdrop-blur-sm p-4 rounded-full shadow-lg border border-border">
|
||
<Ban className="h-12 w-12 text-muted-foreground" />
|
||
</div>
|
||
</div>
|
||
{/* 内容区 */}
|
||
<div className="space-y-4 flex-1">
|
||
{/* 状态和ID */}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground font-mono">#{expr.id}</span>
|
||
<div className="flex items-center gap-2">
|
||
{getStatusBadge(expr)}
|
||
{getModifierBadge(expr.modified_by)}
|
||
</div>
|
||
</div>
|
||
{/* 情景 */}
|
||
<div className="space-y-1.5">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">情景</div>
|
||
<div className="p-3 bg-muted/30 rounded-lg border border-border/50">
|
||
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
|
||
</div>
|
||
</div>
|
||
{/* 风格 */}
|
||
<div className="space-y-1.5">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">风格</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{expr.style.split(/[,,]/).map((s, i) => (
|
||
<Badge key={i} variant="secondary" className="font-normal">
|
||
{s.trim()}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 底部信息 */}
|
||
<div className="mt-auto pt-4 border-t flex items-center justify-between text-xs text-muted-foreground">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||
<User className="h-3 w-3" />
|
||
</div>
|
||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||
{getChatName(expr)}
|
||
</span>
|
||
</div>
|
||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||
</div>
|
||
</AnimatedDiv>
|
||
) : (
|
||
<div
|
||
key={expr.id}
|
||
role="option"
|
||
id={`quick-expr-${expr.id}`}
|
||
aria-selected={false}
|
||
className={cn(
|
||
'bg-card border rounded-xl shadow-xl p-6 select-none h-full flex flex-col'
|
||
)}
|
||
style={style}
|
||
>
|
||
{/* 内容区 */}
|
||
<div className="space-y-4 flex-1">
|
||
{/* 状态和ID */}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground font-mono">#{expr.id}</span>
|
||
<div className="flex items-center gap-2">
|
||
{getStatusBadge(expr)}
|
||
{getModifierBadge(expr.modified_by)}
|
||
</div>
|
||
</div>
|
||
{/* 情景 */}
|
||
<div className="space-y-1.5">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">情景</div>
|
||
<div className="p-3 bg-muted/30 rounded-lg border border-border/50">
|
||
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
|
||
</div>
|
||
</div>
|
||
{/* 风格 */}
|
||
<div className="space-y-1.5">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">风格</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{expr.style.split(/[,,]/).map((s, i) => (
|
||
<Badge key={i} variant="secondary" className="font-normal">
|
||
{s.trim()}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 底部信息 */}
|
||
<div className="mt-auto pt-4 border-t flex items-center justify-between text-xs text-muted-foreground">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||
<User className="h-3 w-3" />
|
||
</div>
|
||
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
|
||
{getChatName(expr)}
|
||
</span>
|
||
</div>
|
||
<span className="font-mono">{formatTime(expr.create_date)}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 操作按钮(移动端) */}
|
||
<div className="flex items-center gap-8 mt-8 sm:hidden z-50">
|
||
{(() => {
|
||
const currentExpr = quickExpressions[quickCurrentIndex]
|
||
const directions = getAllowedDirections(currentExpr)
|
||
return (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
className={cn(
|
||
'w-16 h-16 rounded-full border-2 shadow-lg transition-all active:scale-95',
|
||
!directions.left ? 'opacity-30 cursor-not-allowed' : 'hover:bg-red-50 hover:text-red-600 hover:border-red-200'
|
||
)}
|
||
onClick={() => directions.left && handleQuickReview(true)}
|
||
disabled={!directions.left || isAnimatingRef.current}
|
||
>
|
||
<XCircle className="h-8 w-8" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
className={cn(
|
||
'w-16 h-16 rounded-full border-2 shadow-lg transition-all active:scale-95',
|
||
!directions.right ? 'opacity-30 cursor-not-allowed' : 'hover:bg-green-50 hover:text-green-600 hover:border-green-200'
|
||
)}
|
||
onClick={() => directions.right && handleQuickReview(false)}
|
||
disabled={!directions.right || isAnimatingRef.current}
|
||
>
|
||
<CheckCircle2 className="h-8 w-8" />
|
||
</Button>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 底部快捷键提示(桌面端) */}
|
||
<div className="hidden sm:flex items-center justify-center gap-6 px-6 py-3 border-t text-xs text-muted-foreground">
|
||
<div className="flex items-center gap-1">
|
||
<ShortcutKbd size="sm" keys={['left']} />
|
||
<span>拒绝</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<ShortcutKbd size="sm" keys={['right']} />
|
||
<span>通过</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<ShortcutKbd size="sm" keys={['up']} />
|
||
<span>上一条</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<ShortcutKbd size="sm" keys={['down']} />
|
||
<span>下一条</span>
|
||
</div>
|
||
<span className="text-muted-foreground/50">|</span>
|
||
<span>拖拽卡片滑动审核</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|