Files
ftb-project-management/apps/web/components/bug/BugCreateModal.tsx
2026-06-26 11:18:00 +08:00

166 lines
8.8 KiB
TypeScript
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.

'use client';
import { useState, useMemo, useRef } from 'react';
import { X, ImagePlus, Trash2 } from 'lucide-react';
import { useBugStore } from '@/stores/useBugStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { resolveMemberDisplayName } from '@/lib/member-system';
import type { Priority } from '@/lib/derive';
import type { BugSeverity } from '@/lib/bug';
interface Props {
testCaseId: string;
onClose: () => void;
}
export function BugCreateModal({ testCaseId, onClose }: Props) {
const { createBug } = useBugStore();
const { testCases } = useTestCaseStore();
const { tasks: devTasks } = useDevTaskStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const tc = testCases.find((c) => c.id === testCaseId);
const requirement = requirements.find((r) => r.id === tc?.requirementId);
const defaultAssignee = useMemo(() => {
if (!tc) return '';
const reqTasks = devTasks.filter((t) => t.requirementId === tc.requirementId);
const inProgress = reqTasks.filter((t) => t.status === 'in_progress').sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (inProgress.length > 0) return inProgress[0].assigneeId;
const latest = reqTasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return latest.length > 0 ? latest[0].assigneeId : '';
}, [tc, devTasks]);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [severity, setSeverity] = useState<BugSeverity>('major');
const [priority, setPriority] = useState<Priority>(tc?.priority || 'P1');
const [assigneeId, setAssigneeId] = useState(defaultAssignee);
const [images, setImages] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const canSubmit = title.trim() && description.trim() && assigneeId;
const handleFiles = (files: FileList | null) => {
if (!files) return;
Array.from(files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => [...prev, reader.result as string]);
};
reader.readAsDataURL(file);
});
};
const handleSubmit = () => {
if (!canSubmit) return;
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
createBug({
versionId: tc?.versionId || '',
testCaseId,
requirementId: tc?.requirementId || undefined,
title: title.trim(),
description: description.trim(),
severity,
priority,
reportedBy: operator,
assigneeId,
images: images.length > 0 ? images : undefined,
}, operator);
onClose();
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]"> Bug</h3>
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 mb-4 space-y-1 text-[12px]">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc?.caseNo} {tc?.title}</span></div>
{requirement && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{requirement.code} {requirement.title}</span></div>}
</div>
<div className="space-y-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">Bug *</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="简要描述问题" />
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">Bug *</label>
<textarea rows={5} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="复现步骤、实际结果、预期结果" />
</div>
{/* 图片上传 */}
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<div
className="rounded-lg border-2 border-dashed border-[var(--line)] p-4 text-center cursor-pointer hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)] transition-colors"
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleFiles(e.dataTransfer.files); }}
>
<ImagePlus className="h-6 w-6 text-[var(--ink-muted)] mx-auto mb-1" />
<span className="text-[12px] text-[var(--ink-muted)]"></span>
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={(e) => handleFiles(e.target.files)} />
</div>
{images.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{images.map((src, i) => (
<div key={i} className="relative group">
<img src={src} alt={`截图${i + 1}`} className="h-20 w-20 object-cover rounded-lg border border-[var(--line)]" />
<button
onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))}
className="absolute -top-1.5 -right-1.5 h-5 w-5 flex items-center justify-center rounded-full bg-red-500 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<option value="critical"></option>
<option value="major"></option>
<option value="minor"></option>
<option value="trivial"></option>
</select>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<select value={priority} onChange={(e) => setPriority(e.target.value as Priority)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50"> Bug</button>
</div>
</div>
</div>
);
}