feat: 全局反馈系统 + 各模块底部统一动态提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
336
src/components/FeedbackFab.tsx
Normal file
336
src/components/FeedbackFab.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/RotatingFooterHint.tsx
Normal file
51
src/components/RotatingFooterHint.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const FOOTER_HINTS = [
|
||||||
|
'想看哪个角度的数据?告诉我们一下嘛',
|
||||||
|
'更多统计维度接入中,欢迎您的建议 ~',
|
||||||
|
'下一个图表,可能就是您建议的那个',
|
||||||
|
'数据科学家正在深夜挖掘新维度…',
|
||||||
|
'维度灵感正在路上,钉一下产品同学也行',
|
||||||
|
'数字背后还有故事,等下一次上线揭晓',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 自定义提示词集合,默认使用通用文案 */
|
||||||
|
hints?: string[];
|
||||||
|
/** 切换间隔,默认 4 秒 */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** 额外类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 点击时回调(一般用来打开反馈弹窗) */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hints.length <= 1) return;
|
||||||
|
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [hints, intervalMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-1 flex items-center justify-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{ animation: 'rotatingHintFade 0.5s ease' }}
|
||||||
|
>
|
||||||
|
{hints[idx]}
|
||||||
|
</span>
|
||||||
|
<style>{`
|
||||||
|
@keyframes rotatingHintFade {
|
||||||
|
from { opacity: 0; transform: translateY(2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
|
import FeedbackFab from './FeedbackFab';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,6 +107,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
{ActiveComponent && <ActiveComponent />}
|
||||||
|
<FeedbackFab module={activeModule} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type { WeeklyDetailItem } from './api';
|
|||||||
import { SearchSelect } from '../../components/SearchSelect';
|
import { SearchSelect } from '../../components/SearchSelect';
|
||||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -2838,7 +2839,7 @@ export default function AssetsModule() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
import { useAuth } from '../../auth/useAuth';
|
import { useAuth } from '../../auth/useAuth';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import FeedbackFab from '../../components/FeedbackFab';
|
||||||
|
|
||||||
function getJwt(): string | null {
|
function getJwt(): string | null {
|
||||||
return sessionStorage.getItem('bi_jwt');
|
return sessionStorage.getItem('bi_jwt');
|
||||||
@@ -345,7 +347,10 @@ export default function EleImportPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackFab module="ele" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
|||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, ElectricMonthGroup } from './types';
|
import type { CustomerType, ElectricMonthGroup } from './types';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
@@ -114,6 +115,7 @@ export default function ElectricDaily() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Wallet, CalendarClock } from 'lucide-react';
|
import { Wallet, CalendarClock } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -95,6 +96,7 @@ export default function ElectricOverview() {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from
|
|||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchHydrogenDaily } from './api';
|
import { fetchHydrogenDaily } from './api';
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'today', label: '当天' },
|
{ id: 'today', label: '当天' },
|
||||||
@@ -214,6 +215,7 @@ export default function HydrogenDaily() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
interface YAxisTickProps {
|
interface YAxisTickProps {
|
||||||
x?: number;
|
x?: number;
|
||||||
@@ -29,15 +30,6 @@ const REGION_COLORS = [
|
|||||||
'#94a3b8',
|
'#94a3b8',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 幽默动态提示词,每 4 秒轮换一条
|
|
||||||
const FOOTER_HINTS = [
|
|
||||||
'更多统计维度接入中,欢迎您的建议 ~',
|
|
||||||
'下一个图表,可能就是您建议的那个',
|
|
||||||
'数据科学家正在深夜挖掘新维度…',
|
|
||||||
'想看哪个角度的数据?告诉我们一下嘛',
|
|
||||||
'维度灵感正在路上,钉一下产品同学也行',
|
|
||||||
'数字背后还有故事,等下一次上线揭晓',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HydrogenOverview() {
|
export default function HydrogenOverview() {
|
||||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||||
@@ -153,32 +145,6 @@ export default function HydrogenOverview() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RotatingFooterHint() {
|
|
||||||
const [idx, setIdx] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setIdx(i => (i + 1) % FOOTER_HINTS.length), 4000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-slate-400 font-bold">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className="transition-opacity duration-300"
|
|
||||||
style={{ animation: 'hintFade 0.5s ease' }}
|
|
||||||
>
|
|
||||||
{FOOTER_HINTS[idx]}
|
|
||||||
</span>
|
|
||||||
<style>{`
|
|
||||||
@keyframes hintFade {
|
|
||||||
from { opacity: 0; transform: translateY(2px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HydrogenOverviewSkeleton() {
|
function HydrogenOverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 animate-pulse">
|
<div className="flex flex-col gap-3 animate-pulse">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion } from 'motion/react';
|
|||||||
import MonitoringView from './MonitoringView';
|
import MonitoringView from './MonitoringView';
|
||||||
import StatisticsView from './StatisticsView';
|
import StatisticsView from './StatisticsView';
|
||||||
import DailyReportView from './DailyReportView';
|
import DailyReportView from './DailyReportView';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
export default function MileageModule() {
|
export default function MileageModule() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||||
@@ -52,6 +53,7 @@ export default function MileageModule() {
|
|||||||
) : (
|
) : (
|
||||||
<DailyReportView />
|
<DailyReportView />
|
||||||
)}
|
)}
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SuggestionDetail from './SuggestionDetail';
|
|||||||
import NotificationHistory from './NotificationHistory';
|
import NotificationHistory from './NotificationHistory';
|
||||||
import { exportSuggestionsCsv } from './csv-export';
|
import { exportSuggestionsCsv } from './csv-export';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -632,6 +633,7 @@ export default function SchedulingModule() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import mileageRouter from './routes/mileage/index.js';
|
|||||||
import schedulingRouter from './routes/scheduling/index.js';
|
import schedulingRouter from './routes/scheduling/index.js';
|
||||||
import energyRouter from './routes/energy/index.js';
|
import energyRouter from './routes/energy/index.js';
|
||||||
import eleRouter from './routes/ele/index.js';
|
import eleRouter from './routes/ele/index.js';
|
||||||
|
import feedbackRouter from './routes/feedback/index.js';
|
||||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -29,6 +30,7 @@ app.route('/api/mileage', mileageRouter);
|
|||||||
app.route('/api/scheduling', schedulingRouter);
|
app.route('/api/scheduling', schedulingRouter);
|
||||||
app.route('/api/energy', energyRouter);
|
app.route('/api/energy', energyRouter);
|
||||||
app.route('/api/ele', eleRouter);
|
app.route('/api/ele', eleRouter);
|
||||||
|
app.route('/api/feedback', feedbackRouter);
|
||||||
|
|
||||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
77
src/server/routes/feedback/index.ts
Normal file
77
src/server/routes/feedback/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../../db.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||||
|
module VARCHAR(64) NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
contact VARCHAR(200) NULL,
|
||||||
|
user_id VARCHAR(64) NULL,
|
||||||
|
user_name VARCHAR(128) NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
|
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_created_at (created_at),
|
||||||
|
KEY idx_type (type),
|
||||||
|
KEY idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`;
|
||||||
|
|
||||||
|
let ensured = false;
|
||||||
|
async function ensureTable(): Promise<void> {
|
||||||
|
if (ensured) return;
|
||||||
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
|
ensured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||||
|
|
||||||
|
app.post('/submit', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const body = await c.req.json().catch(() => ({})) as {
|
||||||
|
type?: string; module?: string | null; content?: string;
|
||||||
|
contact?: string | null; userAgent?: string;
|
||||||
|
};
|
||||||
|
const type = (body.type || '').trim();
|
||||||
|
const content = (body.content || '').trim();
|
||||||
|
if (!VALID_TYPES.has(type)) {
|
||||||
|
return c.json({ ok: false, message: '类型不合法' }, 400);
|
||||||
|
}
|
||||||
|
if (!content || content.length > 2000) {
|
||||||
|
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||||
|
const contact = (body.contact || '').slice(0, 200) || null;
|
||||||
|
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||||
|
|
||||||
|
const [r] = await pool.query<ResultSetHeader>(
|
||||||
|
`INSERT INTO bi_user_feedback (type, module, content, contact, user_id, user_name, user_agent, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
|
||||||
|
[type, moduleVal, content, contact, user?.userId || null, user?.userName || null, userAgent],
|
||||||
|
);
|
||||||
|
return c.json({ ok: true, id: r.insertId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/feedback/list — 简易列表(按时间倒序,最多 200 条)
|
||||||
|
app.get('/list', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT id, type, module, content, contact, user_name, status, created_at
|
||||||
|
FROM bi_user_feedback
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[limit],
|
||||||
|
);
|
||||||
|
return c.json({ items: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
Reference in New Issue
Block a user