/** * 表达方式审核器弹窗组件 * * 功能: * 1. 分页显示待审核/已通过/已拒绝的表达方式 * 2. 支持单条通过/拒绝 * 3. 支持批量操作 * 4. 冲突检测(防止与AI自动检查冲突) */ import { useState, useEffect, useCallback, useRef } 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 { 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(null) const [expressions, setExpressions] = useState([]) // 快速审核模式状态 const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked') const [quickExpressions, setQuickExpressions] = useState([]) const [quickCurrentIndex, setQuickCurrentIndex] = useState(0) const [quickLoading, setQuickLoading] = useState(false) const [quickTotal, setQuickTotal] = useState(0) const [quickPage, setQuickPage] = useState(1) const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null) const [swipeOffset, setSwipeOffset] = useState(0) const [isAnimating, setIsAnimating] = useState(false) const [conflictId, setConflictId] = useState(null) const cardRef = useRef(null) const dragStartRef = useRef<{ x: number; y: number } | null>(null) const isDraggingRef = useRef(false) 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>(new Set()) const [processingIds, setProcessingIds] = useState>(new Set()) const [chatNameMap, setChatNameMap] = useState>(new Map()) const { toast } = useToast() // 加载统计数据 const loadStats = useCallback(async () => { try { setStatsLoading(true) const data = await getReviewStats() setStats(data) } catch (error) { console.error('加载统计失败:', error) } finally { setStatsLoading(false) } }, []) // 加载列表 const loadList = useCallback(async () => { try { setLoading(true) const response = await getReviewList({ page, page_size: pageSize, filter_type: filterType, search: search || undefined, }) setExpressions(response.data) setTotal(response.total) } 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 response = await getChatList() if (response?.data) { const nameMap = new Map() response.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 response = await getReviewList({ page: pageToLoad, page_size: 20, filter_type: quickFilterType, }) if (append) { // 追加模式:拼接数据 setQuickExpressions(prev => [...prev, ...response.data]) setQuickPage(pageToLoad) } else { // 替换模式 setQuickExpressions(response.data) } setQuickTotal(response.total) if (resetIndex) { setQuickCurrentIndex(0) } } 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 || isAnimating) return const directions = getAllowedDirections(currentExpr) if ((rejected && !directions.left) || (!rejected && !directions.right)) { return } setIsAnimating(true) setSwipeDirection(rejected ? 'left' : 'right') setSwipeOffset(rejected ? -400 : 400) try { const response = await batchReviewExpressions([{ id: currentExpr.id, rejected, require_unchecked: quickFilterType === 'unchecked', }]) if (response.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)) } // 重置状态 setSwipeDirection(null) setSwipeOffset(0) setIsAnimating(false) // 刷新统计 loadStats() // 如果列表为空且还有更多数据,加载下一页 if (quickExpressions.length <= 1 && quickTotal > 1) { loadQuickList(false) } }, 300) } else { // 冲突处理 setConflictId(currentExpr.id) toast({ title: '数据冲突', description: '该条目已被后台任务处理,正在刷新数据...', variant: 'destructive', }) // 播放冲突动画后刷新 setTimeout(() => { setConflictId(null) setSwipeDirection(null) setSwipeOffset(0) setIsAnimating(false) loadQuickList(false) // 重新加载当前页 loadStats() }, 1500) } } catch (error) { toast({ title: '操作失败', description: error instanceof Error ? error.message : '未知错误', variant: 'destructive', }) setSwipeDirection(null) setSwipeOffset(0) setIsAnimating(false) } }, [quickExpressions, quickCurrentIndex, isAnimating, getAllowedDirections, quickFilterType, toast, loadStats, quickTotal, loadQuickList]) // 拖拽开始 const handleDragStart = useCallback((clientX: number, clientY: number) => { if (isAnimating) return dragStartRef.current = { x: clientX, y: clientY } isDraggingRef.current = false }, [isAnimating]) // 触发无效操作动画 const triggerInvalidAnimation = useCallback((direction: 'left' | 'right') => { if (isAnimating) return setIsAnimating(true) // 模拟向该方向移动一点 setSwipeOffset(direction === 'left' ? -30 : 30) setTimeout(() => { setSwipeOffset(0) setTimeout(() => setIsAnimating(false), 300) }, 150) }, [isAnimating]) // 拖拽移动 const handleDragMove = useCallback((clientX: number) => { if (!dragStartRef.current || isAnimating) return const deltaX = clientX - dragStartRef.current.x const currentExpr = quickExpressions[quickCurrentIndex] const directions = getAllowedDirections(currentExpr) // 检查方向限制 if (deltaX < 0 && !directions.left) { setSwipeOffset(deltaX * 0.2) // 提供阻力反馈 setSwipeDirection(null) return } if (deltaX > 0 && !directions.right) { setSwipeOffset(deltaX * 0.2) setSwipeDirection(null) return } isDraggingRef.current = true setSwipeOffset(deltaX) if (Math.abs(deltaX) > 50) { setSwipeDirection(deltaX > 0 ? 'right' : 'left') } else { setSwipeDirection(null) } }, [quickExpressions, quickCurrentIndex, getAllowedDirections, isAnimating]) // 拖拽结束 const handleDragEnd = useCallback(() => { if (!dragStartRef.current) return const threshold = 100 if (Math.abs(swipeOffset) > threshold && swipeDirection) { handleQuickReview(swipeDirection === 'left') } else { // 回弹 setSwipeOffset(0) setSwipeDirection(null) } dragStartRef.current = null isDraggingRef.current = false }, [swipeOffset, swipeDirection, handleQuickReview]) // 鼠标事件处理 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 (isAnimating || 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, isAnimating, 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 = (chatId: string): string => { return chatNameMap.get(chatId) || chatId } // 单条审核 const handleReview = async (id: number, rejected: boolean) => { try { setProcessingIds((prev) => new Set(prev).add(id)) const response = await batchReviewExpressions([ { id, rejected, require_unchecked: filterType === 'unchecked' } ]) if (response.results[0]?.success) { toast({ title: rejected ? '已拒绝' : '已通过', description: `表达方式 #${id} ${rejected ? '已拒绝' : '已通过'}`, }) // 刷新列表和统计 loadList() loadStats() } else { toast({ title: '操作失败', description: response.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 response = await batchReviewExpressions(items) toast({ title: '批量审核完成', description: `成功 ${response.succeeded} 条,失败 ${response.failed} 条`, variant: response.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 ( 待审核 ) } if (expr.rejected) { return ( 已拒绝 ) } return ( 已通过 ) } // 获取修改者标签 const getModifierBadge = (modifier: string | null) => { if (!modifier) return null if (modifier === 'ai') { return ( AI ) } return ( 人工 ) } 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 ( {/* 浏览器标签页风格的模式切换器 */}
{/* 列表模式标签 */} {/* 快速审核标签 */} {/* 右侧空白区域和关闭按钮 */}
{/* 列表模式内容 */} {reviewMode === 'list' && ( <> 表达方式审核 审核麦麦学习到的表达方式。通过审核的项目才会被使用(可在配置中调整),被拒绝的项目永远不会被使用。 {/* 统计卡片 */}
{statsLoading ? '-' : stats?.unchecked ?? 0}
待审核
{statsLoading ? '-' : stats?.passed ?? 0}
已通过
{statsLoading ? '-' : stats?.rejected ?? 0}
已拒绝
{statsLoading ? '-' : stats?.total ?? 0}
总计
{/* 筛选和操作栏 */}
setFilterType(v as typeof filterType)} className="w-full" > 待审核 待审 ({stats?.unchecked ?? 0}) 已通过 通过 ({stats?.passed ?? 0}) 已拒绝 拒绝 ({stats?.rejected ?? 0}) 全部 ({stats?.total ?? 0})
setSearchInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="pl-9" />
{/* 批量操作按钮 */} {selectedIds.size > 0 && (
{filterType === 'unchecked' ? ( // 待审核:显示批量通过和批量拒绝 <> ) : filterType === 'passed' ? ( // 已通过:只显示批量改为拒绝 ) : filterType === 'rejected' ? ( // 已拒绝:只显示批量改为通过 ) : ( // 全部:显示两个按钮 <> )}
)}
{/* 列表区域 */} {loading && expressions.length === 0 ? (
) : expressions.length === 0 ? (

没有找到表达方式

) : (
{/* 全选 */} {expressions.length > 0 && (
0} onCheckedChange={handleSelectAll} /> {selectedIds.size === expressions.length && expressions.length > 0 ? `已全选当前页 (${expressions.length} 条)` : `全选当前页 (${expressions.length} 条)`}
{selectedIds.size > 0 && ( )}
)} {/* 表达方式列表 */} {expressions.map((expr) => (
{/* 选择框 */} toggleSelect(expr.id)} disabled={processingIds.has(expr.id)} className="mt-1" /> {/* 内容 */}
{/* 情景 */}
情景:

{expr.situation}

{/* 风格 */}
风格:

{expr.style}

{/* 元信息 */}
#{expr.id} · {getChatName(expr.chat_id)} · {formatTime(expr.create_date)}
{getStatusBadge(expr)} {getModifierBadge(expr.modified_by)}
{/* 操作按钮 */}
{filterType === 'unchecked' ? ( <> ) : filterType === 'passed' ? ( ) : filterType === 'rejected' ? ( ) : ( // all 模式下显示两个按钮 <> {expr.rejected ? ( ) : expr.checked ? ( ) : ( <> )} )}
))}
)}
{/* 分页 */}
{/* 左侧:每页显示数量 */}
每页 共 {total} 条
{/* 中间:页码导航 */} {getPageNumbers().map((pageNum, idx) => ( {pageNum === 'ellipsis' ? ( ) : ( { e.preventDefault() setPage(pageNum) }} className="h-8 w-8 cursor-pointer" > {pageNum} )} ))} {/* 右侧:跳转 */}
跳至 setJumpPage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleJumpPage()} className="w-16 h-8 text-center" placeholder={page.toString()} />
)} {/* 快速审核模式内容 */} {reviewMode === 'quick' && (
{/* 顶部筛选和统计 */}
{/* 统计信息 */}
待审核: {stats?.unchecked ?? 0} 已通过: {stats?.passed ?? 0} 已拒绝: {stats?.rejected ?? 0}
{/* 筛选标签 */} setQuickFilterType(v as typeof quickFilterType)} className="w-full" > 待审核 待审 已通过 通过 已拒绝 拒绝 全部
{/* 卡片区域 */}
{quickLoading && quickExpressions.length === 0 ? (

加载中...

) : quickExpressions.length === 0 ? (

全部审核完成!

当前筛选条件下没有待处理的项目

) : ( <> {/* 进度提示 */}
{quickCurrentIndex + 1} / {quickExpressions.length} {quickTotal > quickExpressions.length && ( (共 {quickTotal} 条) )}
{/* 方向提示 (仅针对当前卡片) */}
{(() => { const currentExpr = quickExpressions[quickCurrentIndex] const directions = getAllowedDirections(currentExpr) return ( <>
拒绝
通过
) })()}
{/* 堆叠卡片 */}
{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) { // 当前卡片样式 style = { ...style, transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`, opacity: Math.max(0, 1 - Math.abs(swipeOffset) / 500), cursor: 'grab', } } else { // 后方卡片样式 const progress = Math.min(Math.abs(swipeOffset) / 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 && conflictId === expr.id && (

数据已更新

后台任务已处理此条目

)} {/* 无效操作提示 */} {isCurrent && (
10 && !getAllowedDirections(expr).right)) ? "opacity-100" : "opacity-0" )}>
)}
{/* 状态和ID */}
#{expr.id}
{getStatusBadge(expr)} {getModifierBadge(expr.modified_by)}
{/* 情景 */}

{expr.situation}

{/* 风格 */}
{expr.style.split(/[,,]/).map((s, i) => ( {s.trim()} ))}
{/* 底部信息 */}
{getChatName(expr.chat_id)}
{formatTime(expr.create_date)}
) })}
{/* 操作按钮(移动端) */}
{(() => { const currentExpr = quickExpressions[quickCurrentIndex] const directions = getAllowedDirections(currentExpr) return ( <> ) })()}
)}
{/* 底部快捷键提示(桌面端) */}
拒绝
通过
上一条
下一条
| 拖拽卡片滑动审核
)}
) }