diff --git a/components.json b/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/docs/asset-ui-migration.md b/docs/asset-ui-migration.md new file mode 100644 index 0000000..b407ecf --- /dev/null +++ b/docs/asset-ui-migration.md @@ -0,0 +1,43 @@ +# 资产管理组件库迁移 + +## 范围与约束 + +资产管理的总览、库存统计、车型/批次、交还车、部门、区域、客户、筛选与明细弹窗。 + +- shadcn/ui:官方 CLI 生成的 Radix Nova 组件,中文化关闭按钮;使用组件默认尺寸、语义主题与 Tailwind 布局工具类。 +- Apache ECharts:趋势、区域对比和省份分布,按需加载;ResizeObserver 适配容器,卸载时销毁实例。 +- AG Grid Community:排序、列筛选、列宽调整、固定识别列和分页。未使用 Enterprise、授权密钥、企业分组或 Excel 导出接口。 +- H5:同一数据/列定义驱动 shadcn 卡片与分页;关键数量、异动类型/时间、待交车合同/脱敏客户直接可见,辅助信息按需展开。手机不加载桌面 Grid 模块。 +- Excel 导出沿用现有 xlsx,按需加载。 +- 移除旧资产展示组件及 assets.css/apple-tokens.css;没有新增资产自定义选择器。视觉变量直接采用 apple-design 技能提供的 `src/vendor/apple-design/tokens.css`,组件空间、颜色与暗色适配使用 shadcn/ui 属性和 Tailwind 工具类。其他 BI 模块未迁移。 + +资产总览采用靛蓝标题区、浅色分组底、蓝色运营卡与靛蓝库存卡,并缩小卡片高度以提高信息密度。操作控件集中在标题下方,主视图与库存子视图选中态更清晰。资产页提供浅色/深色切换并记住当前浏览器选择;图表坐标、图例及明细表格颜色跟随主题。 + +## 数据口径 + +总运营 = 租赁 + 自营;异动、待交车属于总库存子集,不重复累计。 +交还车日期统计为车次;车型/批次的本周交还车沿用后端按车辆去重统计。 +省份分布使用既有公司范围接口,页面明确标示不受表格筛选影响。 +待交车客户来自后端脱敏字段,合同仅使用当前待交车任务,不回退历史合同。 + +修正库存型号误用为合同批次条件、周明细漏传公司条件、区域统计与明细范围不同,以及区域“其他车型”误排除挂车的问题。区域城市折叠列表保留实际城市,不再把第九个及后续城市合并成无法准确下钻的“其他”。新增离线接口回归覆盖。 + +## 验证 + +- TypeScript 检查通过。 +- 全仓 108 项测试通过(包含新的公司/区域下钻测试),无数据库依赖的新增测试使用 repository stub。 +- 生产构建通过。AG Grid 与 ECharts 独立懒加载包仍超过 Vite 的 500 kB 提示阈值;未屏蔽该提示。 +- Web:总览、库存下钻 55 → 55、部门、客户脱敏/省份图、公司切换、区域“其他”下钻 6 → 6、车型入口、日期筛选。 +- 390 px H5:页面无横向溢出;车辆弹窗宽 358 px;搜索结果 1 条及分页、辅助信息折叠、日期区间应用可用。 +- 浏览器验收未发现 console error/warn。 +- 验收时异动、待交车均为 0,只完成其空状态验证;有记录的业务数据展示仍需后续验收。 + +本轮未发布、未推送。版本仍为优化前已提交的 1.1.16。 + +## 本轮可读性与交互调整 + +- 恢复深蓝标题、亮蓝操作、橙色异动提示,降低背景饱和度;库存数字使用深色,和运营数字区分。 +- 所有共享筛选面板默认折叠,已选条件在折叠标题显示,清空后回到未设条件状态。所属公司作为全局数据范围保留可见。 +- Web 与 H5 的记录数不超过 20 条时不分页,桌面表格高度适应数据;超过 20 条仍分页,避免大量车辆渲染。8 条以内不显示无必要的搜索框。 +- 手机标题与总数并排,主导航和统计页签触控高度至少 44 px。 +- 浏览器验证:部门 8 条完整展示且分页隐藏;766 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。 diff --git a/docs/ui-optimization-checklist.md b/docs/ui-optimization-checklist.md new file mode 100644 index 0000000..ac5a915 --- /dev/null +++ b/docs/ui-optimization-checklist.md @@ -0,0 +1,141 @@ +# LN-BI UI 优化待办清单 + +> 生成日期:2026-09-20 +> 来源:全局外壳/导航、数据看板与图表、业务操作页面三路 UI/UX 审查汇总 +> 用法:`- [ ]` 为未完成,`- [x]` 为已完成;每条附 `文件:行号` 证据,完成后建议连同代码一起提交。 +> 关联文档:`../LN-BI-项目分析报告.md` §11.4、`../LN-BI-重构方案.md` 阶段 5/6 + +--- + +## P0 可用性与数据信任(最优先) + +### 可访问性:下拉组件 +- [ ] SearchSelect 键盘/读屏不可用:补 `role="combobox"` + `aria-expanded` + listbox/option 语义、↑↓/Enter/Escape,或直接替换 Headless UI / Radix Combobox — `src/components/SearchSelect.tsx:47-68` +- [ ] MultiSearchSelect 同上,且焦点环颜色与单选版不一致(emerald vs blue)— `src/components/MultiSearchSelect.tsx:59-85` +- [ ] mileage SearchableSelect 选项为裸 div,键盘不可达 — `src/modules/mileage/monitoring/components/SearchableSelect.tsx:60-72` +- [ ] 排序表头补 `aria-sort`;三张 div 模拟表格中期换成真 `` — `src/modules/mileage/daily-report/VehicleTable.tsx:220-271`、`GroupRow.tsx:86-132`、`ElectricDaily.tsx:124-186`、`HydrogenDaily.tsx:192-272` + +### 可访问性:弹窗/全屏 +- [ ] 抽共用 Modal/BottomSheet 壳:统一 `role="dialog" aria-modal`、Escape 关闭、遮罩点击关闭、焦点圈禁与还原、滚动锁定、移动端安全区 — 参考 `src/components/feedback/FeedbackModal.tsx:59-66`、`FeedbackHistoryDrawer.tsx:67-83` +- [ ] 全屏监控/全屏表格补 aria-label + Escape 关闭 + 打开时焦点移到关闭按钮 — `src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx:111-119`、`src/modules/mileage/statistics/FullscreenTargetTable.tsx:49-60` +- [ ] 抽 `useScrollLock` hook,统一锁定 body 滚动(VehicleDetailModal/FlowDetailModal/NotificationHistory 目前未锁)— `src/modules/assets/components/VehicleDetailModal.tsx:47`、`FlowDetailModal.tsx:33`、`src/modules/scheduling/NotificationHistory.tsx:122` +- [ ] 移动端底栏分组弹层:修溢出屏幕(定位约束)、加遮罩点击关闭、活动项补 `aria-current` — `src/components/Shell.tsx:194-206` +- [ ] 全部图标按钮补 `aria-label`/`title`(监控工具栏等)— `src/modules/mileage/monitoring/components/MonitoringToolbar.tsx:88-93` +- [ ] 移动端卡片用 span 冒充可点击元素:改 ` + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..e8dfaad --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +
+
+ {children} +
+
- - - - {deptViewMode === 'manager' && } - - {deptViewMode === 'department' && ( - <> - - - - - )} - {deptViewMode === 'manager' && ( - <> - - - - - - - - )} - - - - - {deptViewMode === 'department' ? ( - deptData.map((dept) => { - const isExpanded = expandedDepts.has(dept.department); - return ( - - toggleDept(dept.department)} - > - - - - - - - - {isExpanded && ( - - - - )} - - ); - }) - ) : ( - managerStats.map((m) => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( - - toggleManagerDetails(m.manager)} - > - - - - - - - - - - - - {isManagerExpanded && ( - - - - )} - - ); - }) - )} - -
{deptViewMode === 'department' ? '部门名称' : '业务负责人'}所属部门{deptViewMode === 'department' ? '出勤率' : '合计资产'}总运营车辆出勤车辆闲置车辆4.5T冷链18T49T挂车其他详情
- {dept.department} - - - {dept.attendanceRate}% - - - - - - - - - {isExpanded ? : } -
-
- {dept.managers.map(m => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } - {m.manager} -
- -
- - {isManagerExpanded && ( -
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
-
- )} -
- ); - })} -
-
- {isManagerExpanded ? : } - {m.manager} - {m.department} { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 正在运营` }); - }} - > - {m.total} - ------ - -
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })}> - 4.5T - {m.t4_5} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })}> - 冷链 - {m.t4_5c} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })}> - 18T - {m.t18} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })}> - 49T - {m.t49} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })}> - 挂车 - {m.trailer} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })}> - 其他 - {m.other} -
-
-
- - - {/* Mobile Card View */} -
- {deptViewMode === 'department' ? ( - deptData.map((dept) => { - const isExpanded = expandedDepts.has(dept.department); - return ( -
-
toggleDept(dept.department)} - > -
-

{dept.department}

- - 出勤率: {dept.attendanceRate}% - -
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, category: 'Operating', source: 'department', title: `部门运营统计 - ${dept.department}` }); }}> -
总运营
-
{dept.totalAssets}
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, attendance: 'active', source: 'department', title: `部门运营统计 - ${dept.department} - 出勤车辆` }); }}> -
出勤
-
{dept.operatingCount}
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, attendance: 'idle', source: 'department', title: `部门运营统计 - ${dept.department} - 闲置车辆` }); }}> -
闲置
-
{dept.idleCount}
-
-
-
- {isExpanded ? : } -
-
- {isExpanded && ( -
- {dept.managers.map(m => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } - {m.manager} -
- -
- - {isManagerExpanded && ( -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
- )} -
- ); - })} -
- )} -
- ); - }) - ) : ( - managerStats.map((m) => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } -
-

{m.manager}

- {m.department} -
{ - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 正在运营` }); - }} - > - 资产: {m.total} -
-
-
- - -
- - {isManagerExpanded && ( -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
- )} -
- ); - }) - )} -
- - - )} - - {tabReady && activeTab === 'region' && ( -
- {/* Region Distribution Chart */} -
-
-
-
-

区域资产分布概览

-
-
- - -
-
-
- - - - - - - - - - - -
-
- - {/* Region - Vehicle - Customer Section */} -
-
-
-
-
-

区域运营统计

-

*按区域—车型—客户维度统计

-
-
- -
- - - - {isRegionFilterOpen && ( - <> - -
- -
-
- - setDraftRegionFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" /> -
- -
-
- - -
-
- - -
-
-
- - - - )} - -
-
- - {Object.values(regionFilters).some(v => v !== '') && ( -
- {regionFilters.customer && ( - - 客户: {regionFilters.customer} - - - )} - {regionFilters.region && ( - - 区域: {regionFilters.region} - - - )} - {regionFilters.city && ( - - 城市: {regionFilters.city} - - - )} - -
- )} - -
-
- - - - - - - - - - - - {regionData.map((r) => { - const isExpanded = expandedRegions.has(r.region); - return ( - - toggleRegion(r.region)} - > - - - - - - - {isExpanded && r.cities.map((city) => { - const cityKey = `${r.region}-${city.city}`; - const isCityExpanded = expandedRegionCities.has(cityKey); - return ( - - toggleRegionCity(cityKey)} - > - - - - - - - {isCityExpanded && city.typeBreakdown.map(tb => ( - - - - - - - - ))} - - ); - })} - - ); - })} - -
区域 / 车型 / 客户资产总数运营中待交车主要客户
- {isExpanded ? : } - - {r.region}区域 - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, source: 'region', title: `区域运营统计 - ${r.region}` }); }}>{r.totalAssets} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - 正在运营` }); }}>{r.operatingCount} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - 待交车` }); }}>{r.pendingCount}{r.customers.slice(0, 2).join(', ')}
- {isCityExpanded ? : } - - {city.city} - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, source: 'region', title: `区域运营统计 - ${city.city}` }); }}>{city.totalAssets} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - 正在运营` }); }}>{city.operatingCount} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - 待交车` }); }}>{city.pendingCount}{city.customers.slice(0, 2).join(', ')}
-
- {tb.type} 车型 -
setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total} setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating} { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}{tb.customers.slice(0, 2).join(', ')}
-
- - {/* Mobile View (Region) */} -
- {regionData.map((r) => { - const isExpanded = expandedRegions.has(r.region); - return ( -
-
toggleRegion(r.region)} - > -
- {isExpanded ? : } - - {r.region}区域 -
-
资产: {r.totalAssets}
-
- {isExpanded && ( - <> -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - 正在运营` })} - > -
运营中
-
{r.operatingCount}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - 待交车` })} - > -
待交车
-
{r.pendingCount}
-
-
-
- {r.typeBreakdown.map(tb => ( -
- {tb.type} 车型 -
- setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 正在运营` })} - > - 运:{tb.operating} - - setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })} - > - 待:{tb.pending} - -
-
- ))} -
- - )} -
- ); - })} -
-
- - )} - - {tabReady && activeTab === 'customer' && ( -
- - {/* Customer Operations Statistics Section */} -
-
-
-
-
-

客户运营统计

-

*按客户维度统计资产分布

-
-
- -
- - - - {isCustomerFilterOpen && ( - <> - {/* Backdrop */} - -
-
- -
-
- - setDraftCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" /> -
- -
- - -
- -
-
- - -
-
- - -
-
- - -
-
-
- - - - )} - -
- - - {Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '') && ( -
- {customerFilters.customer.length > 0 && ( - - 客户: {customerFilters.customer.join(', ')} - - - )} - {customerFilters.manager && ( - - 负责人: {customerFilters.manager} - - - )} - {customerFilters.brand && ( - - 品牌: {customerFilters.brand} - - - )} - {customerFilters.department && ( - - 部门: {customerFilters.department} - - - )} - {customerFilters.region && ( - - 区域: {customerFilters.region} - - - )} - -
- )} - -
- {/* Desktop Table View (Customer) */} -
- - - - - - - - - - - - - - - - - {filteredCustomerStats.map((cust) => { - const isExpanded = expandedCustomers.has(cust.customer); - return ( - - toggleCustomer(cust.customer)} - > - - - - - - - - - - - - {isExpanded && ( - - - - )} - - ); - })} - -
客户名称所在区域关联业务负责人4.5T4.5T冷链18T49T挂车其他合计
- {isExpanded ? : } - {cust.customer} - - {cust.region} - {cust.manager} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '49T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 49T` }); }}>{cust.t49} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 挂车` }); }}>{cust.trailer} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 其他` }); }}>{cust.other} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, source: 'customer', title: `客户运营统计 - ${cust.customer}` }); }}>{cust.total}
-
-
-
客户详情
-
{cust.customer}
-
-
-
主要车型
-
- {cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')} -
-
-
-
业务经理
-
{cust.manager}
-
-
-
资产占比
-
- {((cust.total / deptData.reduce((s, d) => s + d.totalAssets, 0)) * 100).toFixed(1)}% -
-
-
-
-
- - {/* Mobile Card View (Customer) */} -
- {filteredCustomerStats.map((cust) => { - const isExpanded = expandedCustomers.has(cust.customer); - return ( -
-
toggleCustomer(cust.customer)} - > -
- {isExpanded ? : } -
- {cust.customer} - {cust.region}区域 -
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, source: 'customer', title: `客户运营统计 - ${cust.customer}` }); }} - > - 合计: {cust.total} -
-
- - {isExpanded && ( -
- {/* Details Cards for Mobile */} -
-
-
客户详情
-
{cust.customer}
-
-
-
主要车型
-
- {cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')} -
-
-
-
业务经理
-
{cust.manager}
-
-
-
资产占比
-
- {((cust.total / deptData.reduce((s, d) => s + d.totalAssets, 0)) * 100).toFixed(1)}% -
-
-
- -
-
车型分布
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` })} - > -
4.5T
-
{cust.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` })} - > -
冷链
-
{cust.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` })} - > -
18T
-
{cust.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '49T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 49T` })} - > -
49T
-
{cust.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 挂车` })} - > -
挂车
-
{cust.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 其他` })} - > -
其他
-
{cust.other}
-
-
-
-
- )} -
- ); - })} -
-
- +
+ + {updated + ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` + : "正在加载数据"} + + +
- )} - - - - - setModalRetry(v=>v+1)} - weeklyDetails={modalWeeklyDetail} - filteredWeeklyDetails={filteredModalWeeklyDetail} - filteredVehicles={filteredModalVehicles} - /> - - +

+ {updated + ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` + : "正在加载数据"} +

+ {error && ( + + )} + {!data ? ( + loading ? ( + + ) : null + ) : ( + setHash({ tab: value as AssetTab })} + > + + + 总览 + + + 按部门 + + + 按区域 + + + 按客户 + + + + + setHash({ section: next }) + } + open={setSelection} + /> + + + + + + + + + + + + )} + setSelection(null)} + /> ); } diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index d04d63a..30ac0cc 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise { const query = new URLSearchParams(); + if (params.brand) query.set('brand', params.brand); if (params.batch) query.set('batch', params.batch); if (params.model) query.set('model', params.model); if (params.location) query.set('location', params.location); @@ -153,13 +155,14 @@ export async function fetchRegionChart( export async function fetchWeeklyDetail( type: string, - filters?: { model?: string; batch?: string; location?: string; source?: string }, + filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null }, ): Promise { const params = new URLSearchParams({ type }); if (filters?.model && filters.model !== 'All') params.set('model', filters.model); if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch); if (filters?.location && filters.location !== 'All') params.set('location', filters.location); if (filters?.source) params.set('source', filters.source); + if (filters?.subject) params.set('subject', filters.subject); return fetchJson(`${BASE}/weekly-detail?${params.toString()}`); } diff --git a/src/modules/assets/assets.css b/src/modules/assets/assets.css deleted file mode 100644 index 5b25f87..0000000 --- a/src/modules/assets/assets.css +++ /dev/null @@ -1,25 +0,0 @@ -/* Scoped readability and interaction rules for the asset workspace. */ -.asset-workspace { font-variant-numeric: tabular-nums; } -.asset-workspace :is(button, input, select, [tabindex]):focus-visible { - outline: 2px solid #2563eb; - outline-offset: 3px; -} -.asset-workspace :is(input, select) { min-height: 38px; } -.asset-workspace [class~="text-[8px]"], -.asset-workspace [class~="text-[9px]"], -.asset-workspace [class~="text-[10px]"] { font-size: 11px; line-height: 1.5; } -.asset-workspace :is(.text-gray-400, .text-slate-400) { color: #64748b; } -.asset-workspace table { font-variant-numeric: tabular-nums; } -.asset-workspace table th { white-space: nowrap; } -.asset-workspace table td { line-height: 1.6; } -.asset-workspace .asset-table-scroll { max-height: 65vh; overflow: auto; overscroll-behavior: contain; } -.asset-workspace .asset-table-scroll thead { position: sticky; top: 0; z-index: 10; background: #f8fafc; } -@media (max-width: 767px) { - .asset-workspace { padding-bottom: 28px; } - .asset-workspace :is(input,select) { min-height: 42px; font-size: 16px; } - .asset-workspace .asset-header { padding-top: 12px; } - .asset-workspace .asset-filter-panel { position: fixed; inset: auto 8px 12px; max-height: 80dvh; overflow-y: auto; width: auto; border-radius: 20px; } -} -@media (prefers-reduced-motion: reduce) { - .asset-workspace * { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } -} diff --git a/src/modules/assets/components/AbnormalVehicleDetails.tsx b/src/modules/assets/components/AbnormalVehicleDetails.tsx deleted file mode 100644 index 74dc0c7..0000000 --- a/src/modules/assets/components/AbnormalVehicleDetails.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Blur from '../../../components/Blur'; -import type { VehicleListItem } from '../types'; - -type DetailsProps = { vehicles: VehicleListItem[] }; - -function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) { - const pending = kind === 'pending'; - const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '预计结束时间']; - const details = (vehicle: VehicleListItem) => pending - ? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供'] - : [vehicle.abnormalMove?.type || '未提供', vehicle.abnormalMove?.targetCompletionTime || '未提供']; - - if (vehicles.length === 0) return
暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}
; - - return <> -

{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}

-
- {vehicles.map(vehicle =>
-

{vehicle.plateNumber || vehicle.vin || '—'}

-

{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}

-
- {details(vehicle).map((value, index) =>
-
{labels[index]}
-
{value}
-
)} -
-
)} -
-
- - - {['车牌', '品牌', '车型', '所在地', ...labels].map(label => )} - - {vehicles.map(vehicle => - - - - - {details(vehicle).map((value, index) => )} - )} -
{label}
{vehicle.plateNumber || vehicle.vin || '—'}{vehicle.brandLabel || '—'}{vehicle.type}{vehicle.location === '其他' ? '对接中' : vehicle.location}{value}
-
- ; -} - -export function AbnormalVehicleDetails(props: DetailsProps) { - return ; -} - -export function PendingVehicleDetails(props: DetailsProps) { - return ; -} diff --git a/src/modules/assets/components/AssetsHeader.tsx b/src/modules/assets/components/AssetsHeader.tsx deleted file mode 100644 index 1a8c0a4..0000000 --- a/src/modules/assets/components/AssetsHeader.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Building2, ChevronDown, RefreshCw, Search, X } from 'lucide-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>; - theme: AssetsTheme; - setTheme: React.Dispatch>; - lastUpdate: string; - loading: boolean; - onRefresh: () => void; - selectedSubject: string | null; - setSelectedSubject: React.Dispatch>; - subjects: SubjectOption[]; - subjectDropdownOpen: boolean; - setSubjectDropdownOpen: React.Dispatch>; - subjectSearch: string; - setSubjectSearch: React.Dispatch>; - subjectDropdownRef: React.RefObject; -} -const tabs: {id: AssetsTab; label: string}[] = [ - {id:'overview',label:'总览'}, {id:'department',label:'按部门'}, - {id:'region',label:'按区域'}, {id:'customer',label:'按客户'}, -]; - -export function AssetsHeader(props: AssetsHeaderProps) { - const {activeTab,setActiveTab,selectedSubject,setSelectedSubject,subjectDropdownOpen,setSubjectDropdownOpen} = props; - const chooseSubject = (subject: string | null) => {setSelectedSubject(subject);setSubjectDropdownOpen(false);}; - return
-
-

资产管理

运营、库存与交还车,一处查看

-
- - -
-
-
-
- - {selectedSubject && } - {subjectDropdownOpen &&
{if(e.key==='Escape'){setSubjectDropdownOpen(false);e.stopPropagation();}}}> - -
- - {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).map(s=>)} - {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).length===0 &&

没有匹配的公司

} -
-
} -
-

更新于 {props.lastUpdate.slice(11,16)} · 运维台账截至当日 17:00

-
- -
; -} diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx deleted file mode 100644 index 54ee692..0000000 --- a/src/modules/assets/components/FlowDetailModal.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useRef, useCallback } from 'react'; -import { useDialogFocus } from './useDialogFocus'; -import { Download, X } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { FlowDetailItem, FlowType } from '../api'; -import type { FlowSelection } from '../model'; - -export const FLOW_META: Record = { - delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' }, - returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' }, - replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' }, -}; - -interface FlowDetailModalProps { - selectedFlow: FlowSelection | null; - selectedFlowDetails: FlowDetailItem[]; - setSelectedFlow: React.Dispatch>; - exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; -} -export function FlowDetailModal({ - selectedFlow, - selectedFlowDetails, - setSelectedFlow, - exportFlowDetails, -}: FlowDetailModalProps) { - const dialogRef = useRef(null); - const close = useCallback(()=>setSelectedFlow(null),[setSelectedFlow]); - useDialogFocus(Boolean(selectedFlow),dialogRef,close); - const dateLabel = selectedFlow - ? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`) - : ''; - return ( - <> - {/* Flow Detail Modal */} - - {selectedFlow && ( -
- -
-
-
-
运维签章时间口径 · 单位:车次
-

- {dateLabel} · {FLOW_META[selectedFlow.type].label}明细 -

