移除区域标签栏和自选时区面板
This commit is contained in:
@@ -167,11 +167,15 @@ function CityRegionList({
|
||||
rows,
|
||||
selectedCity,
|
||||
onSelectCity,
|
||||
slots = [],
|
||||
activeSlotIndex = 0,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
rows: ScanOpportunityRow[];
|
||||
selectedCity: string | null;
|
||||
onSelectCity: (city: string) => void;
|
||||
slots?: Array<string | null>;
|
||||
activeSlotIndex?: number;
|
||||
}) {
|
||||
const cities = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -191,28 +195,110 @@ function CityRegionList({
|
||||
|
||||
return (
|
||||
<Panel title={isEn ? "Cities" : "城市"}>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{cities.map(({ city, name, localTime }) => (
|
||||
<button
|
||||
key={city}
|
||||
type="button"
|
||||
onClick={() => onSelectCity(city)}
|
||||
className={clsx(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-left hover:bg-blue-50/70 transition-colors",
|
||||
selectedCity === city && "bg-blue-50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-bold text-slate-800 truncate">{name}</div>
|
||||
<div className="text-[11px] text-slate-400">{localTime}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="divide-y divide-slate-100 max-h-[calc(100vh-140px)] overflow-y-auto">
|
||||
{cities.map(({ city, name, localTime }) => {
|
||||
const isActive = selectedCity === city;
|
||||
const displaySlotIndices = slots
|
||||
.map((s, idx) => (s === city ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={city}
|
||||
type="button"
|
||||
onClick={() => onSelectCity(city)}
|
||||
className={clsx(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-left hover:bg-blue-50/70 transition-colors",
|
||||
isActive && "bg-blue-50 border-l-2 border-blue-500 pl-2.5",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] font-bold text-slate-800 truncate">{name}</div>
|
||||
<div className="text-[11px] text-slate-400">{localTime}</div>
|
||||
</div>
|
||||
|
||||
{/* Slot indicators */}
|
||||
{displaySlotIndices.length > 0 && (
|
||||
<div className="flex gap-0.5 ml-2">
|
||||
{displaySlotIndices.map((idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"grid h-4 w-4 place-items-center rounded-full text-[9px] font-bold",
|
||||
idx === activeSlotIndex
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-slate-200 text-slate-600"
|
||||
)}
|
||||
title={isEn ? `Slot ${idx + 1}` : `槽位 ${idx + 1}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySlotCard({
|
||||
slotIndex,
|
||||
isActive,
|
||||
isEn,
|
||||
availableCities,
|
||||
onSelectSlot,
|
||||
onSelectCity,
|
||||
}: {
|
||||
slotIndex: number;
|
||||
isActive: boolean;
|
||||
isEn: boolean;
|
||||
availableCities: { city: string; name: string }[];
|
||||
onSelectSlot: () => void;
|
||||
onSelectCity: (city: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelectSlot}
|
||||
className={clsx(
|
||||
"flex flex-col items-center justify-center h-full rounded-[4px] border-2 border-dashed p-6 cursor-pointer bg-slate-50/50 transition-all",
|
||||
isActive
|
||||
? "border-blue-500 bg-blue-50/10 ring-2 ring-blue-500/20"
|
||||
: "border-slate-300 hover:border-slate-400 hover:bg-slate-50/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-400 mb-2">
|
||||
<span className="text-xl font-bold">+</span>
|
||||
</div>
|
||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||
{isEn ? `Slot ${slotIndex + 1}: Empty` : `槽位 ${slotIndex + 1}: 空白`}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 text-center mb-3 max-w-[180px]">
|
||||
{isEn
|
||||
? "Select this card and click a city on the left, or select below:"
|
||||
: "激活此卡片并从左侧选择城市,或从下方直接选择:"}
|
||||
</div>
|
||||
<select
|
||||
value=""
|
||||
onClick={(e) => e.stopPropagation()} // prevent triggering onSelectSlot
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onSelectCity(e.target.value);
|
||||
}}
|
||||
className="text-[11px] font-semibold text-slate-600 px-2 py-1 rounded border border-slate-300 bg-white outline-none max-w-[160px]"
|
||||
>
|
||||
<option value="">{isEn ? "Choose city..." : "选择城市..."}</option>
|
||||
{availableCities.map((c) => (
|
||||
<option key={c.city} value={c.city}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolyWeatherTerminal({
|
||||
generatedText,
|
||||
isEn,
|
||||
@@ -278,6 +364,19 @@ function PolyWeatherTerminal({
|
||||
const [navExpanded, setNavExpanded] = useState(false);
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("contracts");
|
||||
|
||||
const [slots, setSlots] = useState<Array<string | null>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("polyweather_terminal_slots");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length === 4) return parsed;
|
||||
}
|
||||
} catch {}
|
||||
return [null, null, null, null];
|
||||
});
|
||||
const [activeSlotIndex, setActiveSlotIndex] = useState<number>(0);
|
||||
const [maximizedSlotIndex, setMaximizedSlotIndex] = useState<number | null>(null);
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "contracts", Icon: Table2, labelEn: "Contracts", labelZh: "天气合约" },
|
||||
{ key: "markets", Icon: Activity, labelEn: "Markets", labelZh: "市场概览" },
|
||||
@@ -290,10 +389,44 @@ function PolyWeatherTerminal({
|
||||
|
||||
const filteredRegionRows = useMemo(() => {
|
||||
return rows.filter(
|
||||
(row) => getCityRegion(row) === selectedRegionKey,
|
||||
(row) => resolveTradingRegionKey(row) === selectedRegionKey,
|
||||
);
|
||||
}, [rows, selectedRegionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRegionRows.length && slots.every((s) => s === null)) {
|
||||
const next = [filteredRegionRows[0].city, null, null, null];
|
||||
setSlots(next);
|
||||
try {
|
||||
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
}, [filteredRegionRows, slots]);
|
||||
|
||||
const handleSelectCityForSlot = (index: number, city: string | null) => {
|
||||
const next = [...slots];
|
||||
next[index] = city;
|
||||
setSlots(next);
|
||||
try {
|
||||
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const availableCities = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: { city: string; name: string }[] = [];
|
||||
filteredRegionRows.forEach((row) => {
|
||||
const key = String(row.city || "").toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
result.push({
|
||||
city: key,
|
||||
name: rowName(row),
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, [filteredRegionRows]);
|
||||
|
||||
const watchRows = useMemo(() => {
|
||||
return filteredRegionRows
|
||||
.filter((row) => decisionLabel(row) === "Watch" || !row.tradable)
|
||||
@@ -502,64 +635,6 @@ function PolyWeatherTerminal({
|
||||
<TrainingDashboard isEn={isEn} />
|
||||
) : (
|
||||
<>
|
||||
{/* Region tabs */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto rounded-[4px] border border-[#cfd6df] bg-white p-1 mb-2 scrollbar-none">
|
||||
{REGIONS.filter((r) => visibleRegions.has(r.key)).map((r) => ({
|
||||
key: r.key,
|
||||
labelEn: r.labelEn.toUpperCase(),
|
||||
labelZh: r.labelZh,
|
||||
})).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedRegionKey(tab.key)}
|
||||
className={clsx(
|
||||
"px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider rounded-[3px] transition-all whitespace-nowrap",
|
||||
selectedRegionKey === tab.key
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-800"
|
||||
)}
|
||||
>
|
||||
{isEn ? tab.labelEn : tab.labelZh}
|
||||
</button>
|
||||
))}
|
||||
{/* Region selector gear */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const el = document.getElementById("region-selector-popover");
|
||||
if (el) el.classList.toggle("hidden");
|
||||
}}
|
||||
className="px-1.5 py-1 text-[10px] font-bold text-slate-400 hover:text-slate-600 rounded-[3px] hover:bg-slate-100"
|
||||
title={isEn ? "Customize regions" : "自选时区"}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div
|
||||
id="region-selector-popover"
|
||||
className="hidden absolute right-0 top-full mt-1 z-50 bg-white border border-slate-200 rounded-lg shadow-lg p-2 min-w-[180px]"
|
||||
>
|
||||
{REGIONS.map((r) => {
|
||||
const checked = visibleRegions.has(r.key);
|
||||
return (
|
||||
<label
|
||||
key={r.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-slate-50 cursor-pointer text-[11px] font-semibold text-slate-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleRegion(r.key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{isEn ? r.labelEn : r.labelZh}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile layout */}
|
||||
<div className="flex flex-col gap-2 lg:hidden overflow-auto flex-1 pb-6">
|
||||
<MobileRegionTabs
|
||||
@@ -607,13 +682,142 @@ function PolyWeatherTerminal({
|
||||
<CityRegionList
|
||||
isEn={isEn}
|
||||
rows={filteredRegionRows}
|
||||
selectedCity={selectedCity}
|
||||
onSelectCity={setSelectedCity}
|
||||
selectedCity={slots[activeSlotIndex]}
|
||||
onSelectCity={(city) => handleSelectCityForSlot(activeSlotIndex, city)}
|
||||
slots={slots}
|
||||
activeSlotIndex={activeSlotIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
<LiveTemperatureThresholdChart isEn={isEn} row={selectedRow} allRows={filteredRegionRows} />
|
||||
{maximizedSlotIndex !== null ? (
|
||||
// Maximized view
|
||||
<div
|
||||
onClick={() => setActiveSlotIndex(maximizedSlotIndex)}
|
||||
className={clsx(
|
||||
"relative h-full rounded-[4px] border overflow-hidden border-blue-500 ring-2 ring-blue-500/20 shadow-md z-10"
|
||||
)}
|
||||
>
|
||||
{/* Floating actions toolbar */}
|
||||
<div className="absolute right-[110px] top-[6px] z-20 flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMaximizedSlotIndex(null);
|
||||
}}
|
||||
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-slate-800 transition-colors shadow-sm"
|
||||
title={isEn ? "Restore Grid" : "还原网格"}
|
||||
>
|
||||
❐
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={slots.filter(Boolean).length <= 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectCityForSlot(maximizedSlotIndex, null);
|
||||
setMaximizedSlotIndex(null);
|
||||
}}
|
||||
className={clsx(
|
||||
"grid h-6 w-6 place-items-center rounded border transition-colors shadow-sm",
|
||||
slots.filter(Boolean).length <= 1
|
||||
? "bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed"
|
||||
: "bg-white hover:bg-slate-50 border-slate-200 text-slate-500 hover:text-red-600"
|
||||
)}
|
||||
title={isEn ? "Clear Slot" : "清除槽位"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LiveTemperatureThresholdChart
|
||||
isEn={isEn}
|
||||
row={filteredRegionRows.find((r) => String(r.city || "").toLowerCase() === slots[maximizedSlotIndex]) || null}
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 2x2 grid layout
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-2 h-full">
|
||||
{[0, 1, 2, 3].map((slotIndex) => {
|
||||
const isSlotActive = activeSlotIndex === slotIndex;
|
||||
const cityInSlot = slots[slotIndex];
|
||||
|
||||
if (!cityInSlot) {
|
||||
return (
|
||||
<EmptySlotCard
|
||||
key={slotIndex}
|
||||
slotIndex={slotIndex}
|
||||
isActive={isSlotActive}
|
||||
isEn={isEn}
|
||||
availableCities={availableCities}
|
||||
onSelectSlot={() => setActiveSlotIndex(slotIndex)}
|
||||
onSelectCity={(city) => handleSelectCityForSlot(slotIndex, city)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rowForSlot = filteredRegionRows.find(
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slotIndex}
|
||||
onClick={() => setActiveSlotIndex(slotIndex)}
|
||||
className={clsx(
|
||||
"relative h-full rounded-[4px] border overflow-hidden transition-all",
|
||||
isSlotActive
|
||||
? "border-blue-500 ring-2 ring-blue-500/20 shadow-md z-10"
|
||||
: "border-[#d2d9e2] hover:border-slate-400"
|
||||
)}
|
||||
>
|
||||
{/* Floating actions toolbar */}
|
||||
<div className="absolute right-[110px] top-[6px] z-20 flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMaximizedSlotIndex(slotIndex);
|
||||
setActiveSlotIndex(slotIndex);
|
||||
}}
|
||||
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-slate-800 transition-colors shadow-sm"
|
||||
title={isEn ? "Maximize" : "最大化"}
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={slots.filter(Boolean).length <= 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectCityForSlot(slotIndex, null);
|
||||
}}
|
||||
className={clsx(
|
||||
"grid h-6 w-6 place-items-center rounded border transition-colors shadow-sm",
|
||||
slots.filter(Boolean).length <= 1
|
||||
? "bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed"
|
||||
: "bg-white hover:bg-slate-50 border-slate-200 text-slate-500 hover:text-red-600"
|
||||
)}
|
||||
title={isEn ? "Clear Slot" : "清除槽位"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LiveTemperatureThresholdChart
|
||||
isEn={isEn}
|
||||
row={rowForSlot}
|
||||
allRows={filteredRegionRows}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user