diff --git a/src/components/FeedbackFab.tsx b/src/components/FeedbackFab.tsx new file mode 100644 index 0000000..3b67239 --- /dev/null +++ b/src/components/FeedbackFab.tsx @@ -0,0 +1,336 @@ +import { useEffect, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles } from 'lucide-react'; +import { fetchJson } from '../auth/api-client'; + +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 [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 [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const taRef = useRef(null); + + // 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(''); + 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, + 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 */} + setOpen(true)} + className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60] 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="提建议 / 想看的数据" + > + + + + + {/* 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 && ( + +
+

说说具体内容

+