393 lines
9.9 KiB
React
393 lines
9.9 KiB
React
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'
|
||
}
|
||
};
|