262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
|
||
import { motion } from 'motion/react';
|
||
import type { SubjectOption } from '../api';
|
||
|
||
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
|
||
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
|
||
|
||
interface AssetsHeaderProps {
|
||
activeTab: AssetsTab;
|
||
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
|
||
theme: AssetsTheme;
|
||
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
|
||
lastUpdate: string;
|
||
loading: boolean;
|
||
selectedSubject: string | null;
|
||
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
|
||
subjects: SubjectOption[];
|
||
subjectDropdownOpen: boolean;
|
||
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||
subjectSearch: string;
|
||
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
|
||
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||
}
|
||
// --- Constants ---
|
||
const TABS = [
|
||
{ id: 'overview', label: '总览' },
|
||
{ id: 'department', label: '按部门' },
|
||
{ id: 'region', label: '按区域' },
|
||
{ id: 'customer', label: '按客户' },
|
||
];
|
||
|
||
function MarqueeBanner() {
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const innerRef = useRef<HTMLDivElement>(null);
|
||
const [overflow, setOverflow] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const check = () => {
|
||
if (!trackRef.current || !innerRef.current) return;
|
||
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
||
};
|
||
check();
|
||
const ro = new ResizeObserver(check);
|
||
ro.observe(trackRef.current!);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
|
||
|
||
return (
|
||
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
|
||
<div ref={trackRef} className="overflow-hidden">
|
||
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||
{text}
|
||
</span>
|
||
{overflow && (
|
||
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||
{text}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssetsHeader({
|
||
activeTab,
|
||
setActiveTab,
|
||
theme,
|
||
setTheme,
|
||
lastUpdate,
|
||
loading,
|
||
selectedSubject,
|
||
setSelectedSubject,
|
||
subjects,
|
||
subjectDropdownOpen,
|
||
setSubjectDropdownOpen,
|
||
subjectSearch,
|
||
setSubjectSearch,
|
||
subjectDropdownRef,
|
||
}: AssetsHeaderProps) {
|
||
return (
|
||
<>
|
||
{/* Compact Header Bar */}
|
||
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||
{/* Title row */}
|
||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||
{/* Right: status + theme */}
|
||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||
<span>{lastUpdate}</span>
|
||
</div>
|
||
{loading && (
|
||
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||
<Loader2 className="animate-spin" size={10} />
|
||
</div>
|
||
)}
|
||
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTheme(t)}
|
||
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||
>
|
||
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||
<div className="flex items-center justify-center px-4 pt-1">
|
||
<div className="relative" ref={subjectDropdownRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSubjectDropdownOpen((o) => !o);
|
||
setSubjectSearch('');
|
||
}}
|
||
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||
selectedSubject
|
||
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||
}`}
|
||
title={selectedSubject || '全部公司'}
|
||
>
|
||
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||
<span className="max-w-[180px] truncate">
|
||
所属公司:{selectedSubject || '全部公司'}
|
||
</span>
|
||
{selectedSubject ? (
|
||
<span
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedSubject(null);
|
||
}}
|
||
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||
aria-label="清除归属公司筛选"
|
||
>
|
||
×
|
||
</span>
|
||
) : (
|
||
<ChevronDown size={11} className="text-gray-400" />
|
||
)}
|
||
</button>
|
||
{subjectDropdownOpen && (
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||
<div className="p-2 border-b border-gray-100">
|
||
<div className="relative">
|
||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
autoFocus
|
||
value={subjectSearch}
|
||
onChange={(e) => setSubjectSearch(e.target.value)}
|
||
placeholder="搜索公司名"
|
||
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1 py-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedSubject(null);
|
||
setSubjectDropdownOpen(false);
|
||
}}
|
||
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||
}`}
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||
全部公司
|
||
</span>
|
||
<span className="text-[10px] text-gray-400">
|
||
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||
</span>
|
||
</button>
|
||
<div className="my-1 mx-3 border-t border-gray-100" />
|
||
{subjects
|
||
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||
.map((s) => {
|
||
const active = selectedSubject === s.name;
|
||
return (
|
||
<button
|
||
key={s.name}
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedSubject(s.name);
|
||
setSubjectDropdownOpen(false);
|
||
}}
|
||
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||
}`}
|
||
title={s.name}
|
||
>
|
||
<span className="flex items-center gap-1.5 min-w-0">
|
||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||
<span className="truncate">{s.name}</span>
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||
{s.total} 台
|
||
<span className="mx-1 text-gray-200">·</span>
|
||
<span className="text-green-500">运营 {s.operating}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab row */}
|
||
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||
{TABS.map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||
activeTab === tab.id
|
||
? 'text-blue-600 font-medium'
|
||
: 'text-gray-400 hover:text-gray-500'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
{activeTab === tab.id && (
|
||
<motion.div
|
||
layoutId="activeTab"
|
||
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||
/>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Status row */}
|
||
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||
<div className="flex items-center gap-1">
|
||
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||
最后更新: {lastUpdate}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* OneOS 迁移提示滚动条 */}
|
||
<MarqueeBanner />
|
||
</>
|
||
);
|
||
}
|