VetNet-LCPS / vetcopilot-app /src /components /CommentSection.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
11.1 kB
import React, { useState, useEffect, useCallback } from 'react'
import {
getComments,
addComment,
replyComment,
deleteComment as apiDeleteComment,
type CommentOut,
} from '../api'
import { useToast } from './Toast'
import { useTranslation } from '../i18n'
import { useDateFormatter } from '../utils/dateUtils'
interface CommentSectionProps {
consultationId: string
}
export default function CommentSection({ consultationId }: CommentSectionProps) {
const toast = useToast()
const { t } = useTranslation()
const { formatDateTime } = useDateFormatter()
const [comments, setComments] = useState<CommentOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [newComment, setNewComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const [replyingTo, setReplyingTo] = useState<string | null>(null)
const [replyContent, setReplyContent] = useState('')
const [replySubmitting, setReplySubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const loadComments = useCallback(async () => {
try {
const data = await getComments(consultationId)
setComments(data.comments)
setTotal(data.total)
} catch {
// 静默失败
} finally {
setLoading(false)
}
}, [consultationId])
useEffect(() => {
loadComments()
}, [loadComments])
const handleAddComment = async () => {
const trimmed = newComment.trim()
if (!trimmed || submitting) return
setSubmitting(true)
try {
await addComment(consultationId, trimmed)
setNewComment('')
toast.success(t('components.comment.posted'))
await loadComments()
} catch (e: any) {
toast.error(e?.message || t('components.comment.postFailed'))
} finally {
setSubmitting(false)
}
}
const handleReply = async (commentId: string) => {
const trimmed = replyContent.trim()
if (!trimmed || replySubmitting) return
setReplySubmitting(true)
try {
await replyComment(consultationId, commentId, trimmed)
setReplyContent('')
setReplyingTo(null)
toast.success(t('components.comment.replyPosted'))
await loadComments()
} catch (e: any) {
toast.error(e?.message || t('components.comment.replyFailed'))
} finally {
setReplySubmitting(false)
}
}
const handleDelete = async (commentId: string) => {
if (!window.confirm(t('components.comment.confirmDelete'))) return
setDeletingId(commentId)
try {
await apiDeleteComment(consultationId, commentId)
toast.success(t('components.comment.deleted'))
await loadComments()
} catch (e: any) {
toast.error(e?.message || t('components.comment.deleteFailed'))
} finally {
setDeletingId(null)
}
}
const formatTime = (iso: string) => {
try {
return formatDateTime(iso)
} catch {
return iso
}
}
if (loading) {
return (
<div className="card p-4 md:p-6 max-w-3xl mx-auto mt-6">
<div className="text-center text-gray-400 text-sm py-4">{t('components.comment.loading')}</div>
</div>
)
}
return (
<div className="card p-4 md:p-6 max-w-3xl mx-auto mt-6">
<h4 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{t('components.comment.title')} ({total})
</h4>
{/* 新增评论 */}
<div className="mb-4">
<div className="flex gap-2">
<textarea
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-medical-500 focus:border-medical-500 outline-none resize-none min-h-[44px]"
placeholder={t('components.comment.placeholder')}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
rows={2}
/>
<button
onClick={handleAddComment}
disabled={submitting || !newComment.trim()}
className="btn-primary text-xs px-4 py-2 min-h-[44px] disabled:opacity-50 shrink-0"
>
{submitting ? t('components.comment.sending') : t('components.comment.send')}
</button>
</div>
</div>
{/* 评论列表 */}
{comments.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-6">{t('components.comment.noComments')}</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
consultationId={consultationId}
replyingTo={replyingTo}
replyContent={replyContent}
replySubmitting={replySubmitting}
deletingId={deletingId}
onReplyClick={(id) => {
setReplyingTo(id)
setReplyContent('')
}}
onReplyCancel={() => setReplyingTo(null)}
onReplyChange={setReplyContent}
onReplySubmit={handleReply}
onDelete={handleDelete}
formatTime={formatTime}
depth={0}
/>
))}
</div>
)}
</div>
)
}
// ================================================================
// 单个评论(递归渲染回复)
// ================================================================
function CommentItem({
comment,
consultationId,
replyingTo,
replyContent,
replySubmitting,
deletingId,
onReplyClick,
onReplyCancel,
onReplyChange,
onReplySubmit,
onDelete,
formatTime,
depth,
}: {
comment: CommentOut
consultationId: string
replyingTo: string | null
replyContent: string
replySubmitting: boolean
deletingId: string | null
onReplyClick: (id: string) => void
onReplyCancel: () => void
onReplyChange: (v: string) => void
onReplySubmit: (id: string) => void
onDelete: (id: string) => void
formatTime: (iso: string) => string
depth: number
}) {
const { t } = useTranslation()
const isDeleting = deletingId === comment.id
const maxDepth = 4 // 最大嵌套层级
return (
<div className={`${depth > 0 ? 'ml-6 border-l-2 border-gray-100 pl-3' : ''}`}>
<div className={`rounded-lg p-3 ${comment.is_deleted ? 'bg-gray-50' : 'bg-gray-50/50'}`}>
{/* 头像 + 信息 + 时间 */}
<div className="flex items-start gap-2 mb-1">
{/* 头像 */}
<div className="w-7 h-7 rounded-full bg-medical-100 text-medical-700 flex items-center justify-center text-xs font-bold shrink-0">
{comment.user?.nickname?.charAt(0) || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">
{comment.is_deleted ? t('components.comment.deleted') : comment.user?.nickname || t('components.comment.anonymous')}
</span>
<span className="text-[10px] text-gray-400">
{formatTime(comment.created_at)}
</span>
{comment.is_deleted && (
<span className="text-[10px] text-gray-400 italic">{t('components.comment.deletedByAuthor')}</span>
)}
</div>
{/* 内容 */}
<p className="text-sm text-gray-700 mt-1 whitespace-pre-wrap break-words">
{comment.content}
</p>
</div>
</div>
{/* 操作按钮 */}
{!comment.is_deleted && (
<div className="flex gap-3 ml-9">
{depth < maxDepth && (
<button
onClick={() => onReplyClick(comment.id)}
className="text-[11px] text-gray-400 hover:text-medical-600 transition-colors"
>
{t('components.comment.reply')}
</button>
)}
<button
onClick={() => onDelete(comment.id)}
disabled={isDeleting}
className="text-[11px] text-gray-400 hover:text-red-500 transition-colors disabled:opacity-50"
>
{isDeleting ? t('components.comment.deleting') : t('components.comment.delete')}
</button>
</div>
)}
{/* 回复输入框 */}
{replyingTo === comment.id && (
<div className="ml-9 mt-2 flex gap-2">
<textarea
className="flex-1 text-xs border border-gray-200 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-medical-500 focus:border-medical-500 outline-none resize-none min-h-[32px]"
placeholder={t('components.comment.replyPlaceholder')}
value={replyContent}
onChange={(e) => onReplyChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onReplySubmit(comment.id)
}
}}
rows={2}
autoFocus
/>
<div className="flex flex-col gap-1">
<button
onClick={() => onReplySubmit(comment.id)}
disabled={replySubmitting || !replyContent.trim()}
className="btn-primary text-[10px] px-2 py-1 min-h-[28px] disabled:opacity-50"
>
{t('components.comment.reply')}
</button>
<button
onClick={onReplyCancel}
className="text-[10px] text-gray-400 hover:text-gray-600 min-h-[24px]"
>
{t('components.comment.cancel')}
</button>
</div>
</div>
)}
</div>
{/* 递归渲染子回复 */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-1">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
consultationId={consultationId}
replyingTo={replyingTo}
replyContent={replyContent}
replySubmitting={replySubmitting}
deletingId={deletingId}
onReplyClick={onReplyClick}
onReplyCancel={onReplyCancel}
onReplyChange={onReplyChange}
onReplySubmit={onReplySubmit}
onDelete={onDelete}
formatTime={formatTime}
depth={depth + 1}
/>
))}
</div>
)}
</div>
)
}