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>
134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { DemoModeProvider } from './Blur';
|
|
import FeedbackFab from './FeedbackFab';
|
|
|
|
export interface ModuleConfig {
|
|
id: string;
|
|
label: string;
|
|
icon: ComponentType<{ size?: number; className?: string }>;
|
|
component: ComponentType;
|
|
}
|
|
|
|
/** path 到模块 id 的映射 */
|
|
const PATH_MAP: Record<string, string> = {
|
|
'/vehicle': 'assets',
|
|
'/assets': 'assets',
|
|
'/mileage': 'mileage',
|
|
'/scheduling': 'scheduling',
|
|
'/energy': 'energy',
|
|
};
|
|
|
|
function getInitialModule(modules: ModuleConfig[]): string {
|
|
// 优先看 hash
|
|
const hash = window.location.hash.slice(1);
|
|
if (modules.some((m) => m.id === hash)) return hash;
|
|
// 再看 pathname
|
|
const pathModule = PATH_MAP[window.location.pathname];
|
|
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
// 默认第一个
|
|
return modules[0]?.id ?? '';
|
|
}
|
|
|
|
function getHashModule(modules: ModuleConfig[]): string {
|
|
const hash = window.location.hash.slice(1);
|
|
return modules.some((m) => m.id === hash) ? hash : '';
|
|
}
|
|
|
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
|
|
|
useEffect(() => {
|
|
const onHashChange = () => {
|
|
const h = getHashModule(modules);
|
|
if (h) setActiveModule(h);
|
|
};
|
|
window.addEventListener('hashchange', onHashChange);
|
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
}, [modules]);
|
|
|
|
useEffect(() => {
|
|
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
|
if (window.location.hash.slice(1) !== activeModule) {
|
|
const { pathname, search } = window.location;
|
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
|
}
|
|
}, [activeModule]);
|
|
|
|
const switchModule = (id: string) => {
|
|
if (window.location.hash.slice(1) === id) return;
|
|
const { pathname, search } = window.location;
|
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
|
setActiveModule(id);
|
|
};
|
|
|
|
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
|
|
|
const { user } = useAuth();
|
|
const watermarkText = useMemo(() => {
|
|
const name = user?.userName || '未登录';
|
|
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
|
return `${name}-${time}`;
|
|
}, [user]);
|
|
|
|
return (
|
|
<DemoModeProvider enabled={false}>
|
|
<div className="flex min-h-screen">
|
|
{/* 全局水印 */}
|
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
|
<div className="absolute inset-0" style={{
|
|
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
|
|
backgroundRepeat: 'repeat',
|
|
}} />
|
|
</div>
|
|
{/* Web 侧边栏 (md 及以上) */}
|
|
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
|
{modules.map((m) => {
|
|
const Icon = m.icon;
|
|
const isActive = m.id === activeModule;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => switchModule(m.id)}
|
|
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
|
isActive
|
|
? 'bg-blue-50 text-blue-600'
|
|
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<Icon size={22} />
|
|
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* 内容区 */}
|
|
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
|
{ActiveComponent && <ActiveComponent />}
|
|
<FeedbackFab module={activeModule} />
|
|
</main>
|
|
|
|
{/* 移动端底部导航 (md 以下) */}
|
|
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
|
{modules.map((m) => {
|
|
const Icon = m.icon;
|
|
const isActive = m.id === activeModule;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => switchModule(m.id)}
|
|
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
|
>
|
|
<Icon size={20} />
|
|
<span className="text-[10px] mt-1">{m.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
</DemoModeProvider>
|
|
);
|
|
}
|