sync from local backup
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
.bugdetail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.bugdetail-hero {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
padding: 48px 20px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bugdetail-particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bugdetail-particle {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: bugdetail-float linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bugdetail-float {
|
||||
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
|
||||
50% { transform: translateY(-18px) scale(1.2); opacity: 0.6; }
|
||||
}
|
||||
|
||||
.bugdetail-hero-content {
|
||||
position: relative;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bugdetail-hero-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.bugdetail-hero-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.bugdetail-hero-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bugdetail-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
.bugdetail-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-id {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bugdetail-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bugdetail-empty h2 {
|
||||
font-size: 22px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bugdetail-empty p {
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bugdetail-reward-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #fef9c3 0%, #fef3c7 50%, #fff7ed 100%);
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-reward-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bugdetail-reward-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bugdetail-reward-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bugdetail-reward-desc {
|
||||
font-size: 13px;
|
||||
color: #a16207;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bugdetail-reward-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bugdetail-reward-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-reward-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
background: #f3e8ff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.bugdetail-info-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bugdetail-info-card {
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.bugdetail-info-card .ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-info-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bugdetail-info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bugdetail-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bugdetail-info-label {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.bugdetail-info-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.bugdetail-description {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bugdetail-description p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.bugdetail-images {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bugdetail-images h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bugdetail-images-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bugdetail-chat-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bugdetail-chat-card {
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.bugdetail-chat-card .ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-chat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bugdetail-chat-count {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.bugdetail-chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-chat-messages::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.bugdetail-chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.bugdetail-msg {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
animation: bugdetail-msg-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes bugdetail-msg-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bugdetail-msg.user {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.bugdetail-msg.admin {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.bugdetail-msg-avatar.ant-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bugdetail-msg-avatar.admin {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.bugdetail-msg-avatar.user {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
.bugdetail-msg-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bugdetail-msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bugdetail-msg-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.bugdetail-msg-tag {
|
||||
font-size: 10px !important;
|
||||
padding: 0 5px !important;
|
||||
line-height: 16px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.bugdetail-msg-time {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.bugdetail-msg-content {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px 12px 12px 12px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bugdetail-msg.admin .bugdetail-msg-content {
|
||||
background: #f0f2ff;
|
||||
border-radius: 4px 12px 12px 12px;
|
||||
}
|
||||
|
||||
.bugdetail-msg-images {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.bugdetail-reply-area {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.bugdetail-reply-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bugdetail-reply-input textarea {
|
||||
border-radius: 10px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.bugdetail-reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bugdetail-resolved-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #52c41a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.bugdetail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bugdetail-info-panel {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.bugdetail-chat-panel {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.bugdetail-hero {
|
||||
padding: 32px 16px 28px;
|
||||
}
|
||||
|
||||
.bugdetail-hero-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.bugdetail-msg-time {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-page {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-info-card,
|
||||
[data-theme="dark"] .bugdetail-chat-card {
|
||||
background: #1e293b !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-info-card .ant-card-body,
|
||||
[data-theme="dark"] .bugdetail-chat-card .ant-card-body {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-id {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-empty h2 {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-empty p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-reward-points {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-info-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-info-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-info-value {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-description {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-images h4 {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-chat-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-chat-count {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-msg-name {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-msg-time {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-msg-content {
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-msg.admin .bugdetail-msg-content {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bugdetail-reply-area {
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Tag,
|
||||
Input,
|
||||
message,
|
||||
Image,
|
||||
Tooltip,
|
||||
Card,
|
||||
Space,
|
||||
Spin,
|
||||
} from "antd";
|
||||
import {
|
||||
BugOutlined,
|
||||
ArrowLeftOutlined,
|
||||
SendOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
UserOutlined,
|
||||
CustomerServiceOutlined,
|
||||
PictureOutlined,
|
||||
SmileOutlined,
|
||||
PaperClipOutlined,
|
||||
EnvironmentOutlined,
|
||||
InfoCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { api_request } from "@/utils/request";
|
||||
import { formatDateTime } from "@/utils/time";
|
||||
import "./BugDetail.css";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const EMOJI_LIST = [
|
||||
"😀", "😂", "🤣", "😊", "😍", "🥰", "😘", "😜",
|
||||
"🤔", "😤", "😭", "😱", "👍", "👎", "👏", "🙌",
|
||||
"🎉", "🔥", "❤️", "💯", "✅", "❌", "⭐", "💪",
|
||||
];
|
||||
|
||||
const ATTACHMENT_ACCEPT = ".pdf,.doc,.docx,.txt,.zip,.rar";
|
||||
|
||||
interface ApiCommentImage {
|
||||
id: number;
|
||||
image: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiCommentAttachment {
|
||||
id: number;
|
||||
file: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiComment {
|
||||
id: number;
|
||||
user: number;
|
||||
user_name: string;
|
||||
user_avatar: string;
|
||||
content: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
images: ApiCommentImage[];
|
||||
attachments: ApiCommentAttachment[];
|
||||
}
|
||||
|
||||
interface ApiBugImage {
|
||||
id: number;
|
||||
image: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiBugAttachment {
|
||||
id: number;
|
||||
file: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiBugDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
bug_type: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: number;
|
||||
user_name: string;
|
||||
user_avatar: string;
|
||||
images: ApiBugImage[];
|
||||
attachments: ApiBugAttachment[];
|
||||
comments: ApiComment[];
|
||||
}
|
||||
|
||||
const priorityColor: Record<string, string> = { "high": "red", "medium": "orange", "low": "blue" };
|
||||
const priorityLabel: Record<string, string> = { "high": "高", "medium": "中", "low": "低" };
|
||||
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
"resolved": { color: "green", icon: <CheckCircleOutlined /> },
|
||||
"processing": { color: "orange", icon: <ClockCircleOutlined /> },
|
||||
"pending": { color: "red", icon: <ExclamationCircleOutlined /> },
|
||||
"rejected": { color: "default", icon: <ExclamationCircleOutlined /> },
|
||||
};
|
||||
const statusLabel: Record<string, string> = { "pending": "待处理", "processing": "处理中", "resolved": "已解决", "rejected": "已拒绝" };
|
||||
const typeLabel: Record<string, string> = { "feature": "功能问题", "ui": "界面问题", "performance": "性能问题", "other": "其他" };
|
||||
|
||||
const BugDetail: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const bugId = Number(searchParams.get("id")) || 0;
|
||||
const [bug, setBug] = useState<ApiBugDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [selectedAttachments, setSelectedAttachments] = useState<File[]>([]);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const loadBugDetail = async () => {
|
||||
if (!bugId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res: any = await api_request.bug.get_report_detail(bugId);
|
||||
if (res?.data) {
|
||||
setBug(res.data);
|
||||
}
|
||||
} catch {
|
||||
setBug(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBugDetail();
|
||||
}, [bugId]);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [bug?.comments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
imagePreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [imagePreviews]);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
const newFiles = Array.from(files);
|
||||
const remaining = 3 - selectedImages.length;
|
||||
const toAdd = newFiles.slice(0, remaining);
|
||||
if (toAdd.length < newFiles.length) {
|
||||
message.warning("最多上传3张图片");
|
||||
}
|
||||
const oversized = toAdd.find((f) => f.size > 10 * 1024 * 1024);
|
||||
if (oversized) {
|
||||
message.error(`图片 ${oversized.name} 超过10MB限制`);
|
||||
return;
|
||||
}
|
||||
setSelectedImages((prev) => [...prev, ...toAdd]);
|
||||
const newPreviews = toAdd.map((f) => URL.createObjectURL(f));
|
||||
setImagePreviews((prev) => [...prev, ...newPreviews]);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
URL.revokeObjectURL(imagePreviews[index]);
|
||||
setSelectedImages((prev) => prev.filter((_, i) => i !== index));
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAttachmentSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
const newFiles = Array.from(files);
|
||||
const remaining = 2 - selectedAttachments.length;
|
||||
const toAdd = newFiles.slice(0, remaining);
|
||||
if (toAdd.length < newFiles.length) {
|
||||
message.warning("最多上传2个附件");
|
||||
}
|
||||
const oversized = toAdd.find((f) => f.size > 20 * 1024 * 1024);
|
||||
if (oversized) {
|
||||
message.error(`附件 ${oversized.name} 超过20MB限制`);
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((prev) => [...prev, ...toAdd]);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setSelectedAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleEmojiClick = (emoji: string) => {
|
||||
setReplyText((prev) => prev + emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!replyText.trim()) {
|
||||
message.warning("请输入回复内容");
|
||||
return;
|
||||
}
|
||||
if (!bug) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("content", replyText.trim());
|
||||
selectedImages.forEach((file) => {
|
||||
formData.append("images", file);
|
||||
});
|
||||
selectedAttachments.forEach((file) => {
|
||||
formData.append("attachments", file);
|
||||
});
|
||||
await api_request.bug.create_comment(bug.id, formData);
|
||||
message.success("回复发送成功");
|
||||
setReplyText("");
|
||||
imagePreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
setSelectedImages([]);
|
||||
setImagePreviews([]);
|
||||
setSelectedAttachments([]);
|
||||
setShowEmojiPicker(false);
|
||||
loadBugDetail();
|
||||
} catch {
|
||||
message.error("回复发送失败,请稍后重试");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bugdetail-page">
|
||||
<div className="bugdetail-empty">
|
||||
<Spin size="large" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bug) {
|
||||
return (
|
||||
<div className="bugdetail-page">
|
||||
<div className="bugdetail-empty">
|
||||
<BugOutlined style={{ fontSize: 48, color: "#667eea" }} />
|
||||
<h2>反馈记录不存在</h2>
|
||||
<p>该反馈可能已被删除或不存在</p>
|
||||
<Button type="primary" onClick={() => navigate("/bug")}>返回反馈页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bugImages = bug.images.map((img) => img.image);
|
||||
const comments = bug.comments || [];
|
||||
|
||||
return (
|
||||
<div className="bugdetail-page">
|
||||
<div className="bugdetail-hero">
|
||||
<div className="bugdetail-particles">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bugdetail-particle"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 4 + 2}px`,
|
||||
height: `${Math.random() * 4 + 2}px`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
animationDuration: `${Math.random() * 3 + 3}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="bugdetail-hero-content">
|
||||
<div className="bugdetail-hero-icon"><BugOutlined /></div>
|
||||
<h1 className="bugdetail-hero-title">{bug.title}</h1>
|
||||
<div className="bugdetail-hero-tags">
|
||||
<Tag color={priorityColor[bug.priority]}>优先级:{priorityLabel[bug.priority]}</Tag>
|
||||
<Tag color={statusConfig[bug.status]?.color} icon={statusConfig[bug.status]?.icon}>
|
||||
{statusLabel[bug.status] || bug.status}
|
||||
</Tag>
|
||||
<Tag>{typeLabel[bug.bug_type] || bug.bug_type}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bugdetail-container">
|
||||
<div className="bugdetail-top-bar">
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/bug")}>
|
||||
返回反馈列表
|
||||
</Button>
|
||||
<span className="bugdetail-id">#{bug.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="bugdetail-body">
|
||||
<div className="bugdetail-info-panel">
|
||||
<Card className="bugdetail-info-card">
|
||||
<h3 className="bugdetail-info-title"><InfoCircleOutlined /> 反馈详情</h3>
|
||||
<div className="bugdetail-info-list">
|
||||
<div className="bugdetail-info-row">
|
||||
<span className="bugdetail-info-label">问题类型</span>
|
||||
<span className="bugdetail-info-value"><Tag>{typeLabel[bug.bug_type] || bug.bug_type}</Tag></span>
|
||||
</div>
|
||||
<div className="bugdetail-info-row">
|
||||
<span className="bugdetail-info-label">优先级</span>
|
||||
<span className="bugdetail-info-value">
|
||||
<Tag color={priorityColor[bug.priority]}>{priorityLabel[bug.priority] || bug.priority}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bugdetail-info-row">
|
||||
<span className="bugdetail-info-label">状态</span>
|
||||
<span className="bugdetail-info-value">
|
||||
<Tag color={statusConfig[bug.status]?.color} icon={statusConfig[bug.status]?.icon}>
|
||||
{statusLabel[bug.status] || bug.status}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bugdetail-info-row">
|
||||
<span className="bugdetail-info-label">提交时间</span>
|
||||
<span className="bugdetail-info-value">{formatDateTime(bug.created_at)}</span>
|
||||
</div>
|
||||
<div className="bugdetail-info-row">
|
||||
<span className="bugdetail-info-label">反馈编号</span>
|
||||
<span className="bugdetail-info-value">#{bug.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bugdetail-info-card">
|
||||
<h3 className="bugdetail-info-title"><EnvironmentOutlined /> 问题描述</h3>
|
||||
<div className="bugdetail-description">
|
||||
{bug.description.split("\n").map((line, i) => (
|
||||
<p key={i}>{line || <br />}</p>
|
||||
))}
|
||||
</div>
|
||||
{bugImages.length > 0 && (
|
||||
<div className="bugdetail-images">
|
||||
<h4><PictureOutlined /> 问题截图</h4>
|
||||
<div className="bugdetail-images-grid">
|
||||
{bugImages.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={img}
|
||||
width={120}
|
||||
height={80}
|
||||
style={{ objectFit: "cover", borderRadius: 8, cursor: "pointer" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bugdetail-chat-panel">
|
||||
<Card className="bugdetail-chat-card">
|
||||
<h3 className="bugdetail-chat-title">
|
||||
<CustomerServiceOutlined /> 沟通记录
|
||||
<span className="bugdetail-chat-count">{comments.length} 条消息</span>
|
||||
</h3>
|
||||
|
||||
<div className="bugdetail-chat-messages">
|
||||
{comments.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`bugdetail-msg ${msg.is_admin ? "admin" : "user"}`}
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
src={msg.user_avatar || undefined}
|
||||
icon={msg.is_admin ? <CustomerServiceOutlined /> : <UserOutlined />}
|
||||
className={`bugdetail-msg-avatar ${msg.is_admin ? "admin" : "user"}`}
|
||||
/>
|
||||
<div className="bugdetail-msg-body">
|
||||
<div className="bugdetail-msg-header">
|
||||
<span className="bugdetail-msg-name">{msg.user_name}</span>
|
||||
{msg.is_admin && <Tag color="blue" className="bugdetail-msg-tag">官方</Tag>}
|
||||
<span className="bugdetail-msg-time">{formatDateTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div className="bugdetail-msg-content">
|
||||
{msg.content}
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div className="bugdetail-msg-images">
|
||||
{msg.images.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={img.image}
|
||||
width={160}
|
||||
height={100}
|
||||
style={{ objectFit: "cover", borderRadius: 8, cursor: "pointer" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{msg.attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={att.file}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12, color: "#667eea" }}
|
||||
>
|
||||
<FileOutlined /> {att.filename} ({formatFileSize(att.file_size)})
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{bug.status !== "resolved" && bug.status !== "rejected" && (
|
||||
<div className="bugdetail-reply-area">
|
||||
<div className="bugdetail-reply-input">
|
||||
<TextArea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="输入回复内容..."
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendReply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedImages.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
{imagePreviews.map((url, i) => (
|
||||
<div key={i} style={{ position: "relative" }}>
|
||||
<img
|
||||
src={url}
|
||||
alt={`预览 ${i}`}
|
||||
style={{ width: 80, height: 60, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }}
|
||||
/>
|
||||
<CloseCircleOutlined
|
||||
style={{ position: "absolute", top: -6, right: -6, fontSize: 16, color: "#ff4d4f", cursor: "pointer", background: "#fff", borderRadius: "50%" }}
|
||||
onClick={() => handleRemoveImage(i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAttachments.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 10 }}>
|
||||
{selectedAttachments.map((file, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "#666", background: "#f5f5f5", padding: "4px 10px", borderRadius: 6 }}>
|
||||
<FileOutlined />
|
||||
<span>{file.name}</span>
|
||||
<span style={{ color: "#999" }}>({formatFileSize(file.size)})</span>
|
||||
<CloseCircleOutlined
|
||||
style={{ fontSize: 14, color: "#ff4d4f", cursor: "pointer", marginLeft: "auto" }}
|
||||
onClick={() => handleRemoveAttachment(i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmojiPicker && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, padding: "8px 12px", background: "#fff", border: "1px solid #eee", borderRadius: 10, marginBottom: 10, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<span
|
||||
key={emoji}
|
||||
style={{ fontSize: 20, cursor: "pointer", padding: 4, borderRadius: 4, transition: "background 0.2s" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "#f0f0f0")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bugdetail-reply-actions">
|
||||
<Space>
|
||||
<Tooltip title="上传图片">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined />}
|
||||
size="small"
|
||||
disabled={selectedImages.length >= 3}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="表情">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SmileOutlined />}
|
||||
size="small"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="附件">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PaperClipOutlined />}
|
||||
size="small"
|
||||
disabled={selectedAttachments.length >= 2}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sending}
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim()}
|
||||
>
|
||||
发送回复
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(bug.status === "resolved" || bug.status === "rejected") && (
|
||||
<div className="bugdetail-resolved-bar">
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
<span>此反馈已{statusLabel[bug.status] || bug.status},如需继续沟通请提交新的反馈</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
accept={ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
onChange={handleAttachmentSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BugDetail;
|
||||
Reference in New Issue
Block a user