Files
to_docx/client/src/pages/IssueDetail.jsx
T

393 lines
9.9 KiB
React
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.
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const STATUS_LABELS = { open: '待处理', in_progress: '处理中', closed: '已关闭' };
const STATUS_COLORS = {
open: { color: '#f59e0b', bg: '#fffbeb' },
in_progress: { color: '#3b82f6', bg: '#eff6ff' },
closed: { color: '#10b981', bg: '#ecfdf5' }
};
const TYPE_LABELS = { bug: '🐛 Bug 报告', feature: '💡 功能建议', other: '📌 其他' };
export default function IssueDetail() {
const { id } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const [issue, setIssue] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// 回复表单
const [replyContent, setReplyContent] = useState('');
const [replying, setReplying] = useState(false);
const [replyMsg, setReplyMsg] = useState('');
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
fetchIssue();
}, [user, id, navigate]);
const fetchIssue = async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/issues/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || '获取失败');
setIssue(data.issue);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleReply = async (e) => {
e.preventDefault();
if (!replyContent.trim()) return;
setReplying(true);
setReplyMsg('');
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/issues/${id}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ content: replyContent.trim() })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || '回复失败');
setReplyContent('');
setReplyMsg('回复成功');
fetchIssue();
} catch (err) {
setReplyMsg(err.message);
} finally {
setReplying(false);
}
};
if (!user) return null;
if (loading) {
return <div style={styles.center}>加载中...</div>;
}
if (error) {
return (
<div style={styles.container}>
<div style={styles.errorBox}>{error}</div>
<Link to="/issues" style={styles.backLink}> 返回反馈列表</Link>
</div>
);
}
if (!issue) return null;
return (
<div style={styles.container}>
<Link to="/issues" style={styles.backLink}> 返回反馈列表</Link>
{/* Issue 主体 */}
<div style={styles.card}>
<div style={styles.headerRow}>
<h2 style={styles.title}>{issue.title}</h2>
<span style={{
...styles.statusBadge,
color: STATUS_COLORS[issue.status]?.color,
backgroundColor: STATUS_COLORS[issue.status]?.bg
}}>
{STATUS_LABELS[issue.status] || issue.status}
</span>
</div>
<div style={styles.metaRow}>
<span style={styles.typeBadge}>{TYPE_LABELS[issue.type] || issue.type}</span>
<span style={styles.metaText}>提交者: {issue.username}</span>
<span style={styles.metaText}>
{new Date(issue.created_at).toLocaleString('zh-CN')}
</span>
</div>
<div style={styles.divider} />
<div style={styles.descBlock}>
<h3 style={styles.sectionTitle}>问题描述</h3>
<p style={styles.descText}>{issue.description}</p>
</div>
{/* 附带的 Markdown 用例 */}
{issue.markdown_sample && (
<div style={styles.sampleBlock}>
<h3 style={styles.sectionTitle}>📎 附带的 Markdown 用例</h3>
<pre style={styles.codeBlock}>{issue.markdown_sample}</pre>
</div>
)}
</div>
{/* 回复列表 */}
<div style={styles.card}>
<h3 style={styles.sectionTitle}>
💬 回复 ({issue.replies?.length || 0})
</h3>
{(!issue.replies || issue.replies.length === 0) ? (
<p style={styles.emptyText}>暂无回复</p>
) : (
<div style={styles.replyList}>
{issue.replies.map(reply => (
<div
key={reply.id}
style={{
...styles.replyItem,
...(reply.author === 'admin' ? styles.adminReply : {})
}}
>
<div style={styles.replyHeader}>
<span style={{
...styles.replyAuthor,
...(reply.author === 'admin' ? { color: '#ef4444' } : {})
}}>
{reply.author === 'admin' ? '🔧 管理员' : reply.author}
</span>
<span style={styles.replyDate}>
{new Date(reply.created_at).toLocaleString('zh-CN')}
</span>
</div>
<p style={styles.replyContent}>{reply.content}</p>
</div>
))}
</div>
)}
{/* 回复表单 */}
{issue.status !== 'closed' && (
<>
<div style={styles.divider} />
<form onSubmit={handleReply}>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
style={styles.replyTextarea}
placeholder="输入你的回复..."
rows={3}
/>
{replyMsg && (
<p style={{ fontSize: '13px', color: replyMsg === '回复成功' ? '#10b981' : '#ef4444', marginBottom: '8px' }}>
{replyMsg}
</p>
)}
<button type="submit" style={styles.replyBtn} disabled={replying || !replyContent.trim()}>
{replying ? '发送中...' : '发送回复'}
</button>
</form>
</>
)}
{issue.status === 'closed' && (
<div style={styles.closedHint}>
🔒 Issue 已关闭无法继续回复
</div>
)}
</div>
</div>
);
}
const styles = {
container: {
maxWidth: '700px',
margin: '0 auto',
padding: '30px 20px'
},
center: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '60vh',
fontSize: '16px',
color: '#9ca3af'
},
backLink: {
display: 'inline-block',
marginBottom: '16px',
fontSize: '14px',
color: '#3b82f6',
textDecoration: 'none',
fontWeight: '500'
},
card: {
backgroundColor: 'white',
borderRadius: '8px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '16px'
},
headerRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '12px',
marginBottom: '12px'
},
title: {
fontSize: '20px',
fontWeight: 'bold',
color: '#111827',
margin: 0,
flex: 1
},
statusBadge: {
fontSize: '12px',
fontWeight: '600',
padding: '4px 12px',
borderRadius: '20px',
whiteSpace: 'nowrap'
},
metaRow: {
display: 'flex',
gap: '12px',
alignItems: 'center',
flexWrap: 'wrap'
},
typeBadge: {
fontSize: '12px',
color: '#6b7280',
backgroundColor: '#f3f4f6',
padding: '3px 10px',
borderRadius: '4px'
},
metaText: {
fontSize: '13px',
color: '#9ca3af'
},
divider: {
border: 'none',
borderTop: '1px solid #e5e7eb',
margin: '20px 0'
},
sectionTitle: {
fontSize: '15px',
fontWeight: '600',
color: '#374151',
marginBottom: '12px'
},
descBlock: {
marginTop: '20px'
},
descText: {
fontSize: '14px',
color: '#4b5563',
lineHeight: '1.7',
whiteSpace: 'pre-wrap'
},
sampleBlock: {
marginTop: '20px'
},
codeBlock: {
backgroundColor: '#1f2937',
color: '#e5e7eb',
padding: '16px',
borderRadius: '6px',
fontSize: '13px',
lineHeight: '1.5',
overflow: 'auto',
maxHeight: '400px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
emptyText: {
color: '#9ca3af',
fontSize: '14px',
textAlign: 'center',
padding: '20px 0'
},
replyList: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
replyItem: {
padding: '14px',
backgroundColor: '#f9fafb',
borderRadius: '8px',
border: '1px solid #e5e7eb'
},
adminReply: {
backgroundColor: '#fef2f2',
borderColor: '#fecaca'
},
replyHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
},
replyAuthor: {
fontSize: '13px',
fontWeight: '600',
color: '#374151'
},
replyDate: {
fontSize: '12px',
color: '#9ca3af'
},
replyContent: {
fontSize: '14px',
color: '#4b5563',
lineHeight: '1.6',
margin: 0,
whiteSpace: 'pre-wrap'
},
replyTextarea: {
width: '100%',
padding: '10px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
boxSizing: 'border-box',
resize: 'vertical',
fontFamily: 'inherit',
lineHeight: '1.5',
marginBottom: '10px'
},
replyBtn: {
padding: '10px 24px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
fontWeight: '500'
},
closedHint: {
marginTop: '16px',
padding: '12px',
backgroundColor: '#f3f4f6',
borderRadius: '6px',
fontSize: '13px',
color: '#6b7280',
textAlign: 'center'
},
errorBox: {
color: '#ef4444',
backgroundColor: '#fef2f2',
padding: '12px 16px',
borderRadius: '6px',
marginBottom: '16px',
fontSize: '14px',
border: '1px solid #fecaca'
}
};