import { useEffect, useRef, useState } from 'react'; import { fetchJson } from '../auth/api-client'; import { useAuth } from '../auth/useAuth'; import { canManageFeedback } from '../shared/auth/roles'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; import FeedbackLauncher from './feedback/FeedbackLauncher'; import FeedbackModal from './feedback/FeedbackModal'; import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types'; const MAX_SCREENSHOTS = 6; const MAX_IMG_SIZE_MB = 5; async function uploadImage(file: File): Promise { const fd = new FormData(); fd.append('file', file); const token = sessionStorage.getItem('bi_jwt'); const res = await fetch('/api/feedback/upload', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); const json = await res.json(); if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`); return json.url as string; } function readAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result || '')); r.onerror = reject; r.readAsDataURL(file); }); } function detectModule(): string { const hash = (window.location.hash || '').slice(1); const path = window.location.pathname; if (path.includes('/ele/')) return 'ele'; if (hash.includes('ele')) return 'ele'; if (hash.startsWith('/')) return hash.split('/')[1] || ''; return hash || ''; } interface Props { /** 显式覆盖当前模块(否则自动从 URL 检测) */ module?: string; } export default function FeedbackFab({ module: moduleProp }: Props = {}) { const { user } = useAuth(); const isAdmin = canManageFeedback(user?.roles); const [open, setOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [step, setStep] = useState(1); const [type, setType] = useState(null); const [mod, setMod] = useState(''); const [content, setContent] = useState(''); const [shots, setShots] = useState([]); const [uploading, setUploading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const taRef = useRef(null); const fileRef = useRef(null); const addFiles = async (files: FileList | File[]) => { const list = Array.from(files).filter(f => f.type.startsWith('image/')); if (list.length === 0) return; setError(null); setUploading(true); try { for (const f of list) { if (shots.length >= MAX_SCREENSHOTS) break; if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) { setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`); continue; } const thumbDataUrl = await readAsDataUrl(f); const url = await uploadImage(f); setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]); } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setUploading(false); } }; useEffect(() => { if (open && step === 1) { setMod(moduleProp ?? detectModule()); } }, [open, step, moduleProp]); useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [open]); const reset = () => { setStep(1); setType(null); setContent(''); setShots([]); setError(null); }; const close = () => { setOpen(false); setTimeout(reset, 300); }; const submit = async () => { if (!type || !content.trim()) return; setSubmitting(true); setError(null); try { await fetchJson('/api/feedback/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, module: mod || null, content: content.trim(), screenshots: shots.map(s => s.url), userAgent: navigator.userAgent.slice(0, 500), }), }); setStep(3); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }; const next = () => { if (step === 1) { if (!type) return; setStep(2); return; } if (step === 2) { if (!content.trim()) { taRef.current?.focus(); return; } submit(); return; } }; const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep); const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true; const progress = step >= 3 ? 100 : (step / 2) * 100; return ( <> setMenuOpen(false)} onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }} onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }} onToggleMenu={() => setMenuOpen(current => !current)} /> setHistoryOpen(false)} /> setShots(prev => prev.filter((_, current) => current !== index))} onSelectType={(selectedType) => { setType(selectedType); setStep(2); }} /> ); }