Download vetcopilot-app/src/components/CommentSection.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/CommentSection.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/CommentSection.tsx
-
curl -L -o CommentSection.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/CommentSection.tsx
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> | |
| ) | |
| } | |