feat: 全局反馈系统 + 各模块底部统一动态提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 新增 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) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-30 13:50:39 +08:00
parent 08f21b7e24
commit e8f1604c11
13 changed files with 486 additions and 36 deletions

View File

@@ -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<string, string> = {
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<FeedbackType | null>(null);
const [mod, setMod] = useState<string>('');
const [content, setContent] = useState('');
const [contact, setContact] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const taRef = useRef<HTMLTextAreaElement>(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 */}
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={() => 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="提建议 / 想看的数据"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
{/* Modal */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={close}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 4 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 3 步'}
{step === 2 && '第二步 / 共 3 步'}
{step === 3 && '第三步 / 共 3 步'}
{step === 4 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
<p className="text-[12px] font-bold text-slate-600 mb-3"></p>
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map(opt => (
<button
key={opt.key}
onClick={() => { setType(opt.key); setStep(2); }}
className={`text-left p-3 rounded-2xl border bg-gradient-to-br ${opt.color} hover:shadow-md transition-all flex items-center gap-3 ${type === opt.key ? 'ring-2 ring-blue-400 shadow' : ''}`}
>
<span className="text-2xl">{opt.emoji}</span>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-black text-slate-800">{opt.label}</div>
<div className="text-[10px] text-slate-500 font-bold mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={14} className="text-slate-400 flex-shrink-0" />
</button>
))}
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2"></p>
<textarea
ref={taRef}
value={content}
onChange={(e) => setContent(e.target.value)}
autoFocus
rows={5}
maxLength={1000}
placeholder={
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…'
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
: '随便聊聊你的想法'
}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([k, label]) => (
<button
key={k}
onClick={() => setMod(k)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div key="s3" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<p className="text-[12px] font-bold text-slate-600">便</p>
<p className="text-[11px] text-slate-400 font-bold"></p>
<input
type="text"
value={contact}
onChange={(e) => setContact(e.target.value)}
autoFocus
maxLength={120}
placeholder="微信 / 钉钉 / 邮箱 / 手机号 都行"
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20"
/>
<div className="bg-slate-50 rounded-xl p-3 mt-1">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1.5"></div>
<div className="text-[11px] text-slate-600 space-y-1">
<div><span className="font-bold text-slate-800">{TYPE_OPTIONS.find(t => t.key === type)?.label}</span></div>
<div><span className="font-bold text-slate-800">{MODULE_LABELS[mod] || '通用'}</span></div>
<div className="break-words"><span className="font-bold text-slate-800 line-clamp-3">{content}</span></div>
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
)}
{step === 4 && (
<motion.div key="s4" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1"> </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed"><br /></div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step < 4 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={back}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} />
</button>
)}
<div className="flex-1" />
<button
onClick={next}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 3 ? '提交' : '下一步'}
{!submitting && step !== 3 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 4 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={close}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}