-
- 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户 -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - {selectedFlowDetails.map((item) => ( - - - - - - - - - ))} - -
车牌运维签章时间建单时间部门业务负责人客户
{item.plateNumber}{item.type === 'replaced' && 替换交车}{item.eventTime || '-'}{item.submitTime || '-'}{item.department || '-'}{item.manager || '-'}{item.customerName || '-'}
-
- -
- {selectedFlowDetails.map((item) => ( -
-
-
-
{item.plateNumber}
-
- {item.typeLabel} -
-
-
-
建单
-
{item.submitTime?.slice(5, 16) || '-'}
-
-
-
-
-
运维签章时间
-
{item.eventTime || '-'}
-
-
-
负责人
-
{item.manager || '-'}
-
-
-
部门
-
{item.department || '-'}
-
-
-
客户
-
{item.customerName || '-'}
-
-
-
- ))} -
- - {selectedFlowDetails.length === 0 && ( -
当前日期暂无明细
- )} -
-
-
- )} -
- - ); -} diff --git a/src/modules/assets/components/OverviewView.tsx b/src/modules/assets/components/OverviewView.tsx deleted file mode 100644 index 4610a34..0000000 --- a/src/modules/assets/components/OverviewView.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState } from 'react'; -import { AssetSummarySection } from './overview/AssetSummarySection'; -import { FlowStatisticsCard } from './overview/FlowStatisticsCard'; -import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection'; -import { SummaryMetrics } from './overview/SummaryMetrics'; -import type { OverviewViewProps } from './overview/types'; - -export function OverviewView(props: OverviewViewProps) { - const [panel, setPanel] = useState<'inventory' | 'models' | 'flow'>('inventory'); - return ( -
- - - - - - - -
- ); -} diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx deleted file mode 100644 index b87e902..0000000 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import React, { useRef, useCallback } from 'react'; -import { useDialogFocus } from './useDialogFocus'; -import { VehicleMobileCards } from './VehicleMobileCards'; -import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails'; -import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import Blur from '../../../components/Blur'; -import { SearchSelect } from '../../../components/SearchSelect'; -import type { WeeklyDetailItem } from '../api'; -import type { ModalVehicleFilters, VehicleModalSelection } from '../model'; -import type { VehicleListItem } from '../types'; - -interface VehicleDetailModalProps { - selection: VehicleModalSelection | null; - setSelection: React.Dispatch>; - isFilterExpanded: boolean; - setIsFilterExpanded: React.Dispatch>; - filters: ModalVehicleFilters; - setFilters: React.Dispatch>; - plates: string[]; - models: string[]; - brands: string[]; - locations: string[]; - loading: boolean; - error?: string | null; - onRetry?: () => void; - weeklyDetails: WeeklyDetailItem[]; - filteredWeeklyDetails: WeeklyDetailItem[]; - filteredVehicles: VehicleListItem[]; -} -export function VehicleDetailModal({ - selection: showPlateNumbers, - setSelection: setShowPlateNumbers, - isFilterExpanded: isModalFilterExpanded, - setIsFilterExpanded: setIsModalFilterExpanded, - filters: modalFilters, - setFilters: setModalFilters, - plates: uniqueModalPlates, - models: uniqueModalModels, - brands: uniqueModalBrands, - locations: uniqueModalLocations, - loading: modalLoading, - error, - onRetry, - weeklyDetails: modalWeeklyDetail, - filteredWeeklyDetails: filteredModalWeeklyDetail, - filteredVehicles: filteredModalVehicles, -}: VehicleDetailModalProps) { - const dialogRef = useRef(null); - const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]); - useDialogFocus(Boolean(showPlateNumbers), dialogRef, close); - return ( - <> - {/* Vehicle Detail Modal */} - - {showPlateNumbers && ( -
- -
-
-

- - {showPlateNumbers.title || ( - (showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` : - showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` : - showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细' - )} -

-

- {showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} | - {showPlateNumbers.category === 'Pending' ? '待交车' : - showPlateNumbers.category === 'Delivered' ? '本周已交车' : - showPlateNumbers.category === 'Returned' ? '已还车' : - showPlateNumbers.category === 'Replaced' ? '已替换' : - showPlateNumbers.category === 'Abnormal' ? '异动' : - showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location === 'All' ? '总' : showPlateNumbers.location}库存` : - showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'} -

-
- -
- - {/* Modal Filters */} -
-
setIsModalFilterExpanded(!isModalFilterExpanded)} - > -
- - 筛选条件 -
-
- {/* Quick Search always visible when collapsed */} - {!isModalFilterExpanded && ( -
e.stopPropagation()}> - setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="搜索车牌 / VIN" className="text-[11px] py-1 px-2" /> -
- )} - - - -
-
- - - {isModalFilterExpanded && ( - -
-
- - setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" /> -
-
- - -
-
- - -
-
- - -
-
-
- -
-
- )} -
-
- -
- {error ? ( -

{error}

- ) : modalLoading ? ( -
- -
- ) : showPlateNumbers.category === 'Abnormal' ? ( - - ) : showPlateNumbers.category === 'Pending' ? ( - - ) : modalWeeklyDetail.length > 0 ? ( -
- - - - - - - - - - {filteredModalWeeklyDetail.map((v, i) => ( - - - - - - ))} - -
车牌客户名称日期
{v.plate_number}{v.customer_name || '—'}{v.handover_date ? v.handover_date.slice(0, 10) : '—'}
-
- ) : ( - <> - -
- - - - {showPlateNumbers.source === 'customer' ? ( - <> - - - - - - - - - - - - - - - ) : ( - <> - - {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( - - )} - - - - - )} - - - - {filteredModalVehicles.map((v, idx) => ( - - {showPlateNumbers.source === 'customer' ? ( - <> - - - - - - - - - - - - - - - ) : ( - <> - - {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( - - )} - - - - - )} - - ))} - {filteredModalVehicles.length === 0 && ( - - - - )} - -
业务部门业务负责人品牌车型资产归属客户名称车牌状态提车时间合同到期时间运营区域离到期签约公司车牌客户名称品牌车型所在地
{v.departmentName || '—'}{v.customerManager || '—'}{v.brandLabel || '—'}{v.type}{v.subjectOrg || '—'}{v.customerName || '—'}{v.plateNumber || v.vin || '—'} - - {v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'} - - {'—'}{'—'}{v.location === '其他' ? '对接中' : v.location}{'—'}{v.orgName || '—'}{v.plateNumber || v.vin || '—'}{v.customerName || '—'}{v.brandLabel || '—'}{v.type}{v.location === '其他' ? '对接中' : v.location}
- 暂无符合条件的车辆数据 -
-
- - )} -
- -
-
- 共计 {['Delivered','Returned','Replaced'].includes(showPlateNumbers.category || '') ? filteredModalWeeklyDetail.length : filteredModalVehicles.length} 台车辆 -
- -
-
-
- )} -
- - ); -} diff --git a/src/modules/assets/components/VehicleMobileCards.tsx b/src/modules/assets/components/VehicleMobileCards.tsx deleted file mode 100644 index cd92207..0000000 --- a/src/modules/assets/components/VehicleMobileCards.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Blur from '../../../components/Blur'; -import type { VehicleListItem } from '../types'; - -export function VehicleMobileCards({vehicles, customer}: {vehicles: VehicleListItem[];customer: boolean}) { - return
- {vehicles.length===0 &&

暂无符合条件的车辆,试试调整筛选条件。

} - {vehicles.map(vehicle=>
-

{vehicle.plateNumber || vehicle.vin || '未提供车牌'}

{({Operating:'运营',Inventory:'库存',Pending:'待交车',Abnormal:'异动'} as Record)[vehicle.status] || '未提供'}
-

{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location==='其他'?'对接中':vehicle.location}

-
- {(customer ? [['客户',vehicle.customerName],['合同编号',vehicle.contractNo],['业务部门',vehicle.departmentName],['业务负责人',vehicle.customerManager],['资产归属',vehicle.subjectOrg]] : [['型号',vehicle.model],['客户',vehicle.customerName]]).map(([label,value])=>
{label}
{value || '未提供'}
)} -
-
)} -
; -} diff --git a/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx b/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx deleted file mode 100644 index a9772bc..0000000 --- a/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { AssetSummarySectionProps } from './types'; - -export function AssetSummaryDesktopTable({ - processedData, - theme, - expandedAssetTypes, - toggleAssetType, - expandedModels, - toggleModel, - setVehicleSelection: setShowPlateNumbers, -}: AssetSummarySectionProps) { - return ( -
- - - - - - - - - - - - - - - - - - - - - - {processedData.map((typeGroup) => ( - - {/* Category Header Row */} - {if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} onClick={() => toggleAssetType(typeGroup.type)}> - - - - - {expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => ( - - {if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} onClick={() => toggleModel(model.model)} - > - - - - - {['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => ( - - ))} - - - - - - - - - ))} - - - ))} - -
车型品牌型号车辆总资产库存总数库存-江浙沪库存-广东库存-北京库存-新疆库存-其他其中异动其中待交车当前在运营本周交车本周还车本周替换
-
-
- {expandedAssetTypes.has(typeGroup.type) ? - : - - } - {typeGroup.type} -
-
- 资产 {typeGroup.totalAssets} - 库存 {typeGroup.totalInventory} - 其中异动 {typeGroup.totalAbnormal} - 运营 {typeGroup.totalOperating} -
-
-
{typeGroup.type} -
- {expandedModels.has(model.model) ? : } - {model.model} -
-
- 资产 {model.total} - 库存 {model.inventory} - 运营 {model.operating} -
-
- - - - - {model.inventoryRegions[reg] > 0 ? ( - - ) : ''} - - - - {model.pending > 0 ? ( - - ) : model.pending} - - - - {model.weeklyDelivered > 0 ? ( - - ) : model.weeklyDelivered} - - {model.weeklyReturned > 0 ? ( - - ) : model.weeklyReturned} - - {model.weeklyReplaced > 0 ? ( - - ) : model.weeklyReplaced} -
-
- ); -} diff --git a/src/modules/assets/components/overview/AssetSummaryMobileCards.tsx b/src/modules/assets/components/overview/AssetSummaryMobileCards.tsx deleted file mode 100644 index c6a7ad0..0000000 --- a/src/modules/assets/components/overview/AssetSummaryMobileCards.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { AssetSummarySectionProps } from './types'; - -export function AssetSummaryMobileCards({ - processedData, - theme, - expandedAssetTypes, - toggleAssetType, - expandedModels, - toggleModel, - setVehicleSelection: setShowPlateNumbers, -}: AssetSummarySectionProps) { - return ( -
- {processedData.map((typeGroup) => ( -
-
{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleAssetType(typeGroup.type);}}} onClick={() => toggleAssetType(typeGroup.type)} - > -
- {expandedAssetTypes.has(typeGroup.type) ? - : - - } - {typeGroup.type} -
-
- 资产 {typeGroup.totalAssets} - 库存 {typeGroup.totalInventory} - 其中异动 {typeGroup.totalAbnormal} - 运营 {typeGroup.totalOperating} -
-
- - - {expandedAssetTypes.has(typeGroup.type) && ( - - {typeGroup.models.map((model) => ( -
-
{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleModel(model.model);}}} onClick={() => toggleModel(model.model)} - > -
- {expandedModels.has(model.model) ? : } - {model.model} -
-
- { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total} - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory} - - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating} -
-
- - {expandedModels.has(model.model) && ( -
-
-
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}> - 总库存 - {model.inventory} -
-
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}> - 其中待交车 - {model.pending} -
-
- {['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => ( -
-
{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}
- {model.inventoryRegions[reg] > 0 ? ( - - ) : ( -
-
- )} -
- ))} -
-
-
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}> - 本周已交车 - {model.weeklyDelivered} -
-
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}> - 已还车 - {model.weeklyReturned} -
-
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}> - 已替换 - {model.weeklyReplaced} -
-
-
-
- )} -
- ))} -
- )} -
-
- ))} -
- ); -} diff --git a/src/modules/assets/components/overview/AssetSummarySection.tsx b/src/modules/assets/components/overview/AssetSummarySection.tsx deleted file mode 100644 index 4f754cd..0000000 --- a/src/modules/assets/components/overview/AssetSummarySection.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Info } from 'lucide-react'; -import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable'; -import { AssetSummaryMobileCards } from './AssetSummaryMobileCards'; -import type { AssetSummarySectionProps } from './types'; - -export function AssetSummarySection(props: AssetSummarySectionProps) { - return ( -
-
-
-

资产数据实时汇总

-
- - 点击型号展开详情 -
-
-
- - {/* Desktop View Table */} - - - {/* Mobile View Cards for Asset Summary */} - -
- ); -} diff --git a/src/modules/assets/components/overview/FlowDateRangePicker.tsx b/src/modules/assets/components/overview/FlowDateRangePicker.tsx deleted file mode 100644 index ed33dee..0000000 --- a/src/modules/assets/components/overview/FlowDateRangePicker.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useRef, useState } from 'react'; -import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'; -import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model'; - -const weekdays = ['一', '二', '三', '四', '五', '六', '日']; -const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; -const parseDate = (value: string) => new Date(`${value}T12:00:00`); -const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); -const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1; - -export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) { - const dialog = useRef(null); - const [draft, setDraft] = useState(value); - const [selecting, setSelecting] = useState<'start' | 'end'>('start'); - const [month, setMonth] = useState(() => parseDate(value.start)); - const [hovered, setHovered] = useState(''); - const today = ymd(new Date()); - const count = draft.end ? dayCount(draft.start, draft.end) : 0; - const valid = count > 0 && count <= 370; - const monthStart = new Date(month.getFullYear(), month.getMonth(), 1); - const previewEnd = draft.end || hovered || draft.start; - const rangeStart = draft.start < previewEnd ? draft.start : previewEnd; - const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd; - const quickPicks = [ - { label: '今天', range: { start: today, end: today } }, - { label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } }, - { label: '业务周', range: getWeeklyFlowRange() }, - { label: '近 7 天', range: getRecentFlowRange() }, - { label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } }, - ]; - const open = () => { - setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered(''); - dialog.current?.showModal(); - }; - const choose = (date: string) => { - if (selecting === 'start') { - setDraft({ start: date, end: '' }); setSelecting('end'); - } else { - setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date }); - setSelecting('start'); - } - setHovered(''); - }; - const calendar = (offset: number) => { - const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1); - const year = current.getFullYear(), m = current.getMonth(); - const padding = (current.getDay() + 6) % 7; - const length = new Date(year, m + 1, 0).getDate(); - return
-
{year} 年 {m + 1} 月
-
- {weekdays.map(day =>
{day}
)} - {Array.from({ length: padding }, (_, i) =>
)} - {Array.from({ length }, (_, i) => { - const date = ymd(new Date(year, m, i + 1)); - const endpoint = date === draft.start || date === draft.end; - const inRange = date >= rangeStart && date <= rangeEnd; - return ; - })} -
-
; - }; - return <> - - { if (e.target === e.currentTarget) dialog.current?.close(); }} - className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm"> -
-
-

选择统计日期

先选开始日期,再选结束日期

- -
-
- {quickPicks.map(pick => )} -
-
- {(['start', 'end'] as const).map(field => )} -
-
- -
- - -
- -
-
setHovered('')}>{calendar(0)}{calendar(1)}
-
-
- 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`} -
- - -
-
-
- ; -} diff --git a/src/modules/assets/components/overview/FlowStatisticsCard.tsx b/src/modules/assets/components/overview/FlowStatisticsCard.tsx deleted file mode 100644 index 181a85b..0000000 --- a/src/modules/assets/components/overview/FlowStatisticsCard.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { FlowType } from '../../api'; -import { FLOW_META } from '../FlowDetailModal'; -import type { FlowStatisticsCardProps } from './types'; -import { FlowDateRangePicker } from './FlowDateRangePicker'; - -const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced']; - -export function FlowStatisticsCard({ - flowRange, - setFlowRange, - flowLoading, - flowStats, - exportFlowDetails, - flowDailyExpanded, - setFlowDailyExpanded, - setSelectedFlow, -}: FlowStatisticsCardProps) { - return ( -
-
-
-
-
- - 交还车统计(车次) - {flowLoading && } -
-
-
- -
-
-

按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。

- -
-
-
-
{flowStats?.totals.total ?? 0}
-
合计
-
- {FLOW_TYPES.map((type) => ( - - ))} -
-
- - - {flowDailyExpanded && ( - -
- {flowLoading && ( -
正在加载交还车统计...
- )} - {!flowLoading && flowStats?.daily.slice().sort((a, b) => b.date.localeCompare(a.date)).map((day) => ( -
-
-
{day.date}
-
运维签章时间
-
-
-
-
{day.total}
-
合计
-
- {FLOW_TYPES.map((type) => { - const count = day[type]; - return ( - - ); - })} -
-
- ))} - {!flowLoading && !flowStats?.daily.length && ( -
暂无交换车数据
- )} -
-
- )} -
-
-
- ); -} diff --git a/src/modules/assets/components/overview/InventoryDesktopTable.tsx b/src/modules/assets/components/overview/InventoryDesktopTable.tsx deleted file mode 100644 index 14b6719..0000000 --- a/src/modules/assets/components/overview/InventoryDesktopTable.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { InventoryStatusCounts } from './InventoryStatusCounts'; -import React from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { InventoryStatisticsSectionProps } from './types'; - -type InventoryDesktopTableProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; - -export function InventoryDesktopTable({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryDesktopTableProps) { - return ( -
- - - - - - - - - - - - - {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( - - {if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} onClick={() => toggleInventoryRegion(region)} - > - - - - {expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - - - ))} - - ))} - - - )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( - - {if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} onClick={() => toggleInventoryType(type)} - > - - - - {expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - - - ))} - - ))} - - - )) - )} - -
{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}{inventoryTab === 'region' ? '品牌' : '品牌'}{inventoryTab === 'region' ? '车型' : '所在区域/城市'}库存数量其中异动其中待交车
-
- {expandedInventoryRegions.has(region) ? : } - {region} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` }); - }}> - (共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {city} : ''} - {stat.brand}{stat.model} - - {stat.abnormal ?? 0}{stat.pending ?? 0}
-
- {expandedInventoryTypes.has(type) ? : } - {type} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` }); - }}> - (共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {model} : ''} - {stat.brand}{stat.region} / {stat.city} - - {stat.abnormal ?? 0}{stat.pending ?? 0}
-
- ); -} diff --git a/src/modules/assets/components/overview/InventoryMobileList.tsx b/src/modules/assets/components/overview/InventoryMobileList.tsx deleted file mode 100644 index 2d5a80a..0000000 --- a/src/modules/assets/components/overview/InventoryMobileList.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import type { InventoryStatisticsSectionProps } from './types'; - -type InventoryMobileListProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; - -export function InventoryMobileList({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryMobileListProps) { - return ( -
- {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( -
-
{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryRegion(region);}}} onClick={() => toggleInventoryRegion(region)} - > -
- {expandedInventoryRegions.has(region) ? : } - {region} -
- - {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryRegions.has(region) && ( -
- {Object.entries(cities).map(([city, stats]) => ( -
-
{city}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.model} -
- -
-
- ))} -
-
- ))} -
- )} -
- )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( -
-
{if(e.target===e.currentTarget && (e.key==='Enter'||e.key===' ')){e.preventDefault();toggleInventoryType(type);}}} onClick={() => toggleInventoryType(type)} - > -
- {expandedInventoryTypes.has(type) ? : } - {type} -
- - {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryTypes.has(type) && ( -
- {Object.entries(models).map(([model, stats]) => ( -
-
{model}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.region} / {stat.city} -
- -
-
- ))} -
-
- ))} -
- )} -
- )) - )} -
- ); -} diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx deleted file mode 100644 index d831e2a..0000000 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { Filter } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import { InventoryDesktopTable } from './InventoryDesktopTable'; -import { InventoryMobileList } from './InventoryMobileList'; -import type { InventoryStatisticsSectionProps } from './types'; - -export function InventoryStatisticsSection({ - inventoryFilters, - setInventoryFilters, - draftInventoryFilters, - setDraftInventoryFilters, - isInventoryFilterOpen, - setIsInventoryFilterOpen, - uniqueInventoryRegions, - uniqueInventoryCities, - uniqueInventoryBrands, - uniqueInventoryTypes, - uniqueInventoryModelsForType, - filteredInventoryStats, - inventoryTab, - setInventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryStatisticsSectionProps) { - const listProps = { - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, - }; - - return ( -
-
-
-
-
-

库存统计

- 按区域或车型查看库存,点击数量查看车辆 -
-
- -
-
- 当前库存 - - {filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)} - 台 - -
- -
- - - - {isInventoryFilterOpen && ( - <> - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - )} - -
-
-
- -
- - 随当前筛选统计,已包含在库存总数中 -
- -
-
- - -
-
- - {/* Active Filters Bar */} - {(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && ( -
- {inventoryFilters.region && ( - - 区域: {inventoryFilters.region} - - - )} - {inventoryFilters.city && ( - - 城市: {inventoryFilters.city} - - - )} - {inventoryFilters.brand && ( - - 品牌: {inventoryFilters.brand} - - - )} - {inventoryFilters.type && ( - - 车型: {inventoryFilters.type} - - - )} - {inventoryFilters.model && ( - - 批次: {inventoryFilters.model} - - - )} - -
- )} - - {filteredInventoryStats.length === 0 &&

没有符合条件的库存

} - {/* Desktop View Table */} - - - {/* Mobile View */} - -
- ); -} diff --git a/src/modules/assets/components/overview/InventoryStatusCounts.tsx b/src/modules/assets/components/overview/InventoryStatusCounts.tsx deleted file mode 100644 index 2071074..0000000 --- a/src/modules/assets/components/overview/InventoryStatusCounts.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { RegionalInventoryStats } from '../../types'; - -/** Counts are subsets of inventory and use the same filtered rows as the total. */ -export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) { - const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0); - const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0); - return - 其中异动 {abnormal} 台 - 其中待交车 {pending} 台 - ; -} diff --git a/src/modules/assets/components/overview/SummaryMetrics.tsx b/src/modules/assets/components/overview/SummaryMetrics.tsx deleted file mode 100644 index 58dc60a..0000000 --- a/src/modules/assets/components/overview/SummaryMetrics.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse, ChevronRight } from 'lucide-react'; -import type { SummaryMetricsProps } from './types'; - -export function SummaryMetrics({ summary, operatingRate, inventoryRate, setVehicleSelection }: SummaryMetricsProps) { - const open = (category: 'Operating' | 'Inventory' | 'Abnormal' | 'Pending' | undefined, title: string) => - setVehicleSelection({ batch: 'All', model: 'All', location: 'All', category, source: 'asset', title }); - - return
- -
-
- -
-
租赁{summary.operating.leased}
-
自营{summary.operating.self}
-
-
-
- -
-

以下车辆已包含在总库存中

-
- - -
-
-
-
-
- 运营率 {operatingRate.toFixed(1)}% - 库存率 {inventoryRate.toFixed(1)}% -
-
; -} diff --git a/src/modules/assets/components/overview/types.ts b/src/modules/assets/components/overview/types.ts deleted file mode 100644 index a5d4f7b..0000000 --- a/src/modules/assets/components/overview/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react'; -import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api'; -import type { - DateRange, - FlowSelection, - InventoryFilters, - VehicleModalSelection, -} from '../../model'; -import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types'; -import type { AssetsTheme } from '../AssetsHeader'; - -export interface SummaryMetricsProps { - summary: SummaryData; - operatingRate: number; - inventoryRate: number; - pendingRate: number; - setVehicleSelection: Dispatch>; -} - -export interface FlowStatisticsCardProps { - flowRange: DateRange; - setFlowRange: Dispatch>; - flowLoading: boolean; - flowStats: FlowStatsResponse | null; - exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; - flowDailyExpanded: boolean; - setFlowDailyExpanded: Dispatch>; - setSelectedFlow: Dispatch>; -} - -export interface AssetSummarySectionProps { - processedData: TypeSummary[]; - theme: AssetsTheme; - expandedAssetTypes: Set; - toggleAssetType: (type: string) => void; - expandedModels: Set; - toggleModel: (model: string) => void; - setVehicleSelection: Dispatch>; -} - -export interface InventoryStatisticsSectionProps { - inventoryFilters: InventoryFilters; - setInventoryFilters: Dispatch>; - draftInventoryFilters: InventoryFilters; - setDraftInventoryFilters: Dispatch>; - isInventoryFilterOpen: boolean; - setIsInventoryFilterOpen: Dispatch>; - uniqueInventoryRegions: string[]; - uniqueInventoryCities: string[]; - uniqueInventoryBrands: string[]; - uniqueInventoryTypes: string[]; - uniqueInventoryModelsForType: string[]; - filteredInventoryStats: RegionalInventoryStats[]; - inventoryTab: 'region' | 'model'; - setInventoryTab: Dispatch>; - inventoryByRegion: Record>; - expandedInventoryRegions: Set; - toggleInventoryRegion: (region: string) => void; - inventoryByModel: Record>; - expandedInventoryTypes: Set; - toggleInventoryType: (type: string) => void; - setVehicleSelection: Dispatch>; -} - -export type OverviewViewProps = SummaryMetricsProps - & FlowStatisticsCardProps - & AssetSummarySectionProps - & InventoryStatisticsSectionProps - & { - allTypesExpanded: boolean; - toggleAllAssetTypes: () => void; - }; diff --git a/src/modules/assets/components/useDialogFocus.ts b/src/modules/assets/components/useDialogFocus.ts deleted file mode 100644 index 07bd3c5..0000000 --- a/src/modules/assets/components/useDialogFocus.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, type RefObject } from 'react'; - -export function useDialogFocus(open: boolean, ref: RefObject, close: () => void) { - useEffect(() => { - if (!open) return; - const previous = document.activeElement as HTMLElement | null; - const overflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - const frame = requestAnimationFrame(() => ref.current?.focus()); - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { event.preventDefault(); close(); } - if (event.key !== 'Tab') return; - const items = Array.from(ref.current?.querySelectorAll('button:not([disabled]), input, select, a[href], [tabindex="0"]') || []).filter(el=>el.getClientRects().length>0); - const first = items[0]; const last = items[items.length-1]; - if (!first) { event.preventDefault(); return; } - if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {event.preventDefault();last.focus();} - else if (!event.shiftKey && (document.activeElement === last || document.activeElement === ref.current)) {event.preventDefault();first.focus();} - }; - document.addEventListener('keydown',onKey); - return () => {cancelAnimationFrame(frame);document.removeEventListener('keydown',onKey);document.body.style.overflow=overflow;previous?.focus();}; - }, [open, ref, close]); -} diff --git a/src/modules/assets/library/AssetOverview.tsx b/src/modules/assets/library/AssetOverview.tsx new file mode 100644 index 0000000..cdcc305 --- /dev/null +++ b/src/modules/assets/library/AssetOverview.tsx @@ -0,0 +1,424 @@ +import { useState } from "react"; +import { InventoryAnalysis } from "./InventoryAnalysis"; +import { inventoryTotals, type InventoryScope } from "./inventory-view"; +import { InventorySection } from "./InventorySection"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardAction, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { + Package, + Truck, + ArrowLeftRight, + AlertTriangle, + Activity, + Warehouse, + ArrowUpRight, +} from "lucide-react"; +import type { + SummaryData, + TypeSummary, + RegionalInventoryStats, + ModelSummary, + BatchSummary, +} from "../types"; +import type { VehicleModalCategory, VehicleModalSelection } from "../model"; +import type { AssetSection } from "./useAssetHash"; +import { + baseSelection, + ProportionBar, + sectionTabsListClass, + sectionTabsTriggerClass, + type OpenVehicles, +} from "./shared"; +import { DataGrid, type Column } from "./DataGrid"; +import { ExportButton } from "./export"; +import { FlowStatistics } from "./FlowStatistics"; + +/** 运营率环形图(SVG donut)。 */ +function RateDonut({ rate, size = 48 }: { rate: number; size?: number }) { + const stroke = 6; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const filled = (Math.min(100, Math.max(0, rate)) / 100) * circumference; + return ( +
+ + +
+ ); +} +export function AssetOverview({ + summary: s, + types, + inventory, + subject, + section, + onSectionChange, + open, +}: { + summary: SummaryData; + types: TypeSummary[]; + inventory: RegionalInventoryStats[]; + subject: string | null; + section: AssetSection; + onSectionChange: (section: AssetSection) => void; + open: OpenVehicles; +}) { + const [inventoryScope, setInventoryScope] = useState( + null, + ); + const inventoryCounts = inventoryTotals(inventory); + const operatingRate = s.totalAssets + ? (s.operating.total / s.totalAssets) * 100 + : 0; + const show = (category?: VehicleModalCategory) => { + if ( + category === "Inventory" || + category === "Abnormal" || + category === "Pending" + ) + setInventoryScope({ + rows: inventory, + label: "全部库存", + initialCategory: category === "Inventory" ? undefined : category, + }); + else open({ ...baseSelection, category }); + }; + return ( +
+
+ + + + + +
+ +
+

+ 运营率 +

+

+ {operatingRate.toFixed(1)}% +

+
+
+
+ 租赁 + 自营 +
+ + +
+ + 租赁 {s.operating.leased} + + + 自营 {s.operating.self} + +
+ +
+
+ + + + + + 待运营 + 可运营;异动、待交车已包含在内 + + + + +
+ + +
+ +
+
+
+ onSectionChange(value as AssetSection)} + > + + + + 库存统计 + + + + 车型资产 + + + + 交还车统计 + + + + + + + + + + 车型资产 + + + 按车型、型号与批次逐层查看;本周交还车按车辆去重。 + + + + + {types.map((type) => ( + + + {type.type} · {type.totalAssets} 台 · 库存{" "} + {type.totalInventory} + + + + + {type.models.map((model) => ( + + + {model.model} · 查看批次 + + + + + + ))} + + + + ))} + + + + + + + + + {inventoryScope && ( + setInventoryScope(null)} + /> + )} +
+ ); +} +function ModelTable({ + rows, + open, + selection, + batch = false, +}: { + rows: (ModelSummary | BatchSummary)[]; + open: OpenVehicles; + selection: VehicleModalSelection; + batch?: boolean; +}) { + const data = rows.map((row) => ({ + ...row, + name: "model" in row ? row.model : row.batch, + })); + type Row = (typeof data)[number]; + const columns: Column[] = [ + { key: "name", label: batch ? "批次" : "型号", width: 190 }, + ...( + [ + ["total", "资产", undefined], + ["operating", "运营", "Operating"], + ["inventory", "库存", "Inventory"], + ["abnormal", "异动", "Abnormal"], + ["pending", "待交车", "Pending"], + ["weeklyDelivered", "本周交车", "Delivered"], + ["weeklyReturned", "本周还车", "Returned"], + ["weeklyReplaced", "替换交车", "Replaced"], + ] as const + ).map(([key, label, category]) => ({ + key, + label, + action: (row: Row) => + open({ + ...selection, + ...(batch ? { batch: row.name } : { model: row.name }), + category, + }), + })), + { + key: "inventoryRegions", + label: "库存分布", + width: 260, + format: (value) => formatInventoryRegions(value), + tooltip: (value) => formatInventoryRegions(value), + }, + ]; + return ( +
+ + +
+ ); +} + +/** 库存分布:只展示有库存的区域,避免 0 值噪声;截断时由 tooltip 展示完整内容。 */ +function formatInventoryRegions(value: unknown): string { + const parts = Object.entries(value as Record) + .filter(([, count]) => count > 0) + .map(([region, count]) => `${region} ${count}`); + return parts.join(" · ") || "—"; +} diff --git a/src/modules/assets/library/BusinessViews.tsx b/src/modules/assets/library/BusinessViews.tsx new file mode 100644 index 0000000..9afe795 --- /dev/null +++ b/src/modules/assets/library/BusinessViews.tsx @@ -0,0 +1,695 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + DeptGroup, + ManagerStats, + RegionGroup, + RegionTypeBreakdown, + CustomerStats, +} from "../types"; +import { + deriveDepartmentView, + deriveCustomerView, + type CustomerFilters, +} from "../model"; +import { fetchRegionStats, fetchRegionChart } from "../api"; +const APPLE_CHART_COLORS = [ + "var(--indigo)", + "var(--blue)", + "var(--teal)", + "var(--orange)", + "var(--green)", +]; +import { + baseSelection, + FilterFields, + PrivateText, + unique, + Failure, + type OpenVehicles, +} from "./shared"; +import { DataGrid, type Column } from "./DataGrid"; +import { ExportButton } from "./export"; +import { Chart } from "./Chart"; +const typeFields = [ + ["t4_5", "4.5T普货"], + ["t4_5c", "4.5T冷链"], + ["t18", "18T"], + ["t49", "49T"], + ["trailer", "挂车"], + ["other", "其他"], +] as const; +export function DepartmentView({ + data, + open, +}: { + data: DeptGroup[]; + open: OpenVehicles; +}) { + const [mode, setMode] = useState("department"); + const [manager, setManager] = useState(""); + const view = deriveDepartmentView(data, manager || "All"); + const cols: Column[] = [ + { key: "department", label: "部门", width: 180 }, + { + key: "totalAssets", + label: "车辆总数", + action: (r) => + open({ + ...baseSelection, + source: "department", + category: "Operating", + department: r.department, + }), + }, + { + key: "operatingCount", + label: "出勤车辆", + action: (r) => + open({ + ...baseSelection, + source: "department", + category: "Operating", + department: r.department, + attendance: "active", + }), + }, + { + key: "idleCount", + label: "未出勤车辆", + action: (r) => + open({ + ...baseSelection, + source: "department", + category: "Operating", + department: r.department, + attendance: "idle", + }), + }, + { + key: "attendanceRate", + label: "出勤率", + numeric: true, + format: (v) => `${Number(v).toFixed(1)}%`, + }, + { + key: "avgMileage", + label: "平均里程(km)", + width: 160, + numeric: true, + format: (v) => Number(v).toFixed(1), + }, + ]; + const managerCols: Column[] = [ + { key: "manager", label: "业务负责人", private: true }, + { key: "department", label: "部门", width: 160 }, + ...typeFields.map(([key, type]) => ({ + key, + label: type, + action: (r: ManagerStats) => + open({ + ...baseSelection, + source: "department", + category: "Operating", + department: r.department, + manager: r.manager, + type, + }), + })), + { + key: "total", + label: "合计", + action: (r) => + open({ + ...baseSelection, + source: "department", + category: "Operating", + department: r.department, + manager: r.manager, + }), + }, + ]; + return ( + + + + 部门运营 + + 查看部门出勤表现与负责人车辆分布。 + + +
+ + + 按部门 + 按负责人 + + + {mode === "department" ? ( + + ) : ( + + )} +
+ {mode === "department" ? ( + <> + + + {data.map((d) => ( + + + {d.department} · 负责人明细 + + + + + + ))} + + + ) : ( + <> + setManager("")} + /> + + + )} +
+
+ ); +} +export function RegionView({ + data, + subject, + open, +}: { + data: RegionGroup[]; + subject: string | null; + open: OpenVehicles; +}) { + const [chartView, setChartView] = useState("region"); + const [filters, setFilters] = useState({ + region: "", + city: "", + customer: "", + }); + const [filtered, setFiltered] = useState(data); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + useEffect(() => { + let active = true; + if (!filters.region && !filters.city && !filters.customer) { + setFiltered(data); + setLoading(false); + setError(""); + return; + } + setLoading(true); + setError(""); + fetchRegionStats(filters, subject) + .then((v) => { + if (active) setFiltered(v); + }) + .catch((e) => { + if (active) setError(String(e.message || e)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [filters, subject, data, retry]); + const option = useMemo( + () => ({ + color: APPLE_CHART_COLORS, + textStyle: { color: "var(--label-secondary)" }, + tooltip: { trigger: "axis" }, + grid: { left: 45, right: 15, top: 20, bottom: 45 }, + xAxis: { + type: "category", + data: filtered.map((r) => r.region), + axisLabel: { color: "var(--label-secondary)", interval: 0 }, + axisLine: { lineStyle: { color: "var(--separator)" } }, + }, + yAxis: { + type: "value", + minInterval: 1, + axisLabel: { color: "var(--label-secondary)" }, + splitLine: { lineStyle: { color: "var(--separator)" } }, + }, + series: [ + { + type: "bar", + name: "车辆", + data: filtered.map((r) => r.totalAssets), + barMaxWidth: 48, + itemStyle: { borderRadius: [4, 4, 0, 0] }, + label: { + show: true, + position: "top", + color: "var(--label-secondary)", + }, + }, + ], + }), + [filtered], + ); + const breakdown = (rows: RegionTypeBreakdown[], location: string) => ( + ({ + key, + label, + action: (r: RegionTypeBreakdown) => + open({ + ...baseSelection, + source: "region", + location: filters.city || location, + customer: filters.customer || undefined, + vehicleType: r.type === "其他" ? "区域其他" : r.type, + category, + }), + })), + ]} + /> + ); + return ( + + + + 区域分布 + + + 运营与待交车车辆,按区域 → 城市 → 车型查看。 + + + + r.region), + change: (v) => setFilters((f) => ({ ...f, region: v, city: "" })), + }, + { + label: "城市", + value: filters.city, + values: unique( + data + .filter((r) => !filters.region || r.region === filters.region) + .flatMap((r) => r.cities.map((c) => c.city)), + ), + change: (v) => setFilters((f) => ({ ...f, city: v })), + }, + { + label: "客户", + value: filters.customer, + values: unique(data.flatMap((r) => r.customers)), + change: (v) => setFilters((f) => ({ ...f, customer: v })), + }, + ]} + onReset={() => setFilters({ region: "", city: "", customer: "" })} + /> + {error ? ( + setRetry((n) => n + 1)} /> + ) : loading ? ( + + ) : ( + <> + + + 区域分布 + 实时省份 + + + {chartView === "region" ? ( + + ) : ( + + )} + + {filtered.map((r) => ( + + + {r.region} · {r.totalAssets} 台 + + + {breakdown(r.typeBreakdown, r.region)} + + {r.cities.map((city) => ( + + + {city.city} · {city.totalAssets} 台 + + + {breakdown(city.typeBreakdown, city.city)} + + + ))} + + + + ))} + + {!filtered.length && ( + 暂无符合条件的区域数据 + )} + + )} + + + ); +} +const initialCustomer: CustomerFilters = { + customer: [], + brand: "", + department: "", + manager: "", + region: "", +}; +export function CustomerView({ + data, + departments, + subject, + open, +}: { + data: CustomerStats[]; + subject: string | null; + departments: DeptGroup[]; + open: OpenVehicles; +}) { + const [filters, setFilters] = useState(initialCustomer); + const [customerSearch, setCustomerSearch] = useState(""); + const [chartGroup, setChartGroup] = useState("region"); + const view = deriveCustomerView(data, departments, filters); + const option = useMemo(() => { + const counts = new Map(); + for (const row of view.filtered) { + const name = chartGroup === "region" ? row.region : row.city; + counts.set( + name || "未归属", + (counts.get(name || "未归属") || 0) + row.total, + ); + } + return { + color: APPLE_CHART_COLORS, + textStyle: { color: "var(--label-secondary)" }, + tooltip: { trigger: "item" }, + legend: { + type: "scroll", + bottom: 0, + textStyle: { color: "var(--label-secondary)" }, + }, + series: [ + { + type: "pie", + radius: ["35%", "65%"], + label: { show: false }, + data: [...counts].map(([name, value]) => ({ name, value })), + }, + ], + }; + }, [view.filtered, chartGroup]); + const cols: Column[] = [ + { key: "customer", label: "客户", width: 220, private: true }, + { key: "manager", label: "负责人", private: true }, + { key: "department", label: "部门", width: 150 }, + { key: "brand", label: "品牌" }, + { key: "region", label: "区域" }, + { key: "city", label: "城市" }, + ...typeFields.map(([key, type]) => ({ + key, + label: type, + action: (r: CustomerStats) => + open({ + ...baseSelection, + source: "customer", + category: "Operating", + customer: r.customer, + type, + }), + })), + { + key: "total", + label: "合计", + action: (r) => + open({ + ...baseSelection, + source: "customer", + category: "Operating", + customer: r.customer, + }), + }, + ]; + return ( + + + + 客户资产 + + + 按客户、负责人、品牌与区域查看车辆分布。 + + + +
+ + + + + + setCustomerSearch(e.target.value)} + /> +
+ {view.customerNames + .filter((c) => c.includes(customerSearch)) + .map((c) => ( + + + setFilters((f) => ({ + ...f, + customer: checked + ? [...f.customer, c] + : f.customer.filter((x) => x !== c), + })) + } + /> + + {c} + + + ))} +
+
+
+ +
+ setFilters((f) => ({ ...f, brand: v })), + }, + { + label: "部门", + value: filters.department, + values: view.departments, + change: (v) => + setFilters((f) => ({ ...f, department: v, manager: "" })), + }, + { + label: "负责人", + value: filters.manager, + values: unique( + view.managersByDepartment + .filter( + (d) => + !filters.department || + d.department === filters.department, + ) + .flatMap((d) => d.managers), + ), + change: (v) => setFilters((f) => ({ ...f, manager: v })), + }, + { + label: "区域", + value: filters.region, + values: view.regions, + change: (v) => setFilters((f) => ({ ...f, region: v })), + }, + ]} + onReset={() => setFilters(initialCustomer)} + /> + + + 区域分布 + 城市分布 + 省份分布 + + + {chartGroup === "province" ? ( + + ) : ( + + )} + +
+
+ ); +} + +function ProvinceChart({ + subject, + source, +}: { + subject: string | null; + source: "vehicle" | "realtime"; +}) { + const [rows, setRows] = useState<{ name: string; value: number }[] | null>( + null, + ); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + useEffect(() => { + let active = true; + setRows(null); + setError(""); + fetchRegionChart("province", 5, source, subject) + .then((v) => { + if (active) setRows(v); + }) + .catch((e) => { + if (active) setError(e.message || "加载失败"); + }); + return () => { + active = false; + }; + }, [subject, source, retry]); + const option = useMemo( + () => ({ + color: APPLE_CHART_COLORS, + textStyle: { color: "var(--label-secondary)" }, + tooltip: { trigger: "item" }, + legend: { + type: "scroll", + bottom: 0, + textStyle: { color: "var(--label-secondary)" }, + }, + series: [ + { + type: "pie", + radius: ["35%", "65%"], + label: { show: false }, + data: rows || [], + }, + ], + }), + [rows], + ); + return ( +
+ + {source === "realtime" ? "实时定位" : "车辆档案"}省份分布 · + 当前公司范围,不受表格筛选影响 + + {error ? ( + setRetry((n) => n + 1)} /> + ) : rows ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/modules/assets/library/Chart.tsx b/src/modules/assets/library/Chart.tsx new file mode 100644 index 0000000..0d0e398 --- /dev/null +++ b/src/modules/assets/library/Chart.tsx @@ -0,0 +1,11 @@ +import { lazy, Suspense } from "react"; +import type { EChartsCoreOption } from "echarts/core"; +import { Skeleton } from "@/components/ui/skeleton"; +const EChart = lazy(() => import("./EChartImpl")); +export function Chart(props: { option: EChartsCoreOption; label: string }) { + return ( + }> + + + ); +} diff --git a/src/modules/assets/library/DataGrid.tsx b/src/modules/assets/library/DataGrid.tsx new file mode 100644 index 0000000..ca607b4 --- /dev/null +++ b/src/modules/assets/library/DataGrid.tsx @@ -0,0 +1,281 @@ +import { useMemo, useState, useEffect, lazy, Suspense, useRef } from "react"; +import { ArrowUpRight, Search, SearchX, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useDemoMode } from "@/components/Blur"; +import { Count, useMobile } from "./shared"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { Skeleton } from "@/components/ui/skeleton"; +const DesktopGrid = lazy( + () => import("./DesktopGrid"), +) as typeof import("./DesktopGrid").default; +export type Column = { + key: keyof T & string; + label: string; + width?: number; + private?: boolean; + primary?: boolean; + action?: (row: T) => void; + format?: (value: unknown, row: T) => string; + numeric?: boolean; + tooltip?: (value: unknown, row: T) => string; +}; +export function DataGrid({ + rows, + columns, + label = "明细", + search = true, +}: { + rows: T[]; + columns: Column[]; + label?: string; + search?: boolean; +}) { + const mobile = useMobile(); + const demo = useDemoMode(); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + const [compact, setCompact] = useState(true); + const top = useRef(null); + const pageSize = 20; + const display = (row: T, col: Column) => + col.private && demo + ? "••••••" + : col.format + ? col.format(row[col.key], row) + : String(row[col.key] ?? "—"); + const filtered = useMemo(() => { + const term = query.trim().toLocaleLowerCase(); + return rows.filter( + (row) => + !term || + columns.some((col) => + display(row, col).toLocaleLowerCase().includes(term), + ), + ); + }, [rows, columns, query, demo]); + useEffect(() => setPage(0), [query, rows.length]); + const pages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const currentPage = Math.min(page, pages - 1); + const renderValue = (row: T, col: Column) => { + if (col.private && demo) return "••••••"; + if (col.action) + return ( + col.action!(row)} + /> + ); + const tooltip = col.tooltip?.(row[col.key], row); + return tooltip ? ( + {display(row, col)} + ) : ( + display(row, col) + ); + }; + const metrics = columns.slice(1).filter((col) => col.action || col.numeric); + const textColumns = columns.slice(1).filter((col) => !metrics.includes(col)); + const primaryColumns = textColumns.filter( + (col, index) => index < 4 || col.primary, + ); + const moreColumns = textColumns.filter( + (col) => !primaryColumns.includes(col), + ); + const fields = (row: T, list: Column[]) => ( +
+ {list.map((col) => ( +
+
{col.label}
+
{renderValue(row, col)}
+
+ ))} +
+ ); + const changePage = (next: number) => { + setPage(next); + top.current?.scrollIntoView({ block: "start", behavior: "instant" }); + }; + return ( +
+
+

+ {query + ? `找到 ${filtered.length} / ${rows.length} 条记录` + : `共 ${filtered.length} 条记录`} + {columns.some((col) => col.action) && ( + 蓝色数量可查看明细 + )} +

+
+ {search && (rows.length > 8 || query) && ( +
+
+ )} + {!mobile && rows.length > 0 && ( + + )} +
+
+ {!filtered.length ? ( +
+
+ ) : mobile ? ( + <> + {filtered + .slice(currentPage * pageSize, (currentPage + 1) * pageSize) + .map((row, index) => ( +
+
+
+

+ {columns[0].label} +

+

{renderValue(row, columns[0])}

+
+ + {String(currentPage * pageSize + index + 1).padStart( + 2, + "0", + )} + +
+ {primaryColumns.length > 0 && fields(row, primaryColumns)} + {metrics.length > 0 && ( +
+ {metrics.map((col) => { + const value = Number(row[col.key]) || 0; + const content = ( + <> + + {col.label} + + + {display(row, col)} + {col.action && value > 0 && ( + + + ); + return col.action && + value > 0 && + !(col.private && demo) ? ( + + ) : ( +
+ {content} +
+ ); + })} +
+ )} + {moreColumns.length > 0 && ( + + + + 更多车辆信息 · {moreColumns.length} 项 + + + {fields(row, moreColumns)} + + + + )} +
+ ))} + {filtered.length > pageSize && ( + + )} + + ) : ( +
pageSize ? "h-[36rem] min-w-0" : "min-w-0" + } + role="region" + aria-label={label} + > + }> + + +
+ )} +
+ ); +} diff --git a/src/modules/assets/library/DesktopGrid.tsx b/src/modules/assets/library/DesktopGrid.tsx new file mode 100644 index 0000000..3bbaf2d --- /dev/null +++ b/src/modules/assets/library/DesktopGrid.tsx @@ -0,0 +1,124 @@ +import { AgGridReact } from "ag-grid-react"; +import { + AllCommunityModule, + ModuleRegistry, + themeQuartz, + type ColDef, + type ICellRendererParams, +} from "ag-grid-community"; +import { useDemoMode } from "@/components/Blur"; +import { Count } from "./shared"; +import type { Column } from "./DataGrid"; +ModuleRegistry.registerModules([AllCommunityModule]); +const gridTheme = themeQuartz.withParams({ + fontFamily: "inherit", + fontSize: 14, + rowHeight: 48, + headerHeight: 36, + spacing: 6, + borderRadius: 8, + accentColor: "var(--blue)", + backgroundColor: "var(--bg-tertiary)", + foregroundColor: "var(--label)", + headerBackgroundColor: "var(--bg-secondary)", + borderColor: "var(--separator)", + oddRowBackgroundColor: "var(--bg-secondary)", +}); +const localeText = { + noRowsToShow: "暂无数据", + page: "页", + of: "/", + to: "–", + more: "更多", + nextPage: "下一页", + previousPage: "上一页", + firstPage: "首页", + lastPage: "末页", + pageSizeSelectorLabel: "每页", + loadingOoo: "加载中…", + filterOoo: "筛选…", + equals: "等于", + notEqual: "不等于", + contains: "包含", + notContains: "不包含", + startsWith: "开头是", + endsWith: "结尾是", + blank: "空白", + notBlank: "非空", + lessThan: "小于", + greaterThan: "大于", + inRange: "范围", + andCondition: "且", + orCondition: "或", +}; +export default function DesktopGrid({ + rows, + columns, + compact = false, +}: { + rows: T[]; + columns: Column[]; + compact?: boolean; +}) { + const demo = useDemoMode(); + const display = (row: T, col: Column) => + col.private && demo + ? "••••••" + : col.format + ? col.format(row[col.key], row) + : String(row[col.key] ?? "—"); + const defs: ColDef[] = columns.map((col, index) => { + const numeric = Boolean(col.numeric || col.action); + return { + colId: col.key, + pinned: index === 0 ? "left" : col.key === "total" ? "right" : undefined, + headerName: col.label, + minWidth: col.width || 110, + initialFlex: 1, + cellStyle: numeric ? { textAlign: "right" } : undefined, + cellClass: numeric ? "tabular-nums" : undefined, + headerClass: numeric ? "tabular-nums ag-numeric-header" : undefined, + valueGetter: ({ data }) => + data ? (demo && col.private ? "••••••" : data[col.key]) : "", + valueFormatter: ({ data }) => (data ? display(data, col) : ""), + cellRenderer: col.action + ? (p: ICellRendererParams) => + p.data && ( + col.action!(p.data!)} + /> + ) + : col.tooltip + ? (p: ICellRendererParams) => + p.data && ( + + {display(p.data, col)} + + ) + : undefined, + }; + }); + return ( + + theme={gridTheme} + rowHeight={compact ? 36 : 48} + tooltipShowDelay={350} + rowData={rows} + columnDefs={defs} + defaultColDef={{ sortable: true, filter: true, resizable: true }} + domLayout={rows.length <= 20 ? "autoHeight" : "normal"} + pagination={rows.length > 20} + paginationPageSize={20} + paginationPageSizeSelector={[20, 50, 100]} + localeText={localeText} + /> + ); +} diff --git a/src/modules/assets/library/EChartImpl.tsx b/src/modules/assets/library/EChartImpl.tsx new file mode 100644 index 0000000..54c5096 --- /dev/null +++ b/src/modules/assets/library/EChartImpl.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { BarChart, LineChart, PieChart } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + LegendComponent, + AriaComponent, + DataZoomComponent, +} from "echarts/components"; +import { SVGRenderer } from "echarts/renderers"; +import type { EChartsCoreOption } from "echarts/core"; +echarts.use([ + BarChart, + LineChart, + PieChart, + GridComponent, + TooltipComponent, + LegendComponent, + AriaComponent, + DataZoomComponent, + SVGRenderer, +]); +export default function Chart({ + option, + label, +}: { + option: EChartsCoreOption; + label: string; +}) { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) return; + const chart = echarts.init(ref.current, undefined, { renderer: "svg" }); + chart.setOption({ ...option, aria: { enabled: true } }); + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(ref.current); + return () => { + observer.disconnect(); + chart.dispose(); + }; + }, [option]); + return ( +
+ ); +} diff --git a/src/modules/assets/library/FlowStatistics.tsx b/src/modules/assets/library/FlowStatistics.tsx new file mode 100644 index 0000000..d77f79b --- /dev/null +++ b/src/modules/assets/library/FlowStatistics.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useState, useMemo } from "react"; +import { zhCN } from "date-fns/locale"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CalendarDays, Download } from "lucide-react"; +import { + fetchFlowStats, + type FlowStatsResponse, + type FlowDetailItem, + type FlowDailyPoint, +} from "../api"; +import { + getRecentFlowRange, + getWeeklyFlowRange, + formatLocalDate, + selectFlowDetails, + type FlowSelection, +} from "../model"; +import { Failure, useMobile } from "./shared"; +import { DataGrid, type Column } from "./DataGrid"; +import { exportToXlsx } from "./export"; +import { Chart } from "./Chart"; +export function FlowStatistics({ subject }: { subject: string | null }) { + const [range, setRange] = useState(getRecentFlowRange); + const [calendarOpen, setCalendarOpen] = useState(false); + const [draft, setDraft] = useState<{ from: Date | undefined; to?: Date }>({ + from: new Date(`${range.start}T00:00:00`), + to: new Date(`${range.end}T00:00:00`), + }); + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + const [selection, setSelection] = useState(null); + const mobile = useMobile(); + useEffect(() => { + let active = true; + setData(null); + setError(""); + fetchFlowStats({ ...range, subject }) + .then((v) => { + if (active) setData(v); + }) + .catch((e) => { + if (active) setError(String(e.message || e)); + }); + return () => { + active = false; + }; + }, [range, subject, retry]); + const details = data && selection ? selectFlowDetails(data, selection) : []; + const columns: Column[] = [ + { key: "plateNumber", label: "车牌", private: true }, + { key: "typeLabel", label: "类型" }, + { key: "date", label: "业务日期" }, + { key: "eventTime", label: "运维签章时间", width: 180 }, + { key: "submitTime", label: "建单时间", width: 180 }, + { key: "department", label: "部门" }, + { key: "manager", label: "负责人", private: true }, + { key: "customerName", label: "客户", private: true, width: 180 }, + ]; + const exportRows = (rows: FlowDetailItem[], filename: string) => + exportToXlsx(rows, columns, filename, "交还车明细"); + const option = useMemo( + () => ({ + color: ["var(--blue)", "var(--orange)", "var(--indigo)"], + textStyle: { color: "var(--label-secondary)" }, + tooltip: { trigger: "axis" }, + legend: { + data: ["交车", "还车", "替换交车"], + textStyle: { color: "var(--label-secondary)" }, + }, + grid: { left: 40, right: 20, bottom: 40, top: 40 }, + xAxis: { + type: "category", + data: data?.daily.map((d) => d.date.slice(5)) || [], + axisLabel: { color: "var(--label-secondary)" }, + axisLine: { lineStyle: { color: "var(--separator)" } }, + }, + yAxis: { + type: "value", + minInterval: 1, + axisLabel: { color: "var(--label-secondary)" }, + splitLine: { lineStyle: { color: "var(--separator)" } }, + }, + series: ["delivered", "returned", "replaced"].map((key, i) => ({ + name: ["交车", "还车", "替换交车"][i], + type: "line", + data: data?.daily.map((d) => d[key as "delivered"]) || [], + })), + }), + [data], + ); + return ( + + + + 交还车统计(车次) + + + 按运维签章时间统计;交车含替换交车,合计 = 交车 + 还车。 + + + +
+ { + setCalendarOpen(v); + if (v) + setDraft({ + from: new Date(`${range.start}T00:00:00`), + to: new Date(`${range.end}T00:00:00`), + }); + }} + > + + + + + setDraft(v || { from: undefined })} + numberOfMonths={mobile ? 1 : 2} + disabled={{ after: new Date() }} + /> + + + + + + +
+ {error ? ( + setRetry((n) => n + 1)} /> + ) : !data ? ( + + ) : ( + <> +
+ {( + [ + ["total", "合计"], + ["delivered", "交车"], + ["returned", "还车"], + ["replaced", "替换交车"], + ] as const + ).map(([key, title]) => ( + + + {title} + + {key === "total" ? ( + data.totals[key] + ) : ( + + )} + + + + ))} +
+ + ({ + key, + label: ["交车", "还车", "替换交车"][i], + action: (row: FlowDailyPoint) => + setSelection({ date: row.date, type: key }), + }), + ), + ]} + /> + + )} +
+ !v && setSelection(null)}> + + + 交还车明细 + + {details.length} 车次 ·{" "} + {selection && + ("date" in selection + ? selection.date + : `${selection.start} — ${selection.end}`)} + + + + + + +
+ ); +} diff --git a/src/modules/assets/library/InventoryAnalysis.tsx b/src/modules/assets/library/InventoryAnalysis.tsx new file mode 100644 index 0000000..ba4b14d --- /dev/null +++ b/src/modules/assets/library/InventoryAnalysis.tsx @@ -0,0 +1,397 @@ +import { useMemo, useRef, useState } from "react"; +import { ArrowLeft, ArrowUpRight, X, AlertTriangle, Truck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + inventoryModel, + inventoryTotals, + scopedInventorySelection, + type InventoryScope, +} from "./inventory-view"; +import { VehicleDetails } from "./VehicleDetails"; +import type { VehicleModalSelection } from "../model"; +import type { RegionalInventoryStats } from "../types"; +import { emptyInventoryCounts } from "../../../shared/assets/inventory"; + +export function InventoryAnalysis({ + scope, + subject, + close, +}: { + scope: InventoryScope; + subject: string | null; + close: () => void; +}) { + const [selection, setSelection] = useState( + () => + scope.initialCategory + ? scopedInventorySelection(scope, { category: scope.initialCategory }) + : null, + ); + const [query, setQuery] = useState(""); + const originFocus = useRef( + document.activeElement instanceof HTMLElement + ? document.activeElement + : null, + ); + const scrollTop = useRef(0); + const total = inventoryTotals(scope.rows); + const open = (extra: Partial = {}) => + setSelection(scopedInventorySelection(scope, extra)); + const segments = [ + { + key: "available" as const, + label: "可运营", + count: total.available, + color: "var(--blue)", + }, + { + key: "awaiting" as const, + label: "待运营", + count: total.awaiting, + color: "var(--inventory-awaiting)", + }, + ...(total.unclassified + ? [ + { + key: "unclassified" as const, + label: "待核实状态", + count: total.unclassified, + color: "var(--label-secondary)", + }, + ] + : []), + ]; + const variants = useMemo(() => { + const groups = new Map< + string, + { + rows: RegionalInventoryStats[]; + color: string; + count: number; + name: string; + brand: string; + operator: string; + } + >(); + for (const row of scope.rows) { + const model = inventoryModel(row.model, row.brand); + const colors = row.variants || { + 未标注颜色: { ...emptyInventoryCounts(), quantity: row.quantity }, + }; + for (const [color, counts] of Object.entries(colors)) { + const key = JSON.stringify([row.brand, row.model, color]); + const value = groups.get(key) || { + rows: [], + color, + count: 0, + name: model.name, + brand: row.brand, + operator: model.operator, + }; + value.rows.push(row); + value.count += counts.quantity; + groups.set(key, value); + } + } + return [...groups.entries()].sort((a, b) => b[1].count - a[1].count); + }, [scope.rows]); + const shown = variants.filter(([, item]) => + [item.name, item.brand, item.operator, item.color] + .join(" ") + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); + let offset = 0; + return ( + <> + !value && close()}> + { + event.preventDefault(); + if (!selection) originFocus.current?.focus({ preventScroll: true }); + }} + > + +
+ +
+

库存分析

+ 库存构成 +
+ +
+ + {subject || "全部公司"} · {scope.label} + +
+
{ + if (node) node.scrollTop = scrollTop.current; + }} + onScroll={(event) => { + scrollTop.current = event.currentTarget.scrollTop; + }} + > +
+
+
+ + + {segments + .filter((segment) => segment.count > 0) + .map((segment) => { + const proportion = segment.count / total.quantity; + const dashOffset = -offset; + offset += proportion * 100; + return ( + + open({ + inventoryOperation: segment.key, + title: `${segment.label}车辆`, + }) + } + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open({ + inventoryOperation: segment.key, + title: `${segment.label}车辆`, + }); + } + }} + > + + {segment.label} {segment.count} 台 ·{" "} + {(proportion * 100).toFixed(1)}% + + + ); + })} + +
+ 总库存 + {total.quantity.toLocaleString()} + 台 +
+
+
+

运营状态

+ {segments.map((segment) => ( + + ))} +

+ {total.unclassified + ? "运营状态按原始字段分别统计" + : "可运营 + 待运营 = 总库存"} +

+ {total.unclassified > 0 && ( +

+ 存在旧状态,单独列示待核实,未计入可运营或待运营。 +

+ )} +
+
+
+
+

业务状态

+ 已包含在总库存内 +
+ +
+ {Object.entries(total.abnormalTypes).map(([type, count]) => ( + + ))} + {!total.abnormal && 当前范围无异动车辆} +
+ +
+
+
+
+
+

型号明细

+

品牌 · 所属公司 · 颜色

+
+ +
+ setQuery(event.target.value)} + /> +
+ {shown.map(([key, item]) => ( + + ))} + {!shown.length && ( +

没有匹配的型号

+ )} +
+
+
+
+ 点击数量查看当前范围的车辆明细 + +
+
+
+ setSelection(null)} + returnLabel="返回库存构成" + /> + + ); +} diff --git a/src/modules/assets/library/InventoryExplorer.tsx b/src/modules/assets/library/InventoryExplorer.tsx new file mode 100644 index 0000000..2601fd0 --- /dev/null +++ b/src/modules/assets/library/InventoryExplorer.tsx @@ -0,0 +1,475 @@ +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(null); + const headingRef = useRef(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( + "[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, 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 ( +
+
+
+

库存找车

+

选地区、选城市,快速找到对应车辆

+
+
+
    + {["选地区", "选城市", "查车辆"].map((title, index) => ( +
  1. + {index + 1} + {title} + {index < 2 &&
  2. + ))} +
+ +
+ {level > 0 && ( + + )} +
+

+ {state.city !== null + ? cityName(state.city) + : state.region !== null + ? regionName(state.region) + : "地区库存"} +

+

+ {state.type || "全部车型"} + {hasFilters ? " · 已筛选" : ""} ·{" "} + {state.city !== null + ? `${models.length} 种型号` + : `${places.filter((place) => place.total.quantity > 0).length} 个${level === 0 ? "地区" : "城市"}有库存`} +

+
+
+ {fmt(total.quantity)} + 库存 / 台 +
+
+
+ 找什么车型 + 数量为当前范围库存 +
+
+ {["", ...INVENTORY_TYPES].map((type) => ( + + ))} +
+
+ + row.brand))} + change={(brand) => navigate({ brand, model: null })} + /> +
+
+ + {level === 0 + ? "选择地区" + : level === 1 + ? "选择城市" + : "选择型号查看车辆"} + + {hasFilters ? ( + + ) : ( + {level < 2 ? "按库存从多到少" : "点击进入车辆明细"} + )} +
+ {total.quantity === 0 && ( +
+
+ )} + {level < 2 ? ( +
+ {places.map((place) => ( + + ))} +
+ ) : ( +
+ {models.map((model) => ( + + ))} +
+ )} + {state.model !== null && !chosenModel && ( +
+ 该型号已不在当前库存范围 + +
+ )} +
+ 库存包含待交车、异动车辆,不代表均可立即交付。 + 「其他」包含挂车及未单列车型。 +
+ +
+ ); +} diff --git a/src/modules/assets/library/InventorySection.tsx b/src/modules/assets/library/InventorySection.tsx new file mode 100644 index 0000000..e864dfb --- /dev/null +++ b/src/modules/assets/library/InventorySection.tsx @@ -0,0 +1,460 @@ +import { Fragment, useMemo, useState } from "react"; +import { + ChevronDown, + ChevronRight, + ChevronsDownUp, + ListFilter, + Search, + X, + ArrowUpRight, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { RegionalInventoryStats } from "../types"; +import { Picker, unique, useMobile } from "./shared"; +import { ExportButton } from "./export"; +import { InventoryExplorer } from "./InventoryExplorer"; +import { + groupInventory, + INVENTORY_TYPES, + inventoryColumn, + inventoryTotals, + type InventoryScope, +} from "./inventory-view"; + +type Inspect = (scope: InventoryScope) => void; +export function InventoryNumber({ + rows, + label, + inspect, +}: { + rows: RegionalInventoryStats[]; + label: string; + inspect: Inspect; +}) { + const total = inventoryTotals(rows); + if (!total.quantity) return 0; + return ( +
+ + {(total.pending > 0 || total.abnormal > 0) && ( +
+ {total.pending > 0 && ( + + )} + {total.abnormal > 0 && ( + + )} +
+ )} +
+ ); +} +export function InventorySection({ + inventory, + inspect, + subject, +}: { + subject: string | null; + inventory: RegionalInventoryStats[]; + inspect: Inspect; +}) { + const mobile = useMobile(); + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState({ region: "", city: "", brand: "" }); + const [filterOpen, setFilterOpen] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + const [expandedType, setExpandedType] = useState(""); + const filtered = useMemo( + () => + inventory.filter( + (row) => + Object.entries(filters).every( + ([key, value]) => + !value || row[key as keyof typeof filters] === value, + ) && + (!query.trim() || + [ + row.region, + row.city, + row.brand, + row.type, + row.model, + inventoryColumn(row.type), + ] + .join(" ") + .toLowerCase() + .includes(query.trim().toLowerCase())), + ), + [inventory, filters, query], + ); + const rows = filtered; + const groups = groupInventory(rows, "region"); + const brands = unique( + filtered + .filter((row) => inventoryColumn(row.type) === expandedType) + .map((row) => row.brand), + ); + const hasBrandColumns = Boolean(expandedType) && brands.length > 0; + const columns = INVENTORY_TYPES.flatMap((type) => [ + { + key: type, + title: expandedType === type && brands.length ? "小计" : type, + type, + brand: "", + }, + ...(expandedType === type + ? brands.map((brand) => ({ + key: `${type}-${brand}`, + title: brand, + type, + brand, + })) + : []), + ]); + const activeCount = Object.values(filters).filter(Boolean).length; + const total = inventoryTotals(rows); + const toggleRegion = (region: string) => + setExpanded((old) => { + const next = new Set(old); + if (next.has(region)) next.delete(region); + else next.add(region); + return next; + }); + const cellRows = ( + records: RegionalInventoryStats[], + type: string, + brand: string, + ) => + records.filter( + (row) => + inventoryColumn(row.type) === type && (!brand || row.brand === brand), + ); + const renderCells = (records: RegionalInventoryStats[], label: string) => ( + <> + + + + {columns.map((col) => ( + + + + ))} + + ); + if (mobile) + return ; + return ( +
+
+
+

库存统计

+

地区 × 车型总览,展开城市核对库存

+
+
+
+ {query || activeCount ? "筛选库存" : "总库存"} + +
+ +
+
+
+ +
+ + inventoryColumn(String(value)), + }, + { key: "model", label: "原始型号" }, + { key: "quantity", label: "库存" }, + { key: "available", label: "可运营" }, + { key: "awaiting", label: "待运营" }, + { key: "abnormal", label: "异动" }, + { key: "pending", label: "待交车" }, + ]} + filename="库存统计" + sheetName="库存" + /> +
+
+ {filterOpen && ( +
+ row.region))} + change={(region) => + setFilters((old) => ({ ...old, region, city: "" })) + } + /> + !filters.region || row.region === filters.region, + ) + .map((row) => row.city), + )} + change={(city) => setFilters((old) => ({ ...old, city }))} + /> + row.brand))} + change={(brand) => setFilters((old) => ({ ...old, brand }))} + /> + +
+ )} + {activeCount > 0 && ( +
+ {Object.values(filters).filter(Boolean).join(" / ")} + +
+ )} +
+ {INVENTORY_TYPES.map((type) => { + const counts = inventoryTotals(cellRows(rows, type, "")); + return ( + + ); + })} +
+
+ 地区合计与车型合计均为当前筛选范围 + 点击车型展开品牌 · 点击数字查看明细 +
+ {!rows.length ? ( +
+ 暂无匹配库存 +

尝试切换车型或调整筛选条件

+ +
+ ) : ( +
+ + + + + + + {INVENTORY_TYPES.map((type) => ( + + ))} + + {hasBrandColumns && ( + + + {brands.map((brand) => ( + + ))} + + )} + + + {groups.map((group) => ( + + + + {renderCells( + group.cities.flatMap((city) => city.rows), + group.name, + )} + + {expanded.has(group.name) && + group.cities.map((city) => ( + + + {renderCells(city.rows, `${group.name} / ${city.name}`)} + + ))} + + ))} + + + + + {renderCells(rows, "当前库存合计")} + + +
+ 区域展开城市,车型展开品牌,点击库存数量查看构成与车辆明细 +
+ 区域 / 城市 + + 总库存 + + +
小计 + {brand} +
+ +
+ {city.name} +
+ 当前范围合计 +
+
+ )} +
+ + {groups.length} 个区域 ·{" "} + {groups.reduce((sum, group) => sum + group.cities.length, 0)} 个城市 ·{" "} + {total.quantity} 台 + + 异动、待交车已包含在库存中 ·「其他」包含挂车及未单列车型 +
+
+ ); +} diff --git a/src/modules/assets/library/VehicleDetails.tsx b/src/modules/assets/library/VehicleDetails.tsx new file mode 100644 index 0000000..15f81de --- /dev/null +++ b/src/modules/assets/library/VehicleDetails.tsx @@ -0,0 +1,333 @@ +import { inventoryColor } from "../../../shared/assets/inventory"; +import { matchesInventorySelection, inventoryModel } from "./inventory-view"; +import { ArrowLeft, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useEffect, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + fetchVehicleList, + fetchWeeklyDetail, + type WeeklyDetailItem, +} from "../api"; +import { buildVehicleModalRequest, type VehicleModalSelection } from "../model"; +import type { VehicleListItem } from "../types"; +import { DataGrid, type Column } from "./DataGrid"; +import { ExportButton } from "./export"; +import { Failure, FilterFields, PrivateText, unique } from "./shared"; +const titles = { + Inventory: "库存车辆", + Operating: "运营车辆", + Abnormal: "异动车辆", + Pending: "待交车车辆", + Delivered: "本周交车", + Returned: "本周还车", + Replaced: "本周替换交车", +}; +export function VehicleDetails({ + selection, + subject, + close, + returnLabel = "返回统计", +}: { + selection: VehicleModalSelection | null; + subject: string | null; + close: () => void; + returnLabel?: string; +}) { + const returnFocus = useRef(null); + const [vehicles, setVehicles] = useState([]); + const [weekly, setWeekly] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + const [filters, setFilters] = useState({ + model: "", + brandLabel: "", + location: "", + }); + useEffect(() => { + let active = true; + setFilters({ model: "", brandLabel: "", location: "" }); + setVehicles([]); + setWeekly([]); + setError(""); + if (!selection) return; + setLoading(true); + const request = buildVehicleModalRequest(selection, subject); + const run = async () => { + try { + if (request.kind === "weekly") { + const rows = await fetchWeeklyDetail(request.type, { + ...request.filters, + subject, + }); + if (active) setWeekly(rows); + } else { + const rows = await fetchVehicleList(request.params); + if (active) + setVehicles( + rows.filter((row) => matchesInventorySelection(row, selection)), + ); + } + } catch (e) { + if (active) setError(e instanceof Error ? e.message : "加载失败"); + } finally { + if (active) setLoading(false); + } + }; + void run(); + return () => { + active = false; + }; + }, [selection, subject, retry]); + const rows = vehicles + .filter((r) => + Object.entries(filters).every( + ([k, v]) => !v || r[k as keyof VehicleListItem] === v, + ), + ) + .map((r) => ({ + ...r, + color: inventoryColor(r.color), + displayModel: inventoryModel(r.model, r.brandLabel || "").name, + operator: inventoryModel(r.model, r.brandLabel || "").operator || "—", + pendingContract: r.pendingDelivery?.contractNo || "—", + pendingCustomer: r.pendingDelivery?.customerName || "—", + moveType: r.abnormalMove?.type || "—", + work: r.abnormalMove?.work || "—", + target: r.abnormalMove?.targetCompletionTime || "—", + actual: r.abnormalMove?.actualCompletionTime || "—", + initiating: r.abnormalMove?.initiatingDepartment || "—", + executing: r.abnormalMove?.executingDepartment || "—", + responsible: r.abnormalMove?.responsiblePerson || "—", + driver: r.abnormalMove?.driver || "—", + })); + type Row = (typeof rows)[number]; + const columns: Column[] = [ + { key: "plateNumber", label: "车牌", private: true, width: 140 }, + { key: "brandLabel", label: "品牌" }, + { key: "type", label: "车型" }, + { key: "displayModel", label: "型号", width: 160 }, + { key: "operator", label: "所属公司" }, + { key: "color", label: "颜色" }, + { key: "location", label: "所在地" }, + ]; + if (selection?.category === "Pending") + columns.push( + { + key: "pendingContract", + primary: true, + label: "待交车合同", + width: 180, + }, + { + key: "pendingCustomer", + primary: true, + label: "客户(已脱敏)", + width: 180, + private: true, + }, + ); + if (selection?.category === "Abnormal") + columns.push( + ...([ + { key: "moveType", primary: true, label: "异动类型" }, + { key: "target", primary: true, label: "预计结束时间", width: 180 }, + { key: "work", label: "具体工作", width: 220 }, + { key: "initiating", label: "发起部门" }, + { key: "executing", label: "执行部门" }, + { key: "responsible", label: "负责人", private: true }, + { key: "driver", label: "操作司机", private: true }, + { key: "actual", label: "实际完成时间", width: 180 }, + ] as Column[]), + ); + columns.push( + { + key: "operationStatus", + label: "运营状态", + primary: true, + format: (value) => + ({ "1": "租赁", "2": "自营", "3": "可运营", "4": "待运营" })[ + String(value) + ] || String(value || "未标注"), + }, + { + key: "status", + label: "业务状态", + primary: true, + format: (value) => + ({ + Inventory: "库存", + Operating: "运营", + Abnormal: "异动", + Pending: "待交车", + })[String(value)] || String(value), + }, + { key: "departmentName", label: "部门" }, + { key: "customerManager", label: "客户经理", private: true }, + { key: "vin", label: "车架号", private: true, width: 200 }, + ); + const isWeekly = + selection && + ["Delivered", "Returned", "Replaced"].includes(selection.category || ""); + const weeklyColumns: Column[] = [ + { key: "plate_number", label: "车牌", private: true }, + { key: "handover_date", label: "签章日期", width: 180 }, + { key: "contract_type", label: "合同类型" }, + { key: "customer_name", label: "客户", private: true }, + ]; + const title = + selection?.title || + (selection?.category ? titles[selection.category] : "车辆明细"); + const context = [ + ["城市 / 区域", selection?.location], + ["品牌", selection?.brand], + ["车型", selection?.type || selection?.vehicleType], + ["型号", selection?.model], + ["批次", selection?.batch], + ["部门", selection?.department], + ].filter(([, value]) => value && value !== "All"); + return ( + !v && close()}> + { + returnFocus.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + }} + onCloseAutoFocus={(event) => { + if (returnFocus.current?.isConnected) { + event.preventDefault(); + returnFocus.current.focus({ preventScroll: true }); + } + }} + > + +
+ +
+

+ 资产管理 /{" "} + {selection?.source === "department" + ? "部门运营" + : selection?.source === "region" + ? "区域分布" + : selection?.source === "customer" + ? "客户资产" + : "资产统计"} +

+ + {title} + +
+ +
+ + {subject || "全部公司"} ·{" "} + {loading + ? "正在加载车辆…" + : `${isWeekly ? weekly.length : vehicles.length} 条记录`} + + {selection?.scopeLabel && ( +

{selection.scopeLabel}

+ )} + {context.length > 0 && ( +
+ {context.map(([label, value]) => ( + + {label}:{value} + + ))} +
+ )} + {selection?.customer && ( +

+ 客户:{selection.customer} +

+ )} + {selection?.manager && ( +

+ 负责人:{selection.manager} +

+ )} +
+
+ {error ? ( + setRetry((n) => n + 1)} /> + ) : loading ? ( + + ) : isWeekly ? ( + <> + + + + ) : ( + <> + ({ + label: ["型号", "品牌", "所在地"][i], + value: filters[key], + values: unique(vehicles.map((r) => r[key])), + change: (value) => + setFilters((f) => ({ ...f, [key]: value })), + }), + )} + onReset={() => + setFilters({ model: "", brandLabel: "", location: "" }) + } + /> + + + + )} +
+
+ 关闭后返回原来的筛选与浏览位置 + +
+
+
+ ); +} diff --git a/src/modules/assets/library/asset-ui.css b/src/modules/assets/library/asset-ui.css new file mode 100644 index 0000000..ccd252d --- /dev/null +++ b/src/modules/assets/library/asset-ui.css @@ -0,0 +1,1040 @@ +/* Asset BI only: shared by the page and portalled drill-down panels. */ +.asset-ui { + --font-sans: + "Geist Variable", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", + system-ui, sans-serif; + --font-heading: var(--font-sans); + --radius: 0.625rem; + --blue: #2563eb; + --label: #172b4d; + --label-secondary: #5e6e85; + --bg-secondary: #f3f6fa; + --bg-tertiary: #fff; + --background: #fff; + --foreground: #172b4d; + --card-foreground: #172b4d; + --muted: #edf2f7; + --muted-foreground: #5e6e85; + --primary: #2563eb; + --border: #dde5ef; + --input: #cbd5e1; + --separator: #dde5ef; + --asset-soft: #eff5ff; + --asset-hover: #e7efff; + --shadow-1: 0 1px 2px rgb(23 43 77 / 3%); + --shadow-2: var(--shadow-1); + --shadow-3: 0 2px 8px rgb(23 43 77 / 6%); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + color: var(--label); +} +[data-theme="dark"] .asset-ui { + --label: #e2eaf5; + --label-secondary: #a4b3c8; + --bg-secondary: #111b2b; + --bg-tertiary: #19263a; + --background: #19263a; + --foreground: #e2eaf5; + --card-foreground: #e2eaf5; + --muted: #23324a; + --muted-foreground: #a4b3c8; + --primary: #8bb6ff; + --blue: #8bb6ff; + --border: #34445c; + --input: #52647e; + --separator: #34445c; + --asset-soft: #1d3050; + --asset-hover: #253c60; +} +.asset-ui [data-slot="card"] { + border: 1px solid var(--border); + border-radius: 12px; + --card-spacing: 12px; +} +.asset-ui [data-slot="card-title"] { + letter-spacing: 0; +} +.asset-ui [data-slot="card-description"] { + line-height: 1.6; +} +.asset-ui [data-slot="button"], +.asset-ui [data-slot="select-trigger"], +.asset-ui [data-slot="input"] { + border-radius: 8px; +} +.asset-ui button:not(:disabled), +.asset-ui [role="tab"] { + cursor: pointer; +} +.asset-ui button:active { + translate: none; +} +.asset-ui :focus-visible { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +.asset-ui [data-slot="tabs-list"] { + height: auto; + min-height: 36px; +} +.asset-ui [data-slot="tabs-trigger"] { + min-height: 36px; + padding: 6px 12px; + color: var(--label-secondary); +} +.asset-ui [data-slot="tabs-trigger"][data-state="active"] { + background: var(--bg-tertiary); + color: var(--blue); + box-shadow: 0 1px 4px rgb(23 43 77 / 8%); +} +.asset-ui .asset-main-tabs { + background: transparent; + border-bottom: 1px solid var(--border); + border-radius: 0; + width: 100%; + justify-content: flex-start; + gap: 24px; + padding: 0; + margin-bottom: 4px; +} +.asset-ui .asset-main-tabs [role="tab"] { + flex: none; + border-radius: 0; + min-height: 36px; + padding-inline: 4px; + border-bottom: 2px solid transparent; +} +.asset-ui .asset-main-tabs [role="tab"][data-state="active"] { + box-shadow: none; + background: transparent; + border-bottom-color: var(--blue); +} +.asset-ui .asset-page-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.asset-ui .asset-title-icon { + color: var(--blue); + background: var(--asset-soft); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px; +} +.asset-ui .asset-total { + display: flex; + align-items: center; + gap: 12px; + padding-left: 16px; + border-left: 1px solid var(--border); +} +.asset-ui .asset-total button { + font-size: 26px; + font-weight: 650; + color: var(--label); + min-height: 44px; +} +.asset-ui .asset-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} +.asset-ui .asset-toolbar [data-slot="field"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +} +.asset-ui .asset-toolbar [data-slot="field-label"] { + white-space: nowrap; + color: var(--label-secondary); +} +.asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-width: 180px; +} +.asset-ui .asset-kpi { + background: var(--bg-tertiary); + border-top: 3px solid var(--blue); +} +.asset-ui .asset-kpi.inventory { + border-top-color: #64748b; +} +.asset-ui .asset-kpi [data-slot="card-header"] { + min-height: 40px; +} +.asset-ui .asset-kpi [data-slot="card-content"] { + gap: 8px; +} +.asset-ui [data-slot="card-content"].grid { + row-gap: 12px; +} +@media (min-width: 768px) { + .asset-ui .asset-kpi { + row-gap: 8px; + } + .asset-ui .asset-kpi-number { + min-height: 36px; + } + .asset-ui .asset-toolbar [data-slot="button"], + .asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-height: 36px; + height: 36px; + } + .asset-ui .asset-toolbar [data-size="icon"] { + min-width: 36px; + width: 36px; + } +} +@media (min-width: 1100px) { + .asset-ui .asset-kpi { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + align-items: center; + column-gap: 0; + } + .asset-ui .asset-kpi [data-slot="card-content"] { + padding-left: 8px; + } +} +.asset-ui .asset-kpi [data-slot="card-title"] svg { + width: 18px; + height: 18px; + color: var(--label-secondary); +} +.asset-ui .asset-kpi-number { + min-height: 44px; + padding: 0; + gap: 8px; + font-size: 28px; + line-height: 1.1; + font-weight: 650; + letter-spacing: -1px; + color: var(--blue); +} +.asset-ui .asset-kpi-number .asset-kpi-unit { + font-size: 13px; + font-weight: 400; + letter-spacing: 0; + color: var(--label-secondary); +} +.asset-ui .asset-kpi-number svg { + width: 18px; + height: 18px; + margin-left: 6px; + opacity: 0.65; +} +.asset-ui .asset-kpi-number:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 6px; +} +.asset-ui .asset-kpi.inventory .asset-kpi-number { + color: var(--label); +} +.asset-ui .asset-group { + border: 1px solid var(--border); + border-radius: 10px; + margin-top: 10px; + overflow: hidden; +} +.asset-ui .asset-group > h3 { + background: var(--bg-secondary); +} +.asset-ui .asset-group > h3 button { + padding: 8px 12px; + min-height: 40px; + text-decoration: none; +} +.asset-ui .asset-group > [role="region"] > div { + padding: 12px; +} +.asset-ui .asset-group-name { + font-size: 14px; + font-weight: 600; +} +.asset-ui .asset-count { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + min-height: 32px; + min-width: 44px; + padding: 0 6px; + color: var(--blue); + font-size: 14px; + font-weight: 600; + border-radius: 5px; +} +.asset-ui .asset-count:hover { + background: var(--asset-hover); + text-decoration: none; +} +.asset-ui .asset-count svg { + width: 12px; + height: 12px; + opacity: 0.65; +} +.asset-ui .asset-zero { + color: var(--label-secondary); + display: inline-block; + padding-right: 23px; + font-weight: 400; +} +.asset-ui .ag-numeric-header .ag-header-cell-label { + justify-content: flex-end; +} +.asset-ui .ag-cell.ag-cell-last-left-pinned { + font-weight: 550; +} +.asset-ui .asset-grid-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.asset-ui .asset-grid-search { + position: relative; + width: min(100%, 280px); +} +.asset-ui .asset-grid-search input { + height: 38px; + padding-left: 34px; + padding-right: 36px; + background: var(--bg-tertiary); +} +.asset-ui .asset-grid-search > svg { + position: absolute; + left: 10px; + top: 11px; + width: 16px; + height: 16px; + color: var(--label-secondary); +} +.asset-ui .asset-grid-search button { + position: absolute; + right: 0; + top: 0; + height: 38px; + width: 36px; +} +.asset-ui .asset-record { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + box-shadow: var(--shadow-1); +} +.asset-ui .asset-record-head { + padding: 10px 12px; + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); +} +.asset-ui .asset-record-head h4 { + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; +} +.asset-ui .asset-record-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + padding: 10px 12px; +} +.asset-ui .asset-record-fields dt { + font-size: 12px; + color: var(--label-secondary); + margin-bottom: 4px; +} +.asset-ui .asset-record-fields dd { + font-size: 14px; + overflow-wrap: anywhere; +} +.asset-ui .asset-record-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--border); + border-top: 1px solid var(--border); +} +.asset-ui .asset-metric-cell { + min-width: 0; + background: var(--asset-soft); + padding: 10px 12px; +} +.asset-ui .asset-metric-cell dt { + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui button.asset-metric-cell { + display: flex; + flex-direction: column; + align-items: start; + text-align: left; + min-height: 60px; + gap: 4px; +} +.asset-ui button.asset-metric-cell:hover { + background: var(--asset-hover); +} +.asset-ui .asset-metric-value { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + font-size: 20px; + font-weight: 600; + color: var(--blue); +} +.asset-ui .asset-metric-value svg { + width: 14px; + height: 14px; +} +.asset-ui .asset-metric-cell.asset-metric-empty { + background: var(--bg-secondary); +} +.asset-ui .asset-metric-empty .asset-metric-value { + color: var(--label-secondary); + font-weight: 400; +} +.asset-ui.asset-detail { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + width: calc(100% - 64px); + max-width: 1280px; + max-height: 90dvh; + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--label); + border-radius: 14px; +} +.asset-ui .asset-detail-header { + flex-shrink: 0; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} +.asset-ui .asset-detail-body { + overflow: auto; + overscroll-behavior: contain; + min-height: 0; + padding: 12px 16px; + display: grid; + gap: 16px; +} +.asset-ui .asset-detail-footer { + border-top: 1px solid var(--border); + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--label-secondary); + font-size: 12px; +} +.asset-ui .asset-context { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} +.asset-ui .asset-context span { + font-size: 12px; + padding: 3px 8px; + border: 1px solid var(--border); + background: var(--bg-secondary); + border-radius: 5px; + overflow-wrap: anywhere; +} +@media (max-width: 767px) { + .asset-ui { + font-size: 14px; + } + .asset-ui [data-slot="card"] { + --card-spacing: 12px; + border-radius: 10px; + } + .asset-ui .asset-page-header { + gap: 12px; + } + .asset-ui .asset-title-icon { + display: none; + } + .asset-ui .asset-total { + display: block; + padding-left: 12px; + text-align: right; + } + .asset-ui .asset-total button { + font-size: 26px; + padding-right: 0; + } + .asset-ui .asset-toolbar { + gap: 12px; + padding-bottom: 12px; + } + .asset-ui .asset-toolbar [data-slot="field"] { + flex-direction: column; + align-items: start; + gap: 6px; + } + .asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-width: 0; + width: 100%; + } + .asset-ui .asset-main-tabs { + gap: 0; + margin-bottom: 8px; + } + .asset-ui .asset-main-tabs [role="tab"] { + min-height: 44px; + flex: 1; + } + .asset-ui [data-slot="tabs-trigger"] { + padding-inline: 10px; + min-height: 44px; + } + .asset-ui [data-slot="button"]:not(.asset-kpi-number), + .asset-ui [data-slot="select-trigger"] { + min-height: 44px; + } + .asset-ui [data-slot="button"][data-size^="icon"] { + min-width: 44px; + } + .asset-ui .asset-kpi [data-slot="card-header"] { + min-height: 0; + } + .asset-ui .asset-kpi-number { + font-size: 28px; + } + .asset-ui .asset-kpi [data-slot="card-action"] { + align-self: center; + } + .asset-ui .asset-group > h3 button { + padding: 12px; + } + .asset-ui .asset-group > [role="region"] > div { + padding: 12px; + } + .asset-ui .asset-grid-search { + width: 100%; + } + .asset-ui .asset-grid-search input { + height: 44px; + font-size: 16px; + } + .asset-ui .asset-grid-search > svg { + top: 14px; + } + .asset-ui .asset-grid-search button { + height: 44px; + width: 44px; + } + .asset-ui.asset-detail { + top: 0; + left: 0; + transform: none; + translate: none; + width: 100%; + max-width: 100%; + height: 100dvh; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + .asset-ui .asset-detail-header { + padding: max(12px, env(safe-area-inset-top)) 16px 12px; + } + .asset-ui .asset-detail-body { + padding: 16px; + flex: 1; + align-content: start; + background: var(--bg-secondary); + } + .asset-ui .asset-detail-footer { + padding: 10px 16px max(10px, env(safe-area-inset-bottom)); + } +} +@media (prefers-reduced-motion: reduce) { + .asset-ui *, + .asset-ui *::before, + .asset-ui *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +.asset-ui .inventory-panel { + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow-1); +} +.asset-ui .inventory-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-heading-title { + position: relative; + padding-left: 16px; +} +.asset-ui .inventory-heading-title::before { + content: ""; + position: absolute; + left: 0; + top: 3px; + width: 4px; + height: 36px; + border-radius: 3px; + background: var(--blue); +} +.asset-ui .inventory-heading h2 { + font-size: 18px; + font-weight: 650; +} +.asset-ui .inventory-heading p { + font-size: 12px; + color: var(--label-secondary); + margin-top: 4px; +} +.asset-ui .inventory-total { + display: grid; + text-align: right; + gap: 0; + white-space: nowrap; +} +.asset-ui .inventory-total > span { + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-total strong { + font-size: 28px; + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.5px; +} +.asset-ui .inventory-total small { + margin-left: 6px; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-filter-count { + background: var(--blue); + color: var(--background); + min-width: 20px; + border-radius: 4px; + font-size: 12px; +} +.asset-ui .inventory-filters { + padding: 16px 24px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 24px 0; +} +.asset-ui .inventory-filter-chips button { + white-space: normal; + height: auto; + text-align: left; +} +.asset-ui .inventory-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px 12px; +} +.asset-ui .inventory-search { + position: relative; + margin-left: auto; + width: 260px; +} +.asset-ui .inventory-search input { + height: 40px; + padding-left: 34px; + padding-right: 36px; + background: var(--bg-tertiary); +} +.asset-ui .inventory-search > svg { + width: 16px; + height: 16px; + position: absolute; + left: 10px; + top: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-search button { + position: absolute; + right: 0; + top: 0; + height: 40px; + width: 36px; +} +.asset-ui .inventory-summary { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 0 24px 12px; + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-table-scroll { + overflow: auto; + max-height: 660px; + scrollbar-gutter: stable; +} +.asset-ui .inventory-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + font-size: 14px; +} +.asset-ui .inventory-table thead { + position: sticky; + top: 0; + z-index: 2; +} +.asset-ui .inventory-table thead th { + background: var(--bg-secondary); + color: var(--label-secondary); + font-size: 12px; + font-weight: 600; + padding: 12px 20px; + text-align: left; + border-block: 1px solid var(--border); +} +.asset-ui .inventory-table td, +.asset-ui .inventory-table th.inventory-model-cell { + padding: 10px 20px; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; +} +.asset-ui .inventory-table tr:hover td, +.asset-ui .inventory-table tr:hover th.inventory-model-cell { + background: var(--asset-soft); +} +.asset-ui .inventory-table .inventory-numeric { + text-align: right; +} +.asset-ui .inventory-numeric > button:not(.asset-count) { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; +} +.asset-ui .inventory-numeric svg { + width: 14px; + height: 14px; +} +.asset-ui .inventory-group-row th { + padding: 0; + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-group-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + text-align: left; + padding: 10px 20px; + background: var(--bg-secondary); + font-size: 14px; +} +.asset-ui .inventory-group-toggle > svg { + width: 16px; + height: 16px; + color: var(--label-secondary); + transition: transform 160ms ease; +} +.asset-ui .inventory-group-toggle:hover { + background: var(--asset-hover); +} +.asset-ui .inventory-group-meta { + font-size: 12px; + color: var(--label-secondary); + font-weight: 400; +} +.asset-ui .inventory-group-total { + margin-left: auto; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-group-total b { + font-size: 15px; + font-weight: 600; + color: var(--label); +} +.asset-ui .inventory-table .inventory-city-cell { + vertical-align: top; + text-align: left; + padding: 16px 20px 16px 46px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-city-cell strong { + font-size: 14px; + font-weight: 600; +} +.asset-ui .inventory-city-cell span { + display: block; + margin-top: 4px; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-model-cell { + text-align: left; +} +.asset-ui .inventory-model-cell strong { + font-weight: 500; +} +.asset-ui .inventory-model-cell span { + margin-left: 10px; + padding: 2px 6px; + background: var(--bg-secondary); + border-radius: 4px; + color: var(--label-secondary); + font-size: 12px; + font-weight: 400; + white-space: nowrap; +} +.asset-ui .inventory-footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + font-size: 12px; + line-height: 1.6; + color: var(--label-secondary); + border-top: 1px solid var(--border); +} +.asset-ui .inventory-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 48px 16px; + text-align: center; + color: var(--label-secondary); +} +.asset-ui .inventory-empty > svg { + width: 32px; + height: 32px; +} +.asset-ui .inventory-empty strong { + font-size: 16px; + color: var(--label); +} +.asset-ui .inventory-mobile { + background: var(--bg-secondary); + padding: 0 12px 12px; +} +.asset-ui .inventory-mobile-sort { + width: 100%; + justify-content: flex-end; + color: var(--label-secondary); + font-size: 12px; +} +.asset-ui .inventory-mobile-group { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 4px; + background: var(--bg-tertiary); +} +.asset-ui .inventory-city { + padding: 12px; + border-top: 1px solid var(--border); +} +.asset-ui .inventory-city-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + margin-bottom: 10px; +} +.asset-ui .inventory-city-title svg { + width: 14px; + height: 14px; + color: var(--label-secondary); +} +.asset-ui .inventory-city-title > span { + margin-left: auto; + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-mobile-record { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-top: 8px; +} +.asset-ui .inventory-record-main { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; +} +.asset-ui .inventory-record-main h3 { + font-size: 14px; + font-weight: 600; + overflow-wrap: anywhere; + line-height: 1.5; +} +.asset-ui .inventory-record-main p { + color: var(--label-secondary); + font-size: 12px; + margin-top: 6px; + overflow-wrap: anywhere; +} +.asset-ui .inventory-record-main p > span { + margin-inline: 6px; +} +.asset-ui .inventory-mobile-quantity { + margin-left: auto; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + justify-content: center; + column-gap: 4px; + flex-shrink: 0; + min-width: 74px; + min-height: 64px; + padding: 6px 8px; + border-radius: 6px; + background: var(--asset-soft); + color: var(--blue); +} +.asset-ui .inventory-mobile-quantity strong { + font-size: 24px; + font-weight: 650; +} +.asset-ui .inventory-mobile-quantity > span { + display: flex; + gap: 3px; + font-size: 12px; +} +.asset-ui .inventory-mobile-quantity svg { + width: 12px; + height: 12px; +} +.asset-ui .inventory-mobile-quantity small { + grid-column: span 2; + font-size: 11px; +} +.asset-ui .inventory-mobile-quantity:hover { + background: var(--asset-hover); +} +.asset-ui .inventory-record-status { + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + padding-inline: 8px; + background: var(--bg-secondary); +} +@media (max-width: 767px) { + .asset-ui .inventory-heading { + padding: 16px; + gap: 12px; + flex-wrap: wrap; + } + .asset-ui .inventory-heading-title { + width: 100%; + padding-left: 12px; + } + .asset-ui .inventory-heading-title h2 { + font-size: 17px; + } + .asset-ui .inventory-heading > div:last-child { + width: 100%; + justify-content: space-between; + } + .asset-ui .inventory-total { + display: flex; + align-items: baseline; + gap: 12px; + text-align: left; + } + .asset-ui .inventory-total strong { + font-size: 26px; + } + .asset-ui .inventory-toolbar { + padding: 12px 16px; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + } + .asset-ui .inventory-toolbar [role="tab"] { + padding-inline: 12px; + } + .asset-ui .inventory-toolbar > div:last-child { + gap: 0; + } + .asset-ui .inventory-search { + order: 3; + margin: 0; + width: 100%; + } + .asset-ui .inventory-search input { + height: 44px; + font-size: 16px; + } + .asset-ui .inventory-search > svg { + top: 14px; + } + .asset-ui .inventory-search button { + height: 44px; + width: 44px; + } + .asset-ui .inventory-summary { + padding: 0 16px 12px; + } + .asset-ui .inventory-filters, + .asset-ui .inventory-filter-chips { + padding: 12px 16px; + } + .asset-ui .inventory-group-toggle { + padding: 12px; + } + .asset-ui .inventory-footer { + padding: 12px 16px; + } + .asset-ui .inventory-mobile-quantity small { + font-size: 12px; + } +} +.asset-ui .asset-toolbar [data-slot="field-label"] { + width: auto; + flex: none; +} +/* The global feedback launcher must not cover the modal's vehicle cards. */ +body:has(.asset-detail[data-state="open"]) button[aria-label="反馈"] { + visibility: hidden; +} diff --git a/src/modules/assets/library/export.tsx b/src/modules/assets/library/export.tsx new file mode 100644 index 0000000..4814afb --- /dev/null +++ b/src/modules/assets/library/export.tsx @@ -0,0 +1,54 @@ +import { Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { Column } from "./DataGrid"; + +function cellValue(row: T, col: Column): string | number { + const raw = row[col.key]; + if (col.format) return col.format(raw, row); + return (raw as string | number) ?? ""; +} + +/** 按表格列定义导出 xlsx;xlsx 按需加载,避免进入首屏包。 */ +export async function exportToXlsx( + rows: T[], + columns: Column[], + filename: string, + sheetName = "明细", +): Promise { + const XLSX = await import("xlsx"); + const sheet = XLSX.utils.json_to_sheet( + rows.map((row) => + Object.fromEntries(columns.map((col) => [col.label, cellValue(row, col)])), + ), + ); + const book = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(book, sheet, sheetName); + XLSX.writeFile(book, `${filename}.xlsx`); +} + +export function ExportButton({ + rows, + columns, + filename, + sheetName, + label = "导出", +}: { + rows: T[]; + columns: Column[]; + filename: string; + sheetName?: string; + label?: string; +}) { + return ( + + ); +} diff --git a/src/modules/assets/library/inventory-explorer.test.ts b/src/modules/assets/library/inventory-explorer.test.ts new file mode 100644 index 0000000..de60b08 --- /dev/null +++ b/src/modules/assets/library/inventory-explorer.test.ts @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { RegionalInventoryStats } from "../types"; +import { + inventoryBrowseRows, + inventoryBrowseUrl, + inventoryModels, + inventoryPlaces, + inventoryTypeRows, + readInventoryBrowse, +} from "./inventory-explorer"; +import { + INVENTORY_TYPES, + inventoryTotals, + scopedInventorySelection, +} from "./inventory-view"; +import { inventoryKey } from "../../../shared/assets/inventory"; + +const make = ( + changes: Partial = {}, +): RegionalInventoryStats => ({ + region: "华东", + city: "嘉兴市", + brand: "现代", + type: "4.5T冷链", + model: "冷链(公司甲)", + batch: "批次甲", + quantity: 5, + available: 3, + awaiting: 2, + pending: 1, + abnormal: 1, + ...changes, +}); + +test("region, city and type partitions reconcile without counting pending/abnormal twice", () => { + const rows = [ + make(), + make({ city: "杭州市", quantity: 3 }), + make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }), + make({ type: "挂车", quantity: 2 }), + ]; + const total = inventoryTotals(rows).quantity; + assert.equal(total, 17); + assert.equal( + inventoryPlaces(rows, "region", "").reduce( + (n, place) => n + place.total.quantity, + 0, + ), + total, + ); + assert.equal( + INVENTORY_TYPES.reduce( + (n, type) => n + inventoryTotals(inventoryTypeRows(rows, type)).quantity, + 0, + ), + total, + ); + for (const region of inventoryPlaces(rows, "region", "")) { + assert.equal( + inventoryPlaces(region.rows, "city", "").reduce( + (n, city) => n + city.total.quantity, + 0, + ), + region.total.quantity, + ); + } +}); + +test("type selection keeps zero-stock locations visible and never uses other-type totals", () => { + const places = inventoryPlaces( + [ + make(), + make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }), + ], + "region", + "49T", + ); + assert.deepEqual( + places.map((place) => [place.name, place.total.quantity]), + [ + ["华南", 7], + ["华东", 0], + ], + ); + assert.equal(places[1].rows.length, 0); +}); + +test("city drill-down intersects geography, brand, query and type before opening exact detail keys", () => { + const wanted = make(); + const rows = [ + wanted, + make({ region: "华南" }), + make({ city: "杭州市" }), + make({ brand: "另一品牌" }), + make({ model: "普货", type: "4.5T普货" }), + ]; + const state = { + ...readInventoryBrowse(""), + region: "华东", + city: "嘉兴市", + brand: "现代", + query: "冷链", + type: "4.5T冷链", + }; + const filtered = inventoryTypeRows( + inventoryBrowseRows(rows, state), + state.type, + ); + assert.deepEqual(filtered, [wanted]); + const model = inventoryModels(filtered)[0]; + assert.deepEqual( + scopedInventorySelection({ rows: model.rows, label: "测试范围" }) + .inventoryKeys, + [inventoryKey(wanted)], + ); +}); + +test("missing geography and duplicate city/model names retain exact identity", () => { + const rows = [ + make({ region: "", city: "" }), + make({ city: "" }), + make({ region: "华南", city: "" }), + ]; + assert.equal(inventoryPlaces(rows, "city", "").length, 3); + assert.deepEqual( + inventoryBrowseRows(rows, { + ...readInventoryBrowse(""), + region: "", + city: "", + }), + [rows[0]], + ); + const models = inventoryModels([ + make({ type: "挂车" }), + make({ type: "35T" }), + make({ brand: "另一品牌" }), + ]); + assert.equal(models.length, 3); + assert.equal(new Set(models.map((model) => model.key)).size, 3); +}); + +test("deep links round-trip filters, empty geography and model identity without changing the host route", () => { + const state = { + region: "", + city: "", + type: "其他", + model: '["现代","挂车","A/B"]', + query: "A/B & C", + brand: "现代", + }; + const url = new URL( + inventoryBrowseUrl( + "https://example.test/asset?existing=yes#assets/overview", + state, + ), + ); + assert.deepEqual(readInventoryBrowse(url.search), state); + assert.equal(url.hash, "#assets/overview"); + assert.equal(url.pathname, "/asset"); + assert.equal(url.searchParams.get("existing"), "yes"); + const cleared = new URL( + inventoryBrowseUrl(url.href, readInventoryBrowse("")), + ); + assert.equal(cleared.search, "?existing=yes"); +}); + +test("orphan child links and invalid type values cannot select an unrelated scope", () => { + assert.deepEqual( + readInventoryBrowse("?stockCity=嘉兴市&stockModel=x&stockType=invalid"), + readInventoryBrowse(""), + ); + const state = readInventoryBrowse("?stockRegion=华东&stockModel=x"); + assert.equal(state.model, null); + assert.equal(inventoryModels([]).length, 0); + assert.equal( + inventoryBrowseRows([make()], { ...state, query: "no matching model" }) + .length, + 0, + ); +}); diff --git a/src/modules/assets/library/inventory-explorer.ts b/src/modules/assets/library/inventory-explorer.ts new file mode 100644 index 0000000..1fafcb5 --- /dev/null +++ b/src/modules/assets/library/inventory-explorer.ts @@ -0,0 +1,150 @@ +import type { RegionalInventoryStats } from "../types"; +import { + INVENTORY_TYPES, + inventoryColumn, + inventoryTotals, +} from "./inventory-view"; + +export interface InventoryBrowseState { + region: string | null; + city: string | null; + type: string; + model: string | null; + query: string; + brand: string; +} + +export function readInventoryBrowse(search: string): InventoryBrowseState { + const params = new URLSearchParams(search); + const region = params.get("stockRegion"); + const city = region === null ? null : params.get("stockCity"); + const type = params.get("stockType") || ""; + return { + region, + city, + type: INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number]) + ? type + : "", + model: city === null ? null : params.get("stockModel"), + query: params.get("stockQuery") || "", + brand: params.get("stockBrand") || "", + }; +} + +/** Keep the application's route and unrelated URL parameters intact. */ +export function inventoryBrowseUrl(href: string, state: InventoryBrowseState) { + const url = new URL(href); + for (const [key, value] of Object.entries({ + stockRegion: state.region, + stockCity: state.region === null ? null : state.city, + stockType: state.type || null, + stockModel: + state.region === null || state.city === null ? null : state.model, + stockQuery: state.query || null, + stockBrand: state.brand || null, + })) { + if (value === null) url.searchParams.delete(key); + else url.searchParams.set(key, value); + } + return url.href; +} + +export function inventoryBrowseRows( + rows: RegionalInventoryStats[], + state: InventoryBrowseState, +) { + const query = state.query.trim().toLocaleLowerCase(); + return rows.filter( + (row) => + (state.region === null || row.region === state.region) && + (state.city === null || row.city === state.city) && + (!state.brand || row.brand === state.brand) && + (!query || + [ + row.region, + row.city, + row.brand, + row.type, + row.model, + inventoryColumn(row.type), + ] + .join(" ") + .toLocaleLowerCase() + .includes(query)), + ); +} + +export function inventoryTypeRows( + rows: RegionalInventoryStats[], + type: string, +) { + return type ? rows.filter((row) => inventoryColumn(row.type) === type) : rows; +} + +/** Build places before applying the type filter so no-stock places remain explicit. */ +export function inventoryPlaces( + rows: RegionalInventoryStats[], + level: "region" | "city", + type: string, +) { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify( + level === "region" ? [row.region] : [row.region, row.city], + ); + const group = groups.get(key) || []; + group.push(row); + groups.set(key, group); + } + return [...groups] + .map(([key, records]) => { + const selected = inventoryTypeRows(records, type); + return { + key, + value: records[0][level], + name: + records[0][level] || + (level === "region" ? "未归属地区" : "未归属城市"), + rows: selected, + total: inventoryTotals(selected), + cities: new Set( + selected.filter((row) => row.quantity > 0).map((row) => row.city), + ).size, + types: INVENTORY_TYPES.map((name) => ({ + name, + quantity: inventoryTotals(inventoryTypeRows(records, name)).quantity, + })).filter((item) => item.quantity > 0), + }; + }) + .sort( + (a, b) => + b.total.quantity - a.total.quantity || + a.name.localeCompare(b.name, "zh-CN"), + ); +} + +/** Keep the exact type/model/brand identity even when displayed in the Other column. */ +export function inventoryModels(rows: RegionalInventoryStats[]) { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.brand, row.type, row.model]); + const group = groups.get(key) || []; + group.push(row); + groups.set(key, group); + } + return [...groups] + .map(([key, records]) => ({ + key, + rows: records, + brand: records[0].brand, + model: records[0].model, + type: records[0].type, + total: inventoryTotals(records), + })) + .filter((group) => group.total.quantity > 0) + .sort( + (a, b) => + b.total.quantity - a.total.quantity || + a.model.localeCompare(b.model, "zh-CN"), + ); +} diff --git a/src/modules/assets/library/inventory-matrix.css b/src/modules/assets/library/inventory-matrix.css new file mode 100644 index 0000000..4f04fb7 --- /dev/null +++ b/src/modules/assets/library/inventory-matrix.css @@ -0,0 +1,1173 @@ +/* Inventory surfaces use the app's vendored Apple tokens; data surfaces stay opaque. */ +.asset-ui { + --inventory-awaiting: #74829b; + --inventory-warning: #a85512; + font-family: + -apple-system, BlinkMacSystemFont, "Geist Variable", "PingFang SC", + "Microsoft YaHei", system-ui, sans-serif; + font-optical-sizing: auto; +} +[data-theme="dark"] .asset-ui { + --inventory-awaiting: #9aa9c0; + --inventory-warning: #fdba74; +} +.inventory-matrix { + overflow: hidden; + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-1); +} +.matrix-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; +} +.matrix-heading h2 { + font-size: 1rem; + font-weight: 600; + letter-spacing: var(--tracking-snug); + line-height: 1.25; +} +.matrix-heading p, +.matrix-header-total > span, +.analysis-eyebrow { + font-size: 0.8125rem; + color: var(--label-secondary); + margin-top: 5px; +} +.matrix-heading-actions { + display: flex; + align-items: center; + gap: 16px; +} +.matrix-header-total { + text-align: right; +} +.matrix-header-total .matrix-value { + font-size: 1.5rem; + letter-spacing: var(--tracking-tight); + font-weight: 650; +} +.matrix-header-total .matrix-number { + align-items: center; + flex-direction: row; + gap: 12px; +} +.matrix-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px 10px; +} +.matrix-search { + position: relative; + display: flex; + align-items: center; + width: 360px; + max-width: 100%; +} +.matrix-search > svg { + position: absolute; + left: 12px; + width: 16px; + color: var(--label-secondary); +} +.matrix-search input { + padding-left: 36px; + padding-right: 40px; + min-height: 44px; + background: var(--bg-secondary); + border-color: transparent; +} +.matrix-search > button { + position: absolute; + right: 0; + display: grid; + place-items: center; + min-width: 44px; + min-height: 44px; +} +.matrix-search > button svg { + width: 16px; +} +.matrix-tools { + display: flex; + align-items: center; + gap: 8px; +} +.matrix-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + align-items: end; + gap: 12px; + background: var(--bg-secondary); + padding: 12px 16px; + border-top: 1px solid var(--border); +} +.matrix-filter-summary { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + padding: 8px 24px; + background: var(--asset-soft); + font-size: 0.8125rem; +} +.matrix-filter-summary button { + color: var(--blue); + min-height: 44px; +} +.matrix-scroll { + overflow: auto; + max-height: 640px; + border-block: 1px solid var(--border); +} +.matrix-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} +.matrix-table th, +.matrix-table td { + min-width: 104px; + padding: 4px 12px; + text-align: right; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.matrix-table thead { + position: sticky; + top: 0; + z-index: 3; + background: var(--bg-secondary); +} +.matrix-table thead th { + font-size: 0.8125rem; + font-weight: 500; + color: var(--label-secondary); + background: var(--bg-secondary); + height: 36px; +} +.matrix-table .matrix-geography { + position: sticky; + left: 0; + z-index: 2; + min-width: 176px; + width: 176px; + max-width: 176px; + text-align: left; + background: var(--bg-tertiary); + border-right: 1px solid var(--border); +} +.matrix-table thead .matrix-geography { + z-index: 4; + background: var(--bg-secondary); +} +.matrix-table .matrix-total { + position: sticky; + left: 176px; + z-index: 1; + background: var(--asset-soft); + border-right: 1px solid var(--border); +} +.matrix-table thead .matrix-total { + z-index: 4; +} +.matrix-table .matrix-selected-column, +.matrix-table .matrix-region-row .matrix-selected-column { + background: var(--asset-soft); +} +.matrix-type-overview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 0 16px 10px; +} +.matrix-type-overview > button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + text-align: left; + background: var(--bg-tertiary); +} +.matrix-type-overview > button[aria-pressed="true"] { + border-color: var(--blue); + background: var(--asset-soft); +} +.matrix-type-overview > button > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.8125rem; + color: var(--label-secondary); +} +.matrix-type-overview > button > span:first-child { + color: var(--label); + font-weight: 600; +} +.matrix-type-overview svg { + width: 14px; + height: 14px; +} +.matrix-type-overview > button > span:last-child { + grid-column: 1; + grid-row: 2; +} +@media (min-width: 768px) { + .matrix-search input, + .matrix-search > button { + min-height: 36px; + height: 36px; + } +} + +.matrix-type-overview strong { + font-size: 1.5rem; + line-height: 1.25; + font-weight: 650; + grid-column: 2; + grid-row: 1 / 3; + white-space: nowrap; +} +.matrix-type-overview small { + font-size: 0.75rem; + margin-left: 6px; + color: var(--label-secondary); + font-weight: 400; +} +@media (min-width: 768px) and (max-width: 1100px) { + .matrix-type-overview > button { + grid-template-columns: minmax(0, 1fr); + } + .matrix-type-overview strong, + .matrix-type-overview > button > span:last-child { + grid-column: auto; + grid-row: auto; + } +} +.matrix-reading-guide { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + padding: 0 16px 8px; + font-size: 0.75rem; + color: var(--label-secondary); +} +.matrix-table .matrix-region-row th, +.matrix-table .matrix-region-row td { + background: var(--bg-secondary); + font-weight: 600; +} +.matrix-table .matrix-region-row .matrix-total { + background: var(--asset-soft); +} +.matrix-table .matrix-city-row .matrix-geography span { + padding-left: 28px; + color: var(--label-secondary); + font-weight: 500; +} +.matrix-table tr:hover td, +.matrix-table .matrix-city-row:hover th { + background: var(--asset-hover); +} +.matrix-table .matrix-brand-cell { + border-left: 1px solid var(--border); +} +.matrix-table tfoot { + position: sticky; + bottom: 0; + z-index: 2; +} +.matrix-table tfoot th, +.matrix-table tfoot td { + background: var(--bg-secondary); + border-bottom: 0; + border-top: 1px solid var(--border); + font-weight: 600; +} +.matrix-expand { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + text-align: left; + width: 100%; + font-weight: 600; +} +.matrix-expand > svg { + width: 16px; + height: 16px; + color: var(--label-secondary); + flex-shrink: 0; +} +.matrix-expand small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); + margin-left: 8px; +} +.matrix-column-toggle { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 28px; + color: var(--label); + font-weight: 600; +} +.matrix-column-toggle svg { + width: 14px; + height: 14px; + color: var(--label-secondary); +} +.matrix-column-toggle[aria-expanded="true"] { + color: var(--blue); +} +.matrix-number { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.matrix-value { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-height: 28px; + min-width: 44px; + color: var(--blue); + font-size: 0.9375rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.matrix-value svg { + width: 12px; + height: 12px; + opacity: 0.55; +} +.matrix-value:hover { + text-decoration: underline; + text-underline-offset: 4px; +} +.matrix-secondary { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + justify-content: flex-end; + font-size: 0.75rem; + color: var(--label-secondary); + font-weight: 400; +} +.matrix-secondary button { + min-height: 24px; + white-space: nowrap; +} +.matrix-warning { + color: var(--inventory-warning); +} +.matrix-zero { + color: var(--label-secondary); + font-size: 0.875rem; + font-weight: 400; +} +.matrix-footer { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + font-size: 0.75rem; + color: var(--label-secondary); + padding: 10px 16px; +} +.matrix-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 48px 24px; + color: var(--label-secondary); + text-align: center; +} +.matrix-empty strong { + color: var(--label); + font-size: 1rem; +} +.matrix-mobile-types { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 0 16px 16px; +} +.matrix-mobile-types button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 8px 10px; + border: 1px solid transparent; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 0.75rem; + min-height: 44px; +} +.matrix-mobile-types button span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.matrix-mobile-types button[aria-pressed="true"] { + background: var(--asset-soft); + color: var(--blue); + border-color: var(--blue); +} +.asset-ui.inventory-analysis { + max-width: 960px; +} +.analysis-overview { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 20px; +} +.analysis-composition, +.analysis-workflow { + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + background: var(--bg-tertiary); +} +.analysis-composition { + display: flex; + align-items: center; + gap: 16px; +} +.analysis-donut { + position: relative; + width: 180px; + flex-shrink: 0; +} +.analysis-donut svg { + width: 100%; + display: block; + overflow: visible; +} +.analysis-donut circle[role="button"] { + cursor: pointer; + pointer-events: stroke; +} +.analysis-donut circle[role="button"]:hover { + opacity: 0.8; +} +.analysis-donut-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} +.analysis-donut-center span, +.analysis-donut-center small { + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-donut-center strong { + font-size: 2.125rem; + line-height: 1.2; + letter-spacing: var(--tracking-tight); + font-weight: 650; + margin: 4px 0; +} +.analysis-legend { + flex: 1; + min-width: 0; +} +.analysis-legend h3, +.analysis-workflow h3, +.analysis-variants h3 { + font-size: 0.9375rem; + font-weight: 600; +} +.analysis-legend button { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + width: 100%; + font-size: 0.8125rem; + white-space: nowrap; +} +.analysis-legend button strong { + margin-left: auto; + font-weight: 600; +} +.analysis-legend button svg { + width: 12px; + flex-shrink: 0; + color: var(--blue); +} +.analysis-legend button > span:nth-last-child(2) { + color: var(--label-secondary); + font-size: 0.75rem; +} +.analysis-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.analysis-legend p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 8px; + line-height: 1.6; +} +.analysis-workflow-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.analysis-workflow-heading > span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-workflow-total { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + width: 100%; +} +.analysis-workflow-total > span, +.analysis-workflow-total > strong { + display: flex; + align-items: center; + gap: 8px; +} +.analysis-workflow-total svg { + width: 16px; + height: 16px; + color: var(--label-secondary); +} +.analysis-workflow-total strong { + font-size: 1.25rem; + color: var(--blue); + font-weight: 600; +} +.analysis-workflow-total small { + font-size: 0.75rem; + color: var(--label-secondary); + font-weight: 400; +} +.analysis-workflow-total:disabled strong { + color: var(--label-secondary); +} +.analysis-abnormal-types { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-abnormal-types button { + display: flex; + align-items: center; + gap: 12px; + min-height: 36px; + color: var(--inventory-warning); +} +.analysis-variants { + display: grid; + gap: 16px; +} +.analysis-variants-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.analysis-variants-heading p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 4px; +} +.analysis-variant-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.analysis-variant { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + padding: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-tertiary); + min-width: 0; +} +.analysis-variant > div { + min-width: 0; +} +.analysis-variant:hover { + border-color: var(--blue); + background: var(--asset-soft); +} +.analysis-variant > div > strong { + font-size: 0.9375rem; + font-weight: 600; + overflow-wrap: anywhere; +} +.analysis-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.analysis-tags span { + font-size: 0.75rem; + line-height: 1.5; + color: var(--label-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + overflow-wrap: anywhere; +} +.analysis-variant-count { + display: flex; + align-items: center; + gap: 6px; + color: var(--blue); + font-size: 1.25rem; + font-weight: 600; + white-space: nowrap; +} +.analysis-variant-count small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); +} +.analysis-variant-count svg { + width: 14px; + height: 14px; +} +.inventory-matrix button:active, +.inventory-analysis button:active { + opacity: 0.65; +} +@media (max-width: 1100px) { + .analysis-overview { + grid-template-columns: 1fr; + } + .analysis-composition { + justify-content: center; + } + .analysis-legend { + flex: 0 1 300px; + } +} +@media (max-width: 767px) { + .matrix-heading { + padding: 20px 16px; + gap: 12px; + align-items: flex-start; + } + .matrix-heading h2 { + font-size: 1.125rem; + } + .matrix-heading p { + font-size: 0.75rem; + max-width: 130px; + } + .matrix-heading-actions { + gap: 10px; + align-items: flex-end; + } + .matrix-header-total .matrix-value { + font-size: 1.5rem; + } + .matrix-heading-actions > [data-slot="button"] { + font-size: 0.75rem; + padding: 8px; + } + .matrix-toolbar { + padding: 0 16px 14px; + gap: 8px; + flex-wrap: wrap; + } + .matrix-search { + width: 100%; + } + .matrix-tools { + width: 100%; + justify-content: space-between; + } + .matrix-filters { + padding: 16px; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .matrix-secondary button { + min-height: 44px; + } + .matrix-secondary { + gap: 0 8px; + } + .matrix-footer { + padding: 12px 16px; + gap: 6px; + } + .matrix-number { + flex-shrink: 0; + } + .analysis-overview { + gap: 12px; + } + .analysis-composition { + padding: 12px; + gap: 8px; + } + .analysis-donut { + width: 140px; + } + .analysis-legend { + flex: 1; + } + .analysis-legend button { + gap: 5px; + font-size: 0.75rem; + } + .analysis-legend button svg { + display: none; + } + .analysis-workflow { + padding: 16px; + } + .analysis-workflow-heading > span { + font-size: 0.75rem; + } + .analysis-abnormal-types button { + min-height: 44px; + } + .analysis-variant-list { + grid-template-columns: 1fr; + } + .analysis-variant { + padding: 14px; + } + .analysis-variants-heading { + align-items: center; + } + .analysis-variants-heading > [data-slot="button"] { + font-size: 0.75rem; + padding: 8px; + } + .analysis-donut-center strong { + font-size: 1.75rem; + } + .asset-ui.inventory-analysis { + max-width: 100%; + } +} +@media (prefers-contrast: more) { + .inventory-matrix, + .analysis-variant, + .finder-place { + border-color: var(--label-secondary); + } +} +.asset-ui [data-slot="tabs"], +.asset-ui [data-slot="tabs-content"] { + min-width: 0; + max-width: 100%; +} +.inventory-explorer { + --finder-space: 12px; +} +.finder-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px var(--finder-space) 10px; +} +.finder-header h2 { + font-size: 1.125rem; + font-weight: 650; +} +.finder-header p { + color: var(--label-secondary); + font-size: 0.8125rem; + margin-top: 4px; +} +.finder-header > svg { + width: 24px; + height: 24px; + color: var(--blue); + flex-shrink: 0; +} +.finder-steps { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 var(--finder-space); + padding: 8px 10px; + background: var(--bg-secondary); + border-radius: 10px; + list-style: none; + gap: 8px; +} +.finder-steps li { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--label-secondary); +} +.finder-steps li > span { + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 50%; + width: 20px; + height: 20px; +} +.finder-steps li[aria-current] { + color: var(--blue); + font-weight: 650; +} +.finder-steps li[aria-current] > span { + background: var(--blue); + color: var(--bg-tertiary); + border-color: transparent; +} +.finder-steps svg { + width: 12px; + height: 12px; + color: var(--label-secondary); +} +.finder-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0 8px; + padding: 0 var(--finder-space); + font-size: 0.8125rem; + min-height: 44px; +} +.finder-breadcrumb button { + min-width: 44px; + min-height: 44px; + color: var(--blue); +} +.finder-breadcrumb span { + color: var(--label-secondary); + overflow-wrap: anywhere; +} +.finder-breadcrumb svg { + width: 12px; + height: 12px; + color: var(--label-secondary); +} +.finder-scope { + display: flex; + align-items: center; + gap: 8px; + padding: 4px var(--finder-space) 12px; +} +.finder-back { + display: grid; + place-items: center; + width: 44px; + height: 44px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 10px; + margin-right: 4px; +} +.finder-back svg { + width: 18px; + height: 18px; +} +.finder-scope > div:not(.finder-scope-total) { + min-width: 0; +} +.finder-scope h3 { + font-size: 1.25rem; + font-weight: 650; + overflow-wrap: anywhere; +} +.finder-scope p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 5px; +} +.finder-scope-total { + display: grid; + text-align: right; + margin-left: auto; + flex-shrink: 0; +} +.finder-scope-total strong { + font-size: 1.875rem; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.03em; +} +.finder-scope-total > span { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 5px; +} +.finder-type-heading, +.finder-results-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 var(--finder-space) 10px; +} +.finder-type-heading strong, +.finder-results-heading strong { + font-size: 0.875rem; + font-weight: 600; +} +.finder-type-heading > span, +.finder-results-heading > span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.finder-types { + gap: 8px; + padding: 0 var(--finder-space) 12px; +} +.finder-types button { + padding: 8px 10px; + border-color: var(--border); + font-size: 0.8125rem; + min-height: 56px; + white-space: normal; +} +.finder-types button[aria-pressed="true"] { + box-shadow: inset 0 0 0 1px var(--blue); +} +.finder-types button[aria-pressed="true"] span { + color: var(--blue); +} +.finder-search-row { + display: grid; + gap: 12px; + padding: 0 var(--finder-space) 12px; +} +.finder-search-row .matrix-search { + width: 100%; +} +.finder-search-row input { + font-size: 16px; +} +.finder-search-row [data-slot="field"] { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 12px; +} +.finder-search-row [data-slot="field-label"] { + font-size: 0.8125rem; + color: var(--label-secondary); +} +.finder-search-row [data-slot="select-trigger"] { + min-height: 44px; + width: 100%; +} +.finder-results-heading { + min-height: 48px; + padding-bottom: 4px; +} +.finder-results-heading button { + min-height: 44px; + font-size: 0.8125rem; + color: var(--blue); +} +.finder-list { + display: grid; + gap: 10px; + padding: 0 var(--finder-space) 16px; +} +.finder-place { + display: grid; + width: 100%; + gap: 8px; + min-width: 0; + padding: 12px; + text-align: left; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-tertiary); +} +.finder-place:disabled { + background: var(--bg-secondary); +} +.finder-place-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.finder-place-top > strong { + font-size: 1rem; + font-weight: 600; + overflow-wrap: anywhere; + min-width: 0; +} +.finder-place-count { + display: flex; + align-items: center; + gap: 6px; + font-size: 1.375rem; + font-weight: 650; + color: var(--blue); + white-space: nowrap; +} +.finder-place:disabled .finder-place-count { + color: var(--label-secondary); +} +.finder-place-count small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); +} +.finder-place-count svg { + width: 16px; + height: 16px; + margin-left: 4px; + color: var(--label-secondary); +} +.finder-place-status { + display: flex; + gap: 6px 12px; + flex-wrap: wrap; + color: var(--label-secondary); + font-size: 0.75rem; +} +.finder-type-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.finder-type-tags > span { + padding: 4px 8px; + background: var(--bg-secondary); + border-radius: 5px; + color: var(--label-secondary); + font-size: 0.75rem; +} +.finder-type-tags b { + font-weight: 600; + margin-left: 8px; + color: var(--label); +} +.finder-model-meta { + font-size: 0.8125rem; + color: var(--label-secondary); + overflow-wrap: anywhere; +} +.finder-model-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border-top: 1px solid var(--border); + padding-top: 12px; + font-size: 0.75rem; +} +.finder-model-footer > span:first-child { + color: var(--inventory-warning); +} +.finder-model-footer > span:last-child { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + color: var(--blue); + white-space: nowrap; +} +.finder-model-footer svg { + width: 14px; + height: 14px; +} +.finder-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 24px var(--finder-space); + text-align: center; +} +.finder-empty > svg { + width: 28px; + height: 28px; + color: var(--label-secondary); +} +.finder-empty strong { + font-size: 0.9375rem; +} +.finder-empty p { + font-size: 0.8125rem; + color: var(--label-secondary); + max-width: 32ch; +} +.inventory-explorer [data-slot="button"] { + min-height: 44px; +} +.inventory-explorer .matrix-footer { + background: var(--bg-secondary); + font-size: 0.75rem; + line-height: 1.6; +} +@media (hover: hover) { + .finder-place:not(:disabled):hover, + .matrix-type-overview > button:hover { + background: var(--asset-soft); + border-color: var(--blue); + } +} +@media (prefers-reduced-motion: no-preference) { + .inventory-explorer button, + .matrix-type-overview > button { + transition: + background-color 140ms ease, + border-color 140ms ease, + opacity 100ms ease; + } +} +@media (max-width: 767px) { + .matrix-mobile-types button { + flex-direction: column; + align-items: flex-start; + white-space: nowrap; + gap: 3px; + } + .matrix-mobile-types button span { + align-self: flex-end; + font-size: 0.9375rem; + font-weight: 600; + } + .matrix-heading p { + max-width: none; + } +} diff --git a/src/modules/assets/library/inventory-view.test.ts b/src/modules/assets/library/inventory-view.test.ts new file mode 100644 index 0000000..2716ccb --- /dev/null +++ b/src/modules/assets/library/inventory-view.test.ts @@ -0,0 +1,203 @@ +import { + inventoryKey, + inventoryOperation, + inventoryColor, +} from "../../../shared/assets/inventory"; +import { + INVENTORY_TYPES, + inventoryColumn, + inventoryTotals, + inventoryModel, + scopedInventorySelection, + matchesInventorySelection, +} from "./inventory-view"; +import type { VehicleListItem } from "../types"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { groupInventory, inventorySelection } from "./inventory-view"; +import { buildVehicleModalRequest } from "../model"; +import type { RegionalInventoryStats } from "../types"; +const make = ( + change: Partial = {}, +): RegionalInventoryStats => ({ + region: "华东", + city: "嘉兴市", + brand: "明威", + type: "挂车", + model: "挂车", + batch: "挂车", + quantity: 2, + abnormal: 1, + pending: 1, + ...change, +}); +test("inventory grouping preserves totals and does not double-count abnormal/pending subsets", () => { + const rows = [ + make(), + make({ brand: "万风", quantity: 1 }), + make({ region: "华南", city: "广州市", quantity: 4 }), + ]; + const original = JSON.stringify(rows); + const groups = groupInventory(rows, "region"); + assert.deepEqual( + groups.map((group) => [group.name, group.total]), + [ + ["华南", 4], + ["华东", 3], + ], + ); + assert.equal(groups[1].cities[0].rows.length, 2); + assert.equal( + groups.reduce((sum, group) => sum + group.total, 0), + 7, + ); + assert.equal(JSON.stringify(rows), original); +}); +test("type grouping keeps same-name cities in different regions separate", () => { + const grouped = groupInventory( + [make(), make({ region: "华南", brand: "万风" })], + "type", + ); + assert.equal(grouped.length, 1); + assert.equal(grouped[0].cities.length, 2); + assert.notEqual(grouped[0].cities[0].key, grouped[0].cities[1].key); +}); +test("sorting reverses quantities within each city and preserves the source array", () => { + const rows = [make(), make({ quantity: 5, model: "另一型号" })]; + assert.deepEqual( + groupInventory(rows, "region")[0].cities[0].rows.map((row) => row.quantity), + [5, 2], + ); + assert.deepEqual( + groupInventory(rows, "region", true)[0].cities[0].rows.map( + (row) => row.quantity, + ), + [2, 5], + ); + assert.equal(rows[0].quantity, 2); + assert.deepEqual(groupInventory([], "region"), []); +}); +test("drill-down keeps brand, model, city, type, category and company; does not mistake model for contract batch", () => { + const request = buildVehicleModalRequest( + inventorySelection(make(), "Pending"), + "公司甲", + ); + assert.equal(request.kind, "vehicles"); + if (request.kind !== "vehicles") return; + assert.deepEqual(request.params, { + brand: "明威", + model: "挂车", + location: "嘉兴市", + source: "asset", + category: "Pending", + vehicleType: "挂车", + subject: "公司甲", + }); +}); + +test("matrix merges trailers into other while preserving all five column totals", () => { + const rows = [ + make(), + make({ type: "35T", quantity: 3 }), + make({ type: "4.5T普货", quantity: 4 }), + make({ type: "4.5T冷链", quantity: 5 }), + make({ type: "18T", quantity: 6 }), + make({ type: "49T", quantity: 7 }), + ]; + assert.deepEqual( + INVENTORY_TYPES.map( + (type) => + inventoryTotals( + rows.filter((row) => inventoryColumn(row.type) === type), + ).quantity, + ), + [4, 5, 6, 7, 5], + ); + assert.equal(inventoryTotals(rows).quantity, 27); +}); +test("composition uses independent operation status; abnormal and pending remain subsets", () => { + const total = inventoryTotals([ + make({ + quantity: 5, + available: 3, + awaiting: 2, + abnormal: 2, + pending: 1, + abnormalTypes: { 维修: 2 }, + }), + make({ + quantity: 2, + available: 0, + awaiting: 2, + abnormal: 1, + pending: 0, + abnormalTypes: { 保养: 1 }, + }), + ]); + assert.equal(total.available + total.awaiting, total.quantity); + assert.equal(total.quantity, 7); + assert.equal(total.abnormal, 3); + assert.deepEqual(total.abnormalTypes, { 维修: 2, 保养: 1 }); + assert.equal(inventoryOperation("在库"), "unclassified"); +}); +test("model tags separate operator and color while retaining a model-first name", () => { + assert.deepEqual(inventoryModel("现代4.5T普货(恒运)", "现代"), { + name: "4.5T普货", + operator: "恒运", + }); + assert.deepEqual(inventoryModel("现代18T双飞翼(羚牛)", "帕力安牌"), { + name: "18T双飞翼", + operator: "羚牛", + }); + assert.deepEqual(inventoryModel("49T宇通", "宇通"), { + name: "49T", + operator: "", + }); + assert.deepEqual(inventoryModel("飞驰18T(红车)", "飞驰"), { + name: "18T", + operator: "", + }); + assert.deepEqual(inventoryModel("49T飞驰(浙氢-红)", "飞驰"), { + name: "49T", + operator: "浙氢", + }); +}); +test("drill-down intersects exact geographic/model/brand keys with color and operational status", () => { + const row = make(); + const selection = scopedInventorySelection( + { rows: [row], label: "华东 / 嘉兴市 / 其他" }, + { inventoryColor: "白", inventoryOperation: "available" }, + ); + const vehicle = { + inventoryKey: inventoryKey(row), + color: "白", + operationStatus: "3", + } as VehicleListItem; + assert.equal(matchesInventorySelection(vehicle, selection), true); + assert.equal( + matchesInventorySelection({ ...vehicle, color: "红" }, selection), + false, + ); + assert.equal( + matchesInventorySelection({ ...vehicle, operationStatus: "4" }, selection), + false, + ); + assert.equal( + matchesInventorySelection( + { ...vehicle, inventoryKey: inventoryKey({ ...row, region: "华南" }) }, + selection, + ), + false, + ); + assert.equal( + matchesInventorySelection(vehicle, { ...selection, inventoryKeys: [] }), + false, + ); +}); + +test("known color aliases combine without merging distinct multicolor values", () => { + assert.equal(inventoryColor("红"), inventoryColor("红色")); + assert.equal(inventoryColor("白车"), "白色"); + assert.equal(inventoryColor("蓝白绿"), "蓝白绿"); + assert.equal(inventoryColor(null), "未标注颜色"); +}); diff --git a/src/modules/assets/library/inventory-view.ts b/src/modules/assets/library/inventory-view.ts new file mode 100644 index 0000000..3d2541c --- /dev/null +++ b/src/modules/assets/library/inventory-view.ts @@ -0,0 +1,164 @@ +import { + emptyInventoryCounts, + inventoryKey, + inventoryOperation, + inventoryColor, +} from "../../../shared/assets/inventory"; +import type { VehicleListItem } from "../types"; +import type { RegionalInventoryStats } from "../types"; +import type { VehicleModalCategory, VehicleModalSelection } from "../model"; + +export type InventoryGrouping = "region" | "type"; +export function groupInventory( + rows: RegionalInventoryStats[], + grouping: InventoryGrouping, + ascending = false, +) { + const groups = new Map(); + for (const row of rows) { + const key = row[grouping] || "未归属"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + return [...groups] + .map(([name, items]) => { + const cities = new Map(); + for (const row of items) { + // A city may be missing or share a name with a city in another region. + const key = JSON.stringify([row.region, row.city]); + if (!cities.has(key)) cities.set(key, []); + cities.get(key)!.push(row); + } + return { + name, + total: items.reduce((sum, row) => sum + row.quantity, 0), + cities: [...cities] + .map(([key, records]) => ({ + key, + name: records[0].city || "未归属城市", + region: records[0].region || "未归属", + total: records.reduce((sum, row) => sum + row.quantity, 0), + rows: [...records].sort( + (a, b) => + (ascending ? 1 : -1) * (a.quantity - b.quantity) || + a.model.localeCompare(b.model, "zh-CN"), + ), + })) + .sort( + (a, b) => + b.total - a.total || a.name.localeCompare(b.name, "zh-CN"), + ), + }; + }) + .sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, "zh-CN")); +} +export function inventorySelection( + row: RegionalInventoryStats, + category: VehicleModalCategory = "Inventory", +): VehicleModalSelection { + return { + batch: "All", + source: "asset", + category, + brand: row.brand, + model: row.model, + location: row.city, + type: row.type, + }; +} + +export const INVENTORY_TYPES = [ + "4.5T普货", + "4.5T冷链", + "18T", + "49T", + "其他", +] as const; +export function inventoryColumn(type: string) { + return INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number]) + ? type + : "其他"; +} +export function inventoryTotals(rows: RegionalInventoryStats[]) { + const result = emptyInventoryCounts(); + for (const row of rows) { + for (const key of [ + "quantity", + "available", + "awaiting", + "abnormal", + "pending", + ] as const) + result[key] += row[key] || 0; + result.unclassified += + row.unclassified ?? + Math.max(0, row.quantity - (row.available || 0) - (row.awaiting || 0)); + for (const [type, count] of Object.entries(row.abnormalTypes || {})) + result.abnormalTypes[type] = (result.abnormalTypes[type] || 0) + count; + } + return result; +} +/** Display labels never replace the original model used for exact drill-down. */ +export function inventoryModel(model: string, brand: string) { + const parentheses = [...model.matchAll(/[((]([^))]+)[))]/g)].map( + (match) => match[1], + ); + const colorOnly = /^(?:红|白|蓝|绿|灰|黑|黄|银|蓝白绿)(?:色|车)?$/; + const operator = parentheses + .filter((value) => !colorOnly.test(value)) + .map((value) => value.replace(/[--](?:红|白|蓝白绿)(?:色|车)?$/, "")) + .join(" · "); + let name = model.replace(/[((][^))]*[))]/g, "").trim(); + for (const label of [ + brand, + "现代", + "帕力安牌", + "宇通", + "飞驰", + "苏龙", + "跃进", + "楚风", + ]) { + if (label && name.startsWith(label)) + name = name.slice(label.length).replace(/^-/, ""); + if (label && name.endsWith(label)) name = name.slice(0, -label.length); + } + return { name: name || model, operator }; +} +export interface InventoryScope { + rows: RegionalInventoryStats[]; + label: string; + initialCategory?: "Abnormal" | "Pending"; +} +export function scopedInventorySelection( + scope: InventoryScope, + extra: Partial = {}, +): VehicleModalSelection { + return { + batch: "All", + model: "All", + location: "All", + source: "asset", + category: "Inventory", + inventoryKeys: scope.rows.map(inventoryKey), + scopeLabel: scope.label, + ...extra, + }; +} +export function matchesInventorySelection( + vehicle: VehicleListItem, + selection: VehicleModalSelection, +) { + return ( + (!selection.inventoryKeys || + selection.inventoryKeys.includes(vehicle.inventoryKey || "")) && + (!selection.inventoryOperation || + inventoryOperation(vehicle.operationStatus) === + selection.inventoryOperation) && + (!selection.inventoryColor || + inventoryColor(vehicle.color) === + inventoryColor(selection.inventoryColor)) && + (!selection.abnormalType || + (vehicle.abnormalMove?.type || "未提供") === selection.abnormalType) + ); +} diff --git a/src/modules/assets/library/shared.tsx b/src/modules/assets/library/shared.tsx new file mode 100644 index 0000000..3868bcc --- /dev/null +++ b/src/modules/assets/library/shared.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { ArrowUpRight, SlidersHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useDemoMode } from "../../../components/Blur"; +import type { VehicleModalSelection } from "../model"; +export type OpenVehicles = (selection: VehicleModalSelection) => void; +export const baseSelection: VehicleModalSelection = { + batch: "All", + model: "All", + location: "All", + source: "asset", +}; +export const unique = (values: (string | null | undefined)[]) => + [...new Set(values.filter((v): v is string => !!v))].sort(); + +/** 区块级 Tab 统一样式:模块级与卡片内嵌 Tab 共用,避免多套视觉。 */ +export const sectionTabsListClass = + "w-full min-h-11 bg-[var(--muted)] md:w-fit"; +export const sectionTabsTriggerClass = + "min-h-11 px-4 data-[state=active]:text-[var(--blue)] dark:data-[state=active]:text-[var(--cyan)]"; + +/** 数字滚动动画:目标值变化时从当前值缓动到新值;尊重 reduced-motion。 */ +export function useCountUp(target: number, duration = 800) { + const [value, setValue] = useState(0); + const valueRef = useRef(0); + const rafRef = useRef(0); + useEffect(() => { + const from = valueRef.current; + if (from === target) return undefined; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + valueRef.current = target; + setValue(target); + return undefined; + } + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - Math.pow(1 - t, 3); + const current = Math.round(from + (target - from) * eased); + valueRef.current = current; + setValue(current); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, [target, duration]); + return value; +} + +/** 占比条:用于 KPI 卡片内的构成占比(如 租赁/自营)。 */ +export function ProportionBar({ + segments, + label, +}: { + segments: { value: number; className: string; title: string }[]; + label: string; +}) { + const total = segments.reduce((n, s) => n + s.value, 0); + return ( +
+ {total > 0 && + segments + .filter((s) => s.value > 0) + .map((s) => ( +
+ ))} +
+ ); +} +export function PrivateText({ children }: { children: ReactNode }) { + return <>{useDemoMode() ? "••••••" : children || "—"}; +} +export function Count({ + value, + onClick, + label, +}: { + value: number; + onClick: () => void; + label?: string; +}) { + if (!value) + return ( + + 0 + + ); + return ( + + ); +} +export function Picker({ + label, + value = "", + values, + change, +}: { + label: string; + value?: string; + values: string[]; + change: (value: string) => void; +}) { + return ( + + {label} + + + ); +} +export function FilterFields({ + fields, + onReset, +}: { + fields: Parameters[0][]; + onReset: () => void; +}) { + const active = fields.filter((field) => field.value); + return ( + + + + + + 筛选条件 + {active.length ? ( + active.map((field) => ( + + {field.label}:{field.value} + + )) + ) : ( + + 未设条件 + + )} + + + +
+ {fields.map((field) => ( + + ))} + +
+
+
+
+ ); +} +export function Failure({ + message, + retry, +}: { + message: string; + retry?: () => void; +}) { + return ( + + 数据加载失败 + + {message} + {retry && ( + + )} + + + ); +} +export function useMobile() { + const [mobile, setMobile] = useState( + () => window.matchMedia("(max-width: 767px)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(max-width: 767px)"); + const update = () => setMobile(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + return mobile; +} diff --git a/src/modules/assets/library/useAssetHash.ts b/src/modules/assets/library/useAssetHash.ts new file mode 100644 index 0000000..8091dbc --- /dev/null +++ b/src/modules/assets/library/useAssetHash.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +const TAB_IDS = ["overview", "department", "region", "customer"] as const; +const SECTION_IDS = ["inventory", "models", "flow"] as const; + +export type AssetTab = (typeof TAB_IDS)[number]; +export type AssetSection = (typeof SECTION_IDS)[number]; + +interface AssetHashState { + tab: AssetTab; + section: AssetSection; +} + +const DEFAULT_STATE: AssetHashState = { tab: "overview", section: "inventory" }; + +const isTab = (value: string | undefined): value is AssetTab => + Boolean(value) && (TAB_IDS as readonly string[]).includes(value as string); +const isSection = (value: string | undefined): value is AssetSection => + Boolean(value) && (SECTION_IDS as readonly string[]).includes(value as string); + +function parseHash(): AssetHashState { + const [first, second, third] = window.location.hash.slice(1).split("/"); + if (first !== "assets") return DEFAULT_STATE; + return { + tab: isTab(second) ? second : "overview", + section: isSection(third) ? third : "inventory", + }; +} + +function writeHash(tab: AssetTab, section: AssetSection) { + const parts = ["assets", tab]; + // 默认子页签不写入,保持 `#assets/` 简洁。 + if (tab === "overview" && section !== "inventory") parts.push(section); + const hash = `#${parts.join("/")}`; + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}${hash}`, + ); +} + +/** + * 资产模块页签状态 ↔ URL hash 二级/三级段(`#assets/[/
]`)。 + * 默认值不写入 hash,刷新可恢复,链接可分享;与 Shell 的一级模块段兼容。 + */ +export function useAssetHash() { + const [state, setState] = useState(parseHash); + + useEffect(() => { + const onChange = () => setState(parseHash()); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + + const update = (next: Partial) => { + const merged = { ...state, ...next }; + writeHash(merged.tab, merged.section); + setState(merged); + }; + + return [state, update] as const; +} diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index a80ad10..42b3582 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -58,6 +58,12 @@ export type VehicleModalCategory = | 'Operating'; export interface VehicleModalSelection { + inventoryKeys?: string[]; + inventoryOperation?: "available" | "awaiting" | "unclassified"; + inventoryColor?: string; + abnormalType?: string; + scopeLabel?: string; + brand?: string; batch: string; model: string; location: string; @@ -75,6 +81,7 @@ export interface VehicleModalSelection { } export interface VehicleListRequestParams { + brand?: string; batch?: string; model?: string; location?: string; @@ -174,6 +181,7 @@ export function buildVehicleModalRequest( } const params: VehicleListRequestParams = {}; + if (selection.brand) params.brand = selection.brand; if (selection.vehicleType) params.vehicleType = selection.vehicleType; if (selection.batch !== 'All') params.batch = selection.batch; if (selection.model !== 'All') params.model = selection.model; diff --git a/src/modules/assets/types.ts b/src/modules/assets/types.ts index dbba45e..52c82b7 100644 --- a/src/modules/assets/types.ts +++ b/src/modules/assets/types.ts @@ -1,3 +1,4 @@ +import type { InventoryCounts } from '../../shared/assets/inventory'; import type { AbnormalMoveDetail } from '../../shared/assets/abnormal-move'; export interface SummaryData { @@ -103,6 +104,9 @@ export interface InventoryTypeSummary { } export interface VehicleListItem { + color?: string; + operationStatus?: string | null; + inventoryKey?: string; pendingDelivery?: { contractNo: string | null; customerName: string | null } | null; abnormalMove?: AbnormalMoveDetail | null; id: number; @@ -126,6 +130,11 @@ export interface VehicleListItem { } export interface RegionalInventoryStats { + available?: number; + awaiting?: number; + unclassified?: number; + abnormalTypes?: Record; + variants?: Record; abnormal?: number; pending?: number; region: string; diff --git a/src/modules/energy/SubTabs.tsx b/src/modules/energy/SubTabs.tsx index ff10646..63904d9 100644 --- a/src/modules/energy/SubTabs.tsx +++ b/src/modules/energy/SubTabs.tsx @@ -15,7 +15,7 @@ interface Props { export default function SubTabs({ tabs, active, onChange }: Props) { return ( -
+
); diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index 9466e5f..06a0a36 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -30,7 +30,7 @@ export default function MileageModule() { compactInfo hideHeader > -
+
diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index a51906a..9e42798 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -1,3 +1,4 @@ +import { inventoryKey, inventoryOperation, inventoryColor, emptyInventoryCounts, type InventoryCounts } from '../../shared/assets/inventory.js'; import { Hono } from 'hono'; import type { Vehicle, @@ -306,24 +307,8 @@ app.get('/region-stats', async (c) => { })) .sort((a, b) => b.totalAssets - a.totalAssets); - // Top 8 cities + merge rest into "其他" - const topCities = allCities.slice(0, 8); - const restCities = allCities.slice(8); - if (restCities.length > 0) { - const restVehicles = restCities.flatMap((c) => { - const key = c.city; - return cityMap.get(key) || []; - }); - topCities.push({ - city: '其他', - totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0), - operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0), - pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0), - customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[], - typeBreakdown: getTypeBreakdown(restVehicles), - }); - } - const cities = topCities; + // The UI uses collapsible city sections, so keep actual cities addressable for drill-down. + const cities = allCities; return { region, @@ -372,9 +357,11 @@ app.get('/customer-stats', async (c) => { // GET /api/vehicles/list — flat list with optional filters app.get('/list', async (c) => { const vehicles = await getVehiclesForUser(c); - const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); + const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); - let filtered = vehicles; + let filtered = c.req.query('source') === 'region' + ? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v)) + : vehicles; // attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles) if (attendance === 'active' || attendance === 'idle') { @@ -389,7 +376,9 @@ app.get('/list', async (c) => { } } if (vehicleType) { - if (VEHICLE_TYPE_FILTERS[vehicleType]) { + if (vehicleType === '区域其他' && c.req.query('source') === 'region') { + filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type)); + } else if (VEHICLE_TYPE_FILTERS[vehicleType]) { filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]); } else if (vehicleType === '4.5T') { filtered = filtered.filter((v) => v.type === '4.5T'); @@ -397,6 +386,9 @@ app.get('/list', async (c) => { filtered = filtered.filter((v) => v.type === vehicleType); } } + if (brand) { + filtered = filtered.filter((v) => (v.brandLabel || '未知') === brand); + } if (batch && batch !== 'All') { filtered = filtered.filter((v) => (v.contractNo || '未知') === batch); } @@ -450,6 +442,9 @@ app.get('/list', async (c) => { type: v.type, model: v.model, location: v.location, + color: v.color, + operationStatus: v.operationStatus, + inventoryKey: inventoryKey(inventoryDimensions(v)), province: v.province, city: v.city, status: v.status, @@ -468,44 +463,34 @@ app.get('/list', async (c) => { ); }); -// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 +// Inventory keeps its existing aggregate visibility; vehicle details retain user permissions. +const INVENTORY_TYPE_NAMES: Record = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' }; +function inventoryDimensions(v: Vehicle) { + return { region: mapMacroRegion(v.province, v.city), city: resolveCity(v.city, v.province), brand: v.brandLabel || '未知', type: INVENTORY_TYPE_NAMES[classifyVehicleType({ ...v, model: v.model || "" })], model: v.model || "未标注型号" }; +} app.get('/inventory-stats', async (c) => { const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); - const inventory = vehicles.filter((v) => isInventoryVehicle(v)); - - const TYPE_NAME_MAP: Record = { - t4_5: '4.5T普货', - t4_5c: '4.5T冷链', - t18: '18T', - t49: '49T', - trailer: '挂车', - other: '其他', - }; - - const groups = new Map(); - for (const v of inventory) { - const typeCategory = classifyVehicleType(v); - const typeName = TYPE_NAME_MAP[typeCategory]; - const region = mapMacroRegion(v.province, v.city); - const city = resolveCity(v.city, v.province); - const brand = v.brandLabel || '未知'; - const model = v.model; - const key = `${region}|${city}|${brand}|${typeName}|${model}`; - const counts = groups.get(key) || { quantity: 0, abnormal: 0, pending: 0 }; - counts.quantity += 1; - if (isInventoryAbnormal(v)) counts.abnormal += 1; - if (isInventoryPending(v)) counts.pending += 1; - groups.set(key, counts); + const groups = new Map & InventoryCounts & { batch: string; variants: Record }>(); + for (const v of vehicles.filter(isInventoryVehicle)) { + const dimensions = inventoryDimensions(v); + const key = inventoryKey(dimensions); + const row = groups.get(key) || { ...dimensions, batch: v.model, ...emptyInventoryCounts(), variants: {} }; + const color = inventoryColor(v.color); + const variant = row.variants[color] || emptyInventoryCounts(); + for (const count of [row, variant]) { + count.quantity += 1; + count[inventoryOperation(v.operationStatus)] += 1; + if (isInventoryPending(v)) count.pending += 1; + if (isInventoryAbnormal(v)) { + count.abnormal += 1; + const type = v.abnormalMove?.type || '未提供'; + count.abnormalTypes[type] = (count.abnormalTypes[type] || 0) + 1; + } + } + row.variants[color] = variant; + groups.set(key, row); } - - const result = Array.from(groups.entries()) - .map(([key, counts]) => { - const [region, city, brand, type, model] = key.split('|'); - return { region, city, brand, type, model, batch: model, ...counts }; - }) - .sort((a, b) => b.quantity - a.quantity); - - return c.json(result); + return c.json([...groups.values()].sort((a, b) => b.quantity - a.quantity)); }); // GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending @@ -524,7 +509,7 @@ app.get('/weekly-detail', async (c) => { const hasModelFilter = model && model !== 'All'; const hasBatchFilter = batch && batch !== 'All'; const hasLocationFilter = location && location !== 'All'; - if (hasModelFilter || hasBatchFilter || hasLocationFilter) { + if (hasModelFilter || hasBatchFilter || hasLocationFilter || getSubjectParam(c)) { const vehicles = await getVehiclesForUser(c); let pool2 = vehicles; if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model); diff --git a/src/server/routes/vehicles/drilldown.test.ts b/src/server/routes/vehicles/drilldown.test.ts new file mode 100644 index 0000000..4ef9911 --- /dev/null +++ b/src/server/routes/vehicles/drilldown.test.ts @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import app from '../vehicles.js'; +import { vehicleRepository } from './repository.js'; +import type { Vehicle } from '../../types.js'; + +// Repository stubs keep this regression entirely offline. +test('asset drill-down preserves company, region scope, vehicle type and actual cities', async (t) => { + const make = (id: string, overrides: Partial = {}): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: id, type: '4.5T', model: '测试普货', color: '白', + location: '嘉兴', region: '嘉兴', province: '浙江省', city: `测试城市${id}`, + status: 'Operating', operationStatus: '1', ownership: 'Rental', rentCompany: '', + contractNo: null, customerName: null, orgName: null, departmentName: null, + subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null, brandLabel: null, + ...overrides, + }); + const vehicles = Array.from({ length: 9 }, (_, i) => make(String(i + 1))); + vehicles.push(make('10', { operationStatus: '3', status: 'Inventory' })); + vehicles.push(make('11', { type: '挂车', model: '测试挂车' })); + vehicles.push(make('12', { subjectOrg: '公司乙' })); + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + t.mock.method(vehicleRepository, 'getWeeklyDetailRows', async () => [ + { truck_id: '1', plate_number: 'TEST-1', handover_date: null, contract_type: null, customer_name: null }, + { truck_id: '12', plate_number: 'TEST-12', handover_date: null, contract_type: null, customer_name: null }, + ]); + const read = async (path: string) => { + const response = await app.request(path); + assert.equal(response.status, 200); + return response.json(); + }; + const regions = await read('/region-stats?subject=公司甲'); + assert.equal(regions[0].totalAssets, 10); + assert.equal(regions[0].cities.length, 10, 'cities beyond the first eight stay individually addressable'); + const total = await read('/list?source=region&location=华东&subject=公司甲'); + assert.equal(total.length, regions[0].totalAssets); + assert.ok(total.every((v: Vehicle) => v.id !== '10' && v.id !== '12')); + const light = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=4.5T'); + assert.equal(light.length, 9); + const other = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=区域其他'); + assert.deepEqual(other.map((v: Vehicle) => v.id), ['11']); + const weekly = await read('/weekly-detail?type=delivered&subject=公司甲'); + assert.deepEqual(weekly.map((v: {truck_id: string}) => v.truck_id), ['1']); +}); + +test('inventory brand drill-down matches each same-model row, including unknown brand', async (t) => { + const make = (id: string, brandLabel: string | null, subjectOrg = '公司甲'): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: id, type: '挂车', model: '挂车', color: '白', + location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', + status: 'Inventory', operationStatus: '3', ownership: 'Rental', rentCompany: '', + contractNo: null, customerName: null, orgName: null, departmentName: null, + subjectOrg, projectName: null, customerManager: null, managerId: null, brandLabel, + }); + t.mock.method(vehicleRepository, 'getVehicles', async () => [make('1', '明威'), make('2', '明威'), make('3', '万风'), make('4', null), make('5', '明威', '公司乙')]); + const summary = await (await app.request('/inventory-stats?subject=公司甲')).json(); + for (const row of summary) { + const query = new URLSearchParams({ source: 'asset', subject: '公司甲', category: 'Inventory', model: row.model, location: row.city, vehicleType: row.type, brand: row.brand }); + const result = await app.request(`/list?${query}`); + assert.equal(result.status, 200); + const detail = await result.json(); + assert.equal(detail.length, row.quantity); + assert.ok(detail.every((vehicle: { brandLabel: string | null }) => (vehicle.brandLabel || '未知') === row.brand)); + } +}); + +test('inventory composition and color variants reconcile with permission-filtered list keys', async (t) => { + const make = (id: string, operationStatus: string, overrides: Partial = {}): Vehicle => ({ + id,plateNumber:`TEST-${id}`,vin:id,type:'18T',model:'现代18T双飞翼(羚牛)',color:'白',location:'嘉兴',region:'嘉兴',province:'浙江省',city:'嘉兴市',status:'Inventory',operationStatus,ownership:'Self',rentCompany:'',contractNo:null,customerName:null,orgName:null,departmentName:null,subjectOrg:'公司甲',projectName:null,customerManager:null,managerId:null,brandLabel:'帕力安牌',...overrides, + }); + t.mock.method(vehicleRepository,'getVehicles',async () => [make('1','3'),make('2','可运营',{status:'Abnormal',abnormalMove:{type:'维修'} as Vehicle['abnormalMove']}),make('3','4',{status:'Pending',color:'红'}),make('4','待运营'),make('5','1'),make('6','2'),make('7','3',{subjectOrg:'公司乙'})]); + const stats = await (await app.request('/inventory-stats?subject=公司甲')).json(); + assert.equal(stats.length,1); + const row = stats[0]; + assert.equal(row.quantity,4); assert.equal(row.available,2); assert.equal(row.awaiting,2); assert.equal(row.unclassified,0); + assert.equal(row.abnormal,1); assert.equal(row.pending,1); assert.deepEqual(row.abnormalTypes,{维修:1}); + assert.equal(row.variants['白色'].quantity,3); assert.equal(row.variants['红色'].quantity,1); + const list = await (await app.request('/list?subject=公司甲&category=Inventory')).json(); + assert.equal(list.length,4); + assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model]))); + assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1); +}); diff --git a/src/shared/assets/inventory.ts b/src/shared/assets/inventory.ts new file mode 100644 index 0000000..65f6e6b --- /dev/null +++ b/src/shared/assets/inventory.ts @@ -0,0 +1,44 @@ +/** Stable aggregate identity shared by summary and permission-filtered detail. */ +export function inventoryKey(row: { + region: string; + city: string; + brand: string; + type: string; + model: string; +}) { + return JSON.stringify([row.region, row.city, row.brand, row.type, row.model]); +} +export function inventoryOperation( + status?: string | null, +): "available" | "awaiting" | "unclassified" { + if (["3", "可运营"].includes((status || "").trim())) return "available"; + if (["4", "待运营"].includes((status || "").trim())) return "awaiting"; + return "unclassified"; +} +export interface InventoryCounts { + quantity: number; + available: number; + awaiting: number; + unclassified: number; + abnormal: number; + pending: number; + abnormalTypes: Record; +} +export const emptyInventoryCounts = (): InventoryCounts => ({ + quantity: 0, + available: 0, + awaiting: 0, + unclassified: 0, + abnormal: 0, + pending: 0, + abnormalTypes: {}, +}); + +/** Normalize only known color aliases; preserve meaningful multi-color descriptions. */ +export function inventoryColor(value?: string | null): string { + const color = value?.trim() || ""; + if (!color) return "未标注颜色"; + return /^(红|白|黑|蓝|绿|灰|黄|银|橙)(色|车)?$/.test(color) + ? `${color[0]}色` + : color; +} diff --git a/src/vendor/apple-design/tokens.css b/src/vendor/apple-design/tokens.css new file mode 100644 index 0000000..8be959e --- /dev/null +++ b/src/vendor/apple-design/tokens.css @@ -0,0 +1,239 @@ +/* ============================================================================ + Apple Design Tokens — drop-in theme layer for the web + ---------------------------------------------------------------------------- + Real Apple system values (colors, type, spacing, radii, materials, motion) + expressed as CSS custom properties. Import once at the root of your app: + + @import "tokens.css"; + + Everything below is light-mode default; dark mode is applied automatically + via prefers-color-scheme AND an explicit [data-theme="dark"] override so you + can force a theme. Semantic tokens (--label, --bg, --fill-*) are what you + should reference in components — never hardcode a hex. + ============================================================================ */ + +:root { + /* ---- Type: system font stacks -------------------------------------- */ + /* -apple-system/BlinkMacSystemFont pull SF Pro on Apple platforms; the + rest degrade gracefully. "SF Pro Display" vs "Text" is handled by the OS + via optical sizing — we opt in with font-optical-sizing below. */ + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", + "Helvetica Neue", "Segoe UI", system-ui, sans-serif; + --font-rounded: "SF Pro Rounded", -apple-system, "Nunito", system-ui, sans-serif; + --font-serif: "New York", ui-serif, Georgia, "Times New Roman", serif; + --font-mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace; + + /* ---- Type scale: Apple's text styles, in rem (1rem = 16px base) ----- + Each style is a *set*: size + weight + tracking + leading. See + references/typography.md. Tracking tightens as size grows; leading loosens + as size shrinks. Values are Apple's, converted pt→rem at 16px root. */ + --text-large-title: 2.125rem; /* 34pt */ + --text-title-1: 1.75rem; /* 28pt */ + --text-title-2: 1.375rem; /* 22pt */ + --text-title-3: 1.25rem; /* 20pt */ + --text-headline: 1.0625rem; /* 17pt, semibold */ + --text-body: 1.0625rem; /* 17pt */ + --text-callout: 1rem; /* 16pt */ + --text-subhead: 0.9375rem; /* 15pt */ + --text-footnote: 0.8125rem; /* 13pt */ + --text-caption-1: 0.75rem; /* 12pt */ + --text-caption-2: 0.6875rem; /* 11pt */ + + --tracking-tight: -0.022em; /* large display text */ + --tracking-snug: -0.014em; /* titles */ + --tracking-normal: 0em; /* body */ + --tracking-wide: 0.01em; /* small caps / footnotes */ + + --leading-tight: 1.05; /* headlines */ + --leading-snug: 1.2; + --leading-normal: 1.35; /* body-ish */ + --leading-relaxed: 1.5; /* long-form reading */ + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* ---- Spacing: 4/8pt grid ------------------------------------------- */ + --space-1: 0.125rem; /* 2 */ + --space-2: 0.25rem; /* 4 */ + --space-3: 0.5rem; /* 8 */ + --space-4: 0.75rem; /* 12 */ + --space-5: 1rem; /* 16 */ + --space-6: 1.25rem; /* 20 */ + --space-7: 1.5rem; /* 24 */ + --space-8: 2rem; /* 32 */ + --space-9: 2.5rem; /* 40 */ + --space-10: 3rem; /* 48 */ + --space-12: 4rem; /* 64 */ + + /* ---- Radii: Apple leans on generous, continuous corners ------------ + iOS uses "continuous" curvature (a squircle), not a plain arc. On the web + you approximate it with a slightly larger border-radius; for hero elements + use the squircle recipe in references/tokens.md. Capsule = fully rounded. */ + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 22px; /* iOS app-icon / large card feel */ + --radius-2xl: 28px; + --radius-capsule: 999px; + + /* ---- Elevation: soft, layered, low-contrast shadows ---------------- + Apple shadows are diffuse and never harsh. Two-layer: a tight contact + shadow + a soft ambient one. Tune opacity down in dark mode. */ + --shadow-1: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.06); + --shadow-2: 0 2px 8px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.08); + --shadow-3: 0 8px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.08); + --shadow-4: 0 16px 48px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.10); + --shadow-glass: 0 8px 32px rgba(0,0,0,0.18); /* floating glass chrome */ + + /* ---- Motion: easing + duration tokens ------------------------------ + Apple UI never uses the weak built-in ease-*. These curves are the ones + Apple's own interfaces and the best web recreations use. Springs cannot be + expressed in pure CSS — see references/motion-physics.md for the JS tokens + (damping/response) consumed by Motion and GSAP. NEVER use ease-in on UI: + it starts slow and feels laggy. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* default UI exit-of-motion */ + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* symmetric moves */ + --ease-ios: cubic-bezier(0.32, 0.72, 0, 1); /* sheets/drawers (iOS) */ + --ease-snappy: cubic-bezier(0.4, 0, 0.2, 1); /* quick, neutral */ + + --duration-instant: 100ms; /* button press / tap feedback */ + --duration-fast: 200ms; /* tooltips, small popovers */ + --duration-base: 300ms; /* dropdowns, most UI (keep UI under 300ms) */ + --duration-slow: 450ms; /* modals, sheets, drawers */ + + /* ---- Semantic colors: LIGHT (defaults) ----------------------------- */ + /* Accents (Apple system colors) */ + --blue: #007AFF; --green: #34C759; --indigo: #5856D6; --orange: #FF9500; + --pink: #FF2D55; --purple: #AF52DE; --red: #FF3B30; --teal: #30B0C7; + --cyan: #32ADE6; --yellow: #FFCC00; --mint: #00C7BE; --brown: #A2845E; + --accent: var(--blue); + + /* Grays */ + --gray-1: #8E8E93; --gray-2: #AEAEB2; --gray-3: #C7C7CC; + --gray-4: #D1D1D6; --gray-5: #E5E5EA; --gray-6: #F2F2F7; + + /* Backgrounds (base → elevated) */ + --bg: #FFFFFF; + --bg-secondary: #F2F2F7; + --bg-tertiary: #FFFFFF; + --bg-grouped: #F2F2F7; /* settings-style grouped lists */ + + /* Labels (text) — Apple encodes hierarchy as opacity over a base ink */ + --label: rgba(0, 0, 0, 1); + --label-secondary: rgba(60, 60, 67, 0.60); + --label-tertiary: rgba(60, 60, 67, 0.30); + --label-quaternary: rgba(60, 60, 67, 0.18); + + /* Separators & fills */ + --separator: rgba(60, 60, 67, 0.29); + --separator-opaque: #C6C6C8; + --fill-1: rgba(120, 120, 128, 0.20); /* thick control fill */ + --fill-2: rgba(120, 120, 128, 0.16); + --fill-3: rgba(120, 120, 128, 0.12); + --fill-4: rgba(120, 120, 128, 0.08); /* thin, e.g. hover */ + + /* ---- Materials: the tint + blur pair per thickness ------------------ + A "material" is a translucent tint color paired with a backdrop blur. Use + --material-*-tint as background and --material-*-blur inside backdrop-filter. + See references/liquid-glass.md for the full layered recipe (specular + rim). */ + --material-ultrathin-tint: rgba(255, 255, 255, 0.44); + --material-thin-tint: rgba(255, 255, 255, 0.60); + --material-regular-tint: rgba(255, 255, 255, 0.72); + --material-thick-tint: rgba(255, 255, 255, 0.84); + --material-blur: 20px; + --material-blur-thin: 12px; + --material-saturate: 180%; + + color-scheme: light dark; +} + +/* ============================================================================ + DARK MODE — auto via prefers-color-scheme, plus [data-theme] overrides so + you can force light/dark regardless of OS setting. + ============================================================================ */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --blue: #0A84FF; --green: #30D158; --indigo: #5E5CE6; --orange: #FF9F0A; + --pink: #FF375F; --purple: #BF5AF2; --red: #FF453A; --teal: #40C8E0; + --cyan: #64D2FF; --yellow: #FFD60A; --mint: #63E6E2; --brown: #AC8E68; + + --gray-1: #8E8E93; --gray-2: #636366; --gray-3: #48484A; + --gray-4: #3A3A3C; --gray-5: #2C2C2E; --gray-6: #1C1C1E; + + --bg: #000000; + --bg-secondary: #1C1C1E; + --bg-tertiary: #2C2C2E; + --bg-grouped: #000000; + + --label: rgba(255, 255, 255, 1); + --label-secondary: rgba(235, 235, 245, 0.60); + --label-tertiary: rgba(235, 235, 245, 0.30); + --label-quaternary: rgba(235, 235, 245, 0.16); + + --separator: rgba(84, 84, 88, 0.60); + --separator-opaque: #38383A; + --fill-1: rgba(120, 120, 128, 0.36); + --fill-2: rgba(120, 120, 128, 0.32); + --fill-3: rgba(120, 120, 128, 0.24); + --fill-4: rgba(120, 120, 128, 0.18); + + --material-ultrathin-tint: rgba(30, 30, 32, 0.44); + --material-thin-tint: rgba(30, 30, 32, 0.62); + --material-regular-tint: rgba(30, 30, 32, 0.74); + --material-thick-tint: rgba(30, 30, 32, 0.86); + + --shadow-1: 0 1px 2px rgba(0,0,0,0.30); + --shadow-2: 0 2px 8px rgba(0,0,0,0.36); + --shadow-3: 0 8px 24px rgba(0,0,0,0.44); + --shadow-4: 0 16px 48px rgba(0,0,0,0.52); + --shadow-glass: 0 8px 32px rgba(0,0,0,0.50); + } +} + +/* Explicit dark override (force dark even in a light OS) */ +[data-theme="dark"] { + --blue:#0A84FF; --green:#30D158; --indigo:#5E5CE6; --orange:#FF9F0A; + --pink:#FF375F; --purple:#BF5AF2; --red:#FF453A; --teal:#40C8E0; + --cyan:#64D2FF; --yellow:#FFD60A; --mint:#63E6E2; --brown:#AC8E68; + --gray-1:#8E8E93; --gray-2:#636366; --gray-3:#48484A; + --gray-4:#3A3A3C; --gray-5:#2C2C2E; --gray-6:#1C1C1E; + --bg:#000; --bg-secondary:#1C1C1E; --bg-tertiary:#2C2C2E; --bg-grouped:#000; + --label:rgba(255,255,255,1); --label-secondary:rgba(235,235,245,0.6); + --label-tertiary:rgba(235,235,245,0.3); --label-quaternary:rgba(235,235,245,0.16); + --separator:rgba(84,84,88,0.6); --separator-opaque:#38383A; + --fill-1:rgba(120,120,128,0.36); --fill-2:rgba(120,120,128,0.32); + --fill-3:rgba(120,120,128,0.24); --fill-4:rgba(120,120,128,0.18); + --material-ultrathin-tint:rgba(30,30,32,0.44); --material-thin-tint:rgba(30,30,32,0.62); + --material-regular-tint:rgba(30,30,32,0.74); --material-thick-tint:rgba(30,30,32,0.86); +} + +/* ============================================================================ + BASE — sensible defaults so the tokens feel Apple-native immediately. + ============================================================================ */ +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: var(--font-sans); + font-optical-sizing: auto; /* SF switches Text↔Display by size */ + -webkit-font-smoothing: antialiased; /* thinner, Apple-like rendering */ + text-rendering: optimizeLegibility; + font-size: var(--text-body); + line-height: var(--leading-normal); + letter-spacing: var(--tracking-normal); + color: var(--label); + background: var(--bg); +} + +/* Accessibility: respect the three reduced-* preferences everywhere. */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/tsconfig.json b/tsconfig.json index 1a48f18..bae31d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noEmit": true, "allowImportingTsExtensions": true, "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": ["src"] diff --git a/vite.config.ts b/vite.config.ts index b12b351..1bc5fc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,10 @@ +import { fileURLToPath, URL } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, plugins: [react(), tailwindcss()], server: { proxy: {