import { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles, ImagePlus, Loader2, Inbox } from 'lucide-react'; import { fetchJson } from '../auth/api-client'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; const MAX_SCREENSHOTS = 6; const MAX_IMG_SIZE_MB = 5; interface UploadedImg { url: string; thumbDataUrl: string; } 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); }); } type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other'; const TYPE_OPTIONS: { key: FeedbackType; emoji: string; label: string; sub: string; color: string }[] = [ { key: 'dimension', emoji: '💡', label: '想看新的统计维度', sub: '比如按 XX 维度分组', color: 'from-amber-50 to-orange-50 border-amber-100' }, { key: 'bug', emoji: '🐛', label: '报告一个 bug', sub: '哪里看着不对劲', color: 'from-rose-50 to-red-50 border-rose-100' }, { key: 'ux', emoji: '🎨', label: '界面 / 体验建议', sub: '哪里能更顺手', color: 'from-violet-50 to-fuchsia-50 border-violet-100' }, { key: 'other', emoji: '📝', label: '其他想法', sub: '欢迎随便聊聊', color: 'from-slate-50 to-blue-50 border-slate-100' }, ]; const MODULE_LABELS: Record = { assets: '资产管理', mileage: '里程管理', energy: '能源管理', scheduling: '智能调度', ele: '充电导入', '': '通用', }; 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 [open, setOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [step, setStep] = useState<1 | 2 | 3 | 4>(1); const [type, setType] = useState(null); const [mod, setMod] = useState(''); const [content, setContent] = useState(''); const [contact, setContact] = 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); } }; // Open: detect current module useEffect(() => { if (open && step === 1) { setMod(moduleProp ?? detectModule()); } }, [open, step, moduleProp]); // Lock scroll when open 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(''); setContact(''); 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(), contact: contact.trim() || null, screenshots: shots.map(s => s.url), userAgent: navigator.userAgent.slice(0, 500), }), }); setStep(4); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }; const next = () => { if (step === 1 && !type) return; if (step === 2 && !content.trim()) { taRef.current?.focus(); return; } if (step === 3) { submit(); return; } setStep((step + 1) as typeof step); }; const back = () => setStep((Math.max(1, step - 1)) as typeof step); const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true; const progress = ((step === 4 ? 4 : step) / 4) * 100; return ( <> {/* Floating Action Button */}
setMenuOpen(m => !m)} className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group" aria-label="反馈" title="提建议 / 我的反馈" > {menuOpen && ( )} {/* 点击外面关菜单 */} {menuOpen && (
setMenuOpen(false)} /> )}
setHistoryOpen(false)} /> {/* Modal */} {open && ( e.stopPropagation()} > {/* Drag handle */}
{/* Header + progress */}
{step === 4 ? '收到啦~' : '提个建议'}
{step === 1 && '第一步 / 共 3 步'} {step === 2 && '第二步 / 共 3 步'} {step === 3 && '第三步 / 共 3 步'} {step === 4 && '感谢你的反馈'}
{/* Body */}
{step === 1 && (

想反馈什么呢?

{TYPE_OPTIONS.map(opt => ( ))}
)} {step === 2 && (

说说具体内容