From e8f1604c119988c9bff9d9e9a7369aac4d587f39 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 30 Apr 2026 13:50:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=80=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20=E5=90=84=E6=A8=A1=E5=9D=97=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E7=BB=9F=E4=B8=80=E5=8A=A8=E6=80=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 components/RotatingFooterHint:统一文案+蓝色脉冲,4 秒轮换 - 新增 components/FeedbackFab:右下角悬浮按钮(渐变 + 心形信封 + 黄色脉冲点), 点击打开 4 步引导式弹窗 Step 1 选类型(💡新维度 / 🐛bug / 🎨界面 / 📝其他) Step 2 描述需求 + 选当前板块(chip) Step 3 留联系方式(可选)+ 提交概览 Step 4 ❤️ 成功页(弹簧 √ 动画) 顶部 spring 进度条,底部上一步/下一步,下拉手柄,背景点击或 X 关闭 - 后端 routes/feedback:bi_user_feedback 表(自动建表,含 status 字段) POST /api/feedback/submit + GET /api/feedback/list - Shell 全局挂载 FeedbackFab,自动从 hash 检测当前模块 - 各模块底部追加 RotatingFooterHint: AssetsModule / MileageModule / SchedulingModule / EleImportPage HydrogenOverview / HydrogenDaily / ElectricOverview / ElectricDaily (HydrogenOverview 旧的内嵌实现已替换为共享组件) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/FeedbackFab.tsx | 336 ++++++++++++++++++++ src/components/RotatingFooterHint.tsx | 51 +++ src/components/Shell.tsx | 2 + src/modules/assets/AssetsModule.tsx | 3 +- src/modules/ele/EleImportPage.tsx | 5 + src/modules/energy/ElectricDaily.tsx | 2 + src/modules/energy/ElectricOverview.tsx | 2 + src/modules/energy/HydrogenDaily.tsx | 2 + src/modules/energy/HydrogenOverview.tsx | 36 +-- src/modules/mileage/MileageModule.tsx | 2 + src/modules/scheduling/SchedulingModule.tsx | 2 + src/server/index.ts | 2 + src/server/routes/feedback/index.ts | 77 +++++ 13 files changed, 486 insertions(+), 36 deletions(-) create mode 100644 src/components/FeedbackFab.tsx create mode 100644 src/components/RotatingFooterHint.tsx create mode 100644 src/server/routes/feedback/index.ts 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 && ( + +
+

说说具体内容

+