476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
ChevronRight,
|
||
MapPin,
|
||
Search,
|
||
Truck,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import type { RegionalInventoryStats } from "../types";
|
||
import { Picker, unique } from "./shared";
|
||
import { VehicleDetails } from "./VehicleDetails";
|
||
import {
|
||
INVENTORY_TYPES,
|
||
inventoryModel,
|
||
inventoryTotals,
|
||
scopedInventorySelection,
|
||
} from "./inventory-view";
|
||
import {
|
||
inventoryBrowseRows,
|
||
inventoryBrowseUrl,
|
||
inventoryModels,
|
||
inventoryPlaces,
|
||
inventoryTypeRows,
|
||
readInventoryBrowse,
|
||
type InventoryBrowseState,
|
||
} from "./inventory-explorer";
|
||
|
||
const fmt = (value: number) => value.toLocaleString();
|
||
const regionName = (value: string | null) => value || "未归属地区";
|
||
const cityName = (value: string | null) => value || "未归属城市";
|
||
|
||
export function InventoryExplorer({
|
||
inventory,
|
||
subject,
|
||
}: {
|
||
inventory: RegionalInventoryStats[];
|
||
subject: string | null;
|
||
}) {
|
||
const [state, setState] = useState(() =>
|
||
readInventoryBrowse(window.location.search),
|
||
);
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||
const positions = useRef(
|
||
new Map<
|
||
string,
|
||
{ scroll: [Element, number][]; y: number; focus?: string }
|
||
>(),
|
||
);
|
||
const currentUrl = useRef(window.location.href);
|
||
const locationKey = JSON.stringify([state.region, state.city]);
|
||
const lastLocation = useRef(locationKey);
|
||
|
||
const remember = () => {
|
||
const scroll: [Element, number][] = [];
|
||
let element = sectionRef.current?.parentElement;
|
||
while (element) {
|
||
if (element.scrollHeight > element.clientHeight)
|
||
scroll.push([element, element.scrollTop]);
|
||
element = element.parentElement;
|
||
}
|
||
positions.current.set(currentUrl.current, {
|
||
scroll,
|
||
y: window.scrollY,
|
||
focus: (document.activeElement as HTMLElement | null)?.dataset
|
||
.inventoryKey,
|
||
});
|
||
};
|
||
useEffect(() => {
|
||
const onBack = () => {
|
||
currentUrl.current = window.location.href;
|
||
setState(readInventoryBrowse(window.location.search));
|
||
};
|
||
window.addEventListener("popstate", onBack);
|
||
return () => window.removeEventListener("popstate", onBack);
|
||
}, []);
|
||
useEffect(() => {
|
||
if (lastLocation.current === locationKey) return;
|
||
lastLocation.current = locationKey;
|
||
const saved = positions.current.get(currentUrl.current);
|
||
if (saved) {
|
||
const target = saved.focus
|
||
? Array.from(
|
||
sectionRef.current?.querySelectorAll<HTMLElement>(
|
||
"[data-inventory-key]",
|
||
) || [],
|
||
).find((element) => element.dataset.inventoryKey === saved.focus)
|
||
: null;
|
||
(target || headingRef.current)?.focus({ preventScroll: true });
|
||
saved.scroll.forEach(([element, top]) => {
|
||
element.scrollTop = top;
|
||
});
|
||
window.scrollTo({ top: saved.y, behavior: "instant" });
|
||
} else {
|
||
headingRef.current?.focus({ preventScroll: true });
|
||
headingRef.current?.scrollIntoView({
|
||
block: "nearest",
|
||
behavior: "instant",
|
||
});
|
||
}
|
||
}, [locationKey]);
|
||
|
||
const navigate = (change: Partial<InventoryBrowseState>, replace = false) => {
|
||
remember();
|
||
const next = { ...state, ...change };
|
||
const url = inventoryBrowseUrl(window.location.href, next);
|
||
if (url === window.location.href) return;
|
||
if (replace) window.history.replaceState(window.history.state, "", url);
|
||
else
|
||
window.history.pushState(
|
||
{ ...window.history.state, inventoryParent: window.location.href },
|
||
"",
|
||
url,
|
||
);
|
||
currentUrl.current = url;
|
||
setState(next);
|
||
};
|
||
const goBack = () => {
|
||
const next =
|
||
state.model !== null
|
||
? { model: null }
|
||
: state.city !== null
|
||
? { city: null, model: null }
|
||
: { region: null, city: null, model: null };
|
||
const url = inventoryBrowseUrl(window.location.href, { ...state, ...next });
|
||
if (window.history.state?.inventoryParent === url) window.history.back();
|
||
else navigate(next);
|
||
};
|
||
|
||
const scoped = useMemo(
|
||
() => inventoryBrowseRows(inventory, state),
|
||
[inventory, state],
|
||
);
|
||
const rows = useMemo(
|
||
() => inventoryTypeRows(scoped, state.type),
|
||
[scoped, state.type],
|
||
);
|
||
const total = inventoryTotals(rows);
|
||
const places = inventoryPlaces(
|
||
scoped,
|
||
state.region === null ? "region" : "city",
|
||
state.type,
|
||
);
|
||
const models = useMemo(() => inventoryModels(rows), [rows]);
|
||
const chosenModel = models.find((model) => model.key === state.model);
|
||
const label = [
|
||
state.region === null ? "全部地区" : regionName(state.region),
|
||
state.city === null ? "" : cityName(state.city),
|
||
state.type || "全部车型",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" / ");
|
||
const selection = useMemo(
|
||
() =>
|
||
chosenModel
|
||
? scopedInventorySelection({
|
||
rows: chosenModel.rows,
|
||
label: `${label} / ${chosenModel.brand || "未标注品牌"} / ${chosenModel.model}`,
|
||
})
|
||
: null,
|
||
[chosenModel, label],
|
||
);
|
||
const level = state.region === null ? 0 : state.city === null ? 1 : 2;
|
||
const hasFilters = Boolean(state.query || state.brand);
|
||
|
||
return (
|
||
<section
|
||
ref={sectionRef}
|
||
className="inventory-matrix inventory-explorer"
|
||
aria-label="库存找车"
|
||
>
|
||
<header className="finder-header">
|
||
<div>
|
||
<h2>库存找车</h2>
|
||
<p>选地区、选城市,快速找到对应车辆</p>
|
||
</div>
|
||
<MapPin aria-hidden="true" />
|
||
</header>
|
||
<ol className="finder-steps" aria-label="找车步骤">
|
||
{["选地区", "选城市", "查车辆"].map((title, index) => (
|
||
<li key={title} aria-current={index === level ? "step" : undefined}>
|
||
<span>{index + 1}</span>
|
||
{title}
|
||
{index < 2 && <ChevronRight aria-hidden="true" />}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
<nav className="finder-breadcrumb" aria-label="库存位置">
|
||
{state.region === null ? (
|
||
<span aria-current="page">全部地区</span>
|
||
) : (
|
||
<button
|
||
onClick={() => navigate({ region: null, city: null, model: null })}
|
||
>
|
||
全部地区
|
||
</button>
|
||
)}
|
||
{state.region !== null && (
|
||
<>
|
||
<ChevronRight aria-hidden="true" />
|
||
{state.city === null ? (
|
||
<span aria-current="page">{regionName(state.region)}</span>
|
||
) : (
|
||
<button onClick={() => navigate({ city: null, model: null })}>
|
||
{regionName(state.region)}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
{state.city !== null && (
|
||
<>
|
||
<ChevronRight aria-hidden="true" />
|
||
<span aria-current="page">{cityName(state.city)}</span>
|
||
</>
|
||
)}
|
||
</nav>
|
||
<div className="finder-scope">
|
||
{level > 0 && (
|
||
<button
|
||
className="finder-back"
|
||
aria-label={level === 2 ? "返回城市列表" : "返回地区列表"}
|
||
onClick={goBack}
|
||
>
|
||
<ArrowLeft aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
<div>
|
||
<h3 ref={headingRef} tabIndex={-1}>
|
||
{state.city !== null
|
||
? cityName(state.city)
|
||
: state.region !== null
|
||
? regionName(state.region)
|
||
: "地区库存"}
|
||
</h3>
|
||
<p>
|
||
{state.type || "全部车型"}
|
||
{hasFilters ? " · 已筛选" : ""} ·{" "}
|
||
{state.city !== null
|
||
? `${models.length} 种型号`
|
||
: `${places.filter((place) => place.total.quantity > 0).length} 个${level === 0 ? "地区" : "城市"}有库存`}
|
||
</p>
|
||
</div>
|
||
<div className="finder-scope-total" aria-live="polite">
|
||
<strong>{fmt(total.quantity)}</strong>
|
||
<span>库存 / 台</span>
|
||
</div>
|
||
</div>
|
||
<div className="finder-type-heading">
|
||
<strong>找什么车型</strong>
|
||
<span>数量为当前范围库存</span>
|
||
</div>
|
||
<div className="matrix-mobile-types finder-types" aria-label="库存车型">
|
||
{["", ...INVENTORY_TYPES].map((type) => (
|
||
<button
|
||
key={type}
|
||
aria-pressed={state.type === type}
|
||
onClick={() => navigate({ type, model: null })}
|
||
>
|
||
{type || "全部车型"}
|
||
<span>
|
||
{fmt(inventoryTotals(inventoryTypeRows(scoped, type)).quantity)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="finder-search-row">
|
||
<label className="matrix-search">
|
||
<Search aria-hidden="true" />
|
||
<Input
|
||
value={state.query}
|
||
placeholder={
|
||
level === 0
|
||
? "搜索地区、城市、品牌或型号"
|
||
: "搜索当前范围的城市、品牌或型号"
|
||
}
|
||
aria-label="搜索当前范围库存"
|
||
onChange={(event) =>
|
||
navigate({ query: event.target.value, model: null }, true)
|
||
}
|
||
/>
|
||
{state.query && (
|
||
<button
|
||
aria-label="清空库存搜索"
|
||
onClick={() => navigate({ query: "", model: null }, true)}
|
||
>
|
||
<X aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</label>
|
||
<Picker
|
||
label="品牌"
|
||
value={state.brand}
|
||
values={unique(inventory.map((row) => row.brand))}
|
||
change={(brand) => navigate({ brand, model: null })}
|
||
/>
|
||
</div>
|
||
<div className="finder-results-heading">
|
||
<strong>
|
||
{level === 0
|
||
? "选择地区"
|
||
: level === 1
|
||
? "选择城市"
|
||
: "选择型号查看车辆"}
|
||
</strong>
|
||
{hasFilters ? (
|
||
<button
|
||
onClick={() => navigate({ query: "", brand: "", model: null })}
|
||
>
|
||
清除筛选
|
||
</button>
|
||
) : (
|
||
<span>{level < 2 ? "按库存从多到少" : "点击进入车辆明细"}</span>
|
||
)}
|
||
</div>
|
||
{total.quantity === 0 && (
|
||
<div className="finder-empty" role="status">
|
||
<Truck aria-hidden="true" />
|
||
<strong>
|
||
{hasFilters
|
||
? "当前范围没有匹配车辆"
|
||
: state.type
|
||
? `当前范围暂无${state.type}库存`
|
||
: "当前范围暂无库存"}
|
||
</strong>
|
||
<p>
|
||
{hasFilters
|
||
? "试试清除搜索或品牌条件,也可以返回上级查找。"
|
||
: "可切换车型,或返回上级查看其他地区、城市。"}
|
||
</p>
|
||
{(state.type || hasFilters) && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
navigate({ type: "", query: "", brand: "", model: null })
|
||
}
|
||
>
|
||
查看当前范围全部库存
|
||
</Button>
|
||
)}
|
||
{level > 0 && (
|
||
<Button variant="ghost" onClick={goBack}>
|
||
返回{level === 2 ? "城市" : "地区"}列表
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{level < 2 ? (
|
||
<div className="finder-list">
|
||
{places.map((place) => (
|
||
<button
|
||
key={place.key}
|
||
className="finder-place"
|
||
data-inventory-key={place.key}
|
||
disabled={!place.total.quantity}
|
||
aria-label={`${place.name},${state.type || "全部车型"}库存 ${place.total.quantity} 台,${level === 0 ? "选择城市" : "查看车型"}`}
|
||
onClick={() =>
|
||
navigate(
|
||
level === 0
|
||
? { region: place.value, city: null, model: null }
|
||
: { city: place.value, model: null },
|
||
)
|
||
}
|
||
>
|
||
<span className="finder-place-top">
|
||
<strong>{place.name}</strong>
|
||
<span className="finder-place-count">
|
||
{fmt(place.total.quantity)}
|
||
<small>台</small>
|
||
{place.total.quantity > 0 && (
|
||
<ChevronRight aria-hidden="true" />
|
||
)}
|
||
</span>
|
||
</span>
|
||
{place.total.quantity > 0 ? (
|
||
<>
|
||
<span className="finder-place-status">
|
||
{level === 0 && <span>{place.cities} 个城市有库存</span>}
|
||
<span>可运营 {fmt(place.total.available)}</span>
|
||
<span>待运营 {fmt(place.total.awaiting)}</span>
|
||
</span>
|
||
<span className="finder-type-tags">
|
||
{(state.type
|
||
? place.types.filter((item) => item.name === state.type)
|
||
: place.types
|
||
).map((item) => (
|
||
<span key={item.name}>
|
||
{item.name}
|
||
<b>{fmt(item.quantity)}</b>
|
||
</span>
|
||
))}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="finder-place-status">
|
||
{state.type ? "该车型暂无库存" : "暂无库存"}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="finder-list">
|
||
{models.map((model) => (
|
||
<button
|
||
key={model.key}
|
||
className="finder-place finder-model"
|
||
data-inventory-key={model.key}
|
||
onClick={() => navigate({ model: model.key })}
|
||
>
|
||
<span className="finder-place-top">
|
||
<strong>{inventoryModel(model.model, model.brand).name}</strong>
|
||
<span className="finder-place-count">
|
||
{fmt(model.total.quantity)}
|
||
<small>台</small>
|
||
</span>
|
||
</span>
|
||
<span className="finder-model-meta">
|
||
{model.brand || "未标注品牌"} · {model.type}
|
||
{inventoryModel(model.model, model.brand).operator &&
|
||
` · ${inventoryModel(model.model, model.brand).operator}`}
|
||
</span>
|
||
<span className="finder-place-status">
|
||
<span>可运营 {fmt(model.total.available)}</span>
|
||
<span>待运营 {fmt(model.total.awaiting)}</span>
|
||
{model.total.unclassified > 0 && (
|
||
<span>待核实 {fmt(model.total.unclassified)}</span>
|
||
)}
|
||
</span>
|
||
<span className="finder-model-footer">
|
||
<span>
|
||
{[
|
||
model.total.pending
|
||
? `待交车 ${fmt(model.total.pending)}`
|
||
: "",
|
||
model.total.abnormal
|
||
? `异动 ${fmt(model.total.abnormal)}`
|
||
: "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</span>
|
||
<span>
|
||
查看车辆
|
||
<ArrowRight aria-hidden="true" />
|
||
</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{state.model !== null && !chosenModel && (
|
||
<div className="finder-empty" role="status">
|
||
<strong>该型号已不在当前库存范围</strong>
|
||
<Button variant="outline" onClick={goBack}>
|
||
返回选择型号
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<footer className="matrix-footer">
|
||
<span>库存包含待交车、异动车辆,不代表均可立即交付。</span>
|
||
<span>「其他」包含挂车及未单列车型。</span>
|
||
</footer>
|
||
<VehicleDetails
|
||
selection={selection}
|
||
subject={subject}
|
||
close={goBack}
|
||
returnLabel="返回找车结果"
|
||
/>
|
||
</section>
|
||
);
|
||
}
|