Files
mai-bot/dashboard/src/components/expression-reviewer.tsx

1727 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 表达方式审核器弹窗组件
*
* 功能:
* 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>
)
}