Compare commits

...

160 Commits

Author SHA1 Message Date
kkfluous
6b75437423 Merge branch 'main' into demo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 解决 3 处冲突,保留 demo 的演示行为:
  - AuthProvider: 无 jumpToken 时放行
  - auth middleware: BYPASS_AUTH=true
  - Shell: DemoModeProvider enabled=true
- 引入 main 上的智能调度模块等改动
2026-04-24 10:37:54 +08:00
kkfluous
a472e543ce refactor(scheduling): gate access strictly on BI-SCHEDULE-OPT role
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Remove the implicit fallback that granted scheduling access to any
FULL_ACCESS role (所有权限 / 数智中心 / BI-Leader). Access now requires
an explicit BI-SCHEDULE-OPT assignment, so the module scope is managed
purely via role assignment rather than piggy-backing on admin roles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:50:48 +08:00
kkfluous
0c258dd1a2 fix(docker): copy src/shared into runtime image for server imports
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
src/server/auth/types.ts imports runtime values (role constants,
canAccessScheduling helper) from src/shared/auth/roles.ts — without
the shared folder in the final stage the server crashes with
ERR_MODULE_NOT_FOUND. Existing shared/scheduling imports survived
only because they were type-only and elided at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:44:49 +08:00
kkfluous
200172f0af feat(scheduling): role-based access + align list count with qualifiedCount
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Gate 智能调度 module on BI-SCHEDULE-OPT role (or full-access roles)
  via shared canAccessScheduling helper, replacing hardcoded userId allowlist
- Thread roles[] through JWT payload → middleware → frontend nav
- Add router guard that 403s non-authorized users on /api/scheduling/*
- Emit replace_qualified suggestion for every qualified vehicle so list
  count matches the 已完成考核目标 card; recalc qualifiedCount /
  hopelessCount post-permission-filter for card↔list consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:42:21 +08:00
kkfluous
a954fb90f6 refactor(scheduling): 考核剩余 → 年度考核剩余
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:04:08 +08:00
kkfluous
2ea00a5383 refactor(scheduling): 拆分 reason 区为 客户/车辆 两栏
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
详情页的指标区从单列两格网格改为 左:客户 / 右:车辆 两栏。客户日均归
左侧,考核剩余、日均需、年度完成率、可为新车贡献归右侧,便于一眼
识别数据归属。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:01:59 +08:00
kkfluous
cf138f67c0 refactor(scheduling): remove 7日 客户日均 趋势徽章
详情页和列表里的 ↗ 7日 +X% / ↘ 7日 -X% 徽章移除,客户日均只保留
30 日均这一项。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:00:10 +08:00
kkfluous
e32b0b58b3 fix(scheduling): 近7天 filter should be time-only, not exclude cancelled
Previously the toggle hid cancelled records, so users who clicked a
record timestamped within 7 days but later cancelled would see nothing.
Now 近7天 filters purely by createdAt; combine with status tabs to
narrow further.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:56:30 +08:00
kkfluous
9d1e8c4d30 feat(scheduling): enrich history records with customer/dept/manager + drill-in to swap plan
Each row in 调度记录 now shows 业务部门(简)/业务负责人/客户 beneath
the plate line, and is clickable to open the reusable SwapPreview
showing the full replacement plan (current mileage, 考核目标, 替换后预测).
Drill-in is only enabled when the suggestion is still in the active
scheduling view; the user can still 取消干预 from the preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:30:07 +08:00
kkfluous
ba1e0e9f16 feat(scheduling): add 近期已干预 summary card (last 7 days)
Restore 替换建议 card and add a new emerald 近期已干预 card. Clicking
opens the history modal pre-filtered to the last 7 days (excluding
cancelled) via a toggle chip users can switch off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:25:59 +08:00
kkfluous
1b2ad68743 fix(scheduling): enforce one active intervention per current vehicle
Business rule: a running vehicle can hold AT MOST ONE active (sent|executed)
intervention. Switching to a different candidate requires cancelling the
prior one first.

- Server: insertNotification dedup key changes from (suggestion_id,
  candidate_plate) to just suggestion_id; 409 response includes the blocking
  candidate plate
- Detail modal: shows a banner naming the locked candidate; non-active
  candidates render a disabled "该车已有其他干预,请先解除" hint instead
  of the action button
- Batch: pickBestCandidate returns null for any suggestion already holding
  an active intervention — the whole suggestion is excluded

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:19:43 +08:00
kkfluous
210db7f8ff feat(scheduling): rename 通知→干预, allow drill-in on intervened items
- Globally rename user-facing 通知 → 干预 (list badge, detail button, batch
  modal, CSV header, server response messages, db table comment)
- 已干预 row in detail is now clickable — opens SwapPreview which shows
  a read-only summary plus a 取消干预 action (PATCH notify /:id with
  status=cancelled). Sending is blocked while already intervened.
- Selected suggestion now follows the latest data snapshot so status
  changes from within the detail flow propagate immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:14:53 +08:00
kkfluous
1d9f4cb43d feat(scheduling): history view, execute/cancel lifecycle, CSV export, 7d trend
- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with
  after-mileage + notes) and 取消 for open records
- Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate
  per row picked by same-region > can-qualify preference
- Compute customer 7-day average alongside 30-day baseline in a single query;
  show trend indicator (up/down/flat) next to 客户日均 in list and detail card

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:47:31 +08:00
kkfluous
3ef0d4edfa feat(scheduling): persist notifications, batch notify flow, dedup protection
- Add tab_scheduling_notifications table with bootstrap via ensureSchedulingTables()
- Notify endpoint rewritten: dedup by (suggestion_id, candidate_plate), history list, PATCH /:id for execute/cancel lifecycle
- Batch notify endpoint returns success/skipped/failed counts
- Suggestions response now carries notificationId + notificationStatus per candidate (joined from active-notification map)
- UI: select mode with checkboxes, floating action bar, confirmation modal listing each swap; already-notified items are dimmed and skipped
- Detail view badges show sent/executed state, preventing duplicate notify

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:43:21 +08:00
kkfluous
31716c6547 refactor(scheduling): shared types, structured reason, cross-region candidates
- Extract shared types to src/shared/scheduling/types.ts (client/server both re-export)
- Convert SchedulingSuggestion.reason from string to structured { lines, conclusion }
- Remove hard region filter; algorithm keeps cross-region candidates with isSameRegion flag
- SuggestionDetail renders same-region vs cross-region sections with a divider
- Close detail modal when selected suggestion no longer exists in data
- Unify estimatedGain definition (strict canQualifyAfterSwap) between algorithm and API layers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:36:38 +08:00
kkfluous
335282a2c3 feat(scheduling): add km unit to 客户日均; move 年度考核 to top-right of list item
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 23:24:25 +08:00
kkfluous
dfc32c4485 fix(scheduling): rename 日均→客户日均 to avoid ambiguity
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:07:49 +08:00
kkfluous
ceed067807 refactor(scheduling): re-layout list items — left group (dept/manager/customer) + right group (daily/rate)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:06:49 +08:00
kkfluous
2f11afc25f fix(scheduling): shorten department name by removing 业务 prefix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:05:40 +08:00
kkfluous
9f781c766a feat(scheduling): rename 完成→年度达标, add sort by 客户日均/年度达标 to list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:03:59 +08:00
kkfluous
8664317852 feat(scheduling): show department and manager in list items and detail card
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:02:03 +08:00
kkfluous
c3de4ebaf5 refactor(scheduling): redesign current vehicle card to match candidate card style
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 23:00:22 +08:00
kkfluous
aa9a29fed8 fix(scheduling): use each candidate's own daysLeft for prediction
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Different assessment targets have different end dates. Previously all
candidates used the current vehicle's daysLeft, causing wrong predictions.

Now each inventory vehicle computes its own daysLeft from its assessment
target's current_year_assessment_end_date. predictedAfterSwap uses the
candidate's own daysLeft instead of the current vehicle's.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:58:47 +08:00
kkfluous
a52a77f3a2 feat(scheduling): batch filter as multi-select pills instead of dropdown
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:54:01 +08:00
kkfluous
9012a955b8 feat(scheduling): add 剩余xx天 to candidate header
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:52:50 +08:00
kkfluous
caff78c5f3 fix(scheduling): rename 预估年终 to 考核期结束预估
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:51:37 +08:00
kkfluous
f6f872d2ce fix(scheduling): remove duplicate mileage text, keep only metrics row
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:50:42 +08:00
kkfluous
dbefb90089 feat(scheduling): add metrics row (当前/预估年终/考核) for current vehicle
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:49:43 +08:00
kkfluous
db568c1ebb fix(scheduling): rename 全部批次→全部, reorder metrics to 当前/替换后预计/考核
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:48:26 +08:00
kkfluous
ba6a38973d feat(scheduling): add batch filter and sort controls for candidate list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Batch dropdown filter (全部批次 / per target name)
- Sort by 替换后预计 (asc/desc toggle)
- Sort by 当前里程 (asc/desc toggle)
- Active sort button highlighted in blue
- Display count shows filtered/total (e.g. "3/12 辆")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:46:42 +08:00
kkfluous
7aa0d961ce feat(scheduling): show all candidates instead of top 5, update section title
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:44:51 +08:00
kkfluous
4f02a54d38 fix(scheduling): move region to header as pin badge, remove from metrics grid
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:39:31 +08:00
kkfluous
073496cd44 fix(scheduling): reorder candidate metrics to 区域/考核/当前/替换后预计, remove 缺口
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:38:25 +08:00
kkfluous
bea67b6710 fix(scheduling): remove decimals from km display (1,990.2 → 1,990)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:37:14 +08:00
kkfluous
d0984a430b refactor(scheduling): improve reason text, fix classification, polish detail view
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Classification: qualified requires actual completionRate >= 100% (not just predicted)
- Reason text: structured two-column layout (客户日均 | 考核周期剩余)
- Conclusion line in red bold (预估无法达标,需替换 / 已达标,建议换上未达标车辆)
- Remove verbose subtitle from candidate section
- Remove redundant middle line (预估考核期里程 vs 考核里程)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:32:50 +08:00
kkfluous
b3a6beb26b fix(scheduling): update card titles per user request
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:12:19 +08:00
kkfluous
0a7a9a096d refactor(scheduling): condense reason text to data-only one-liner
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:11:37 +08:00
kkfluous
dd03157804 refactor(scheduling): simplify card labels for clarity
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 22:09:27 +08:00
kkfluous
694e9a207a feat(scheduling): enable department/personal permission filtering
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Disable BYPASS_AUTH (was true, now false) — backend enforces JWT auth
- Scheduling suggestions filtered by department/manager permissions:
  - full: see all suggestions
  - department: see only own department's vehicles
  - personal: see only own managed vehicles
- Candidate vehicles (inventory) remain fully visible to all
- Summary recalculated after permission filtering
- Consistent with mileage module permission model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:04:52 +08:00
kkfluous
fb89c9beed fix(scheduling): change 辆 to 干预 in suggestion list
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-16 21:57:35 +08:00
kkfluous
75f0aca5d1 fix(auth): require jumpToken for access, remove temporary bypass
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Previously: no jumpToken → direct access allowed (临时放行)
Now: no jumpToken → show "请从业务系统跳转访问" unauthorized page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:48:29 +08:00
kkfluous
8598aea445 feat(scheduling): restrict scheduling module to allowed users only
Only userId 1105261382487539712 and 1116631120763437056 can see the
scheduling tab. Other users see only assets + mileage modules.

- Add userId to frontend AuthState.user type
- App.tsx conditionally includes scheduling module based on user ID
- Backend already returns userId in auth exchange response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:47:57 +08:00
kkfluous
25199b507c refactor(scheduling): simplify SwapPreview layout, remove verbose reason
- Remove type badge, reason section — too verbose
- Two clean white cards connected by arrow (swap diagram)
- Result section: predicted mileage, target, conclusion badge
- Tighter spacing, no redundant labels
- Professional tone, no childish wording

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:45:01 +08:00
kkfluous
6a3a5ba319 feat(scheduling): add full-screen SwapPreview for screenshot sharing
New SwapPreview component replaces direct "发送通知" button:
- Full-screen white background for clean screenshots
- Swap diagram: current vehicle → arrow → replacement vehicle
- Replacement reason section
- Post-swap prediction: predicted mileage, target, conclusion
- "发送替换通知" button at bottom
- Candidate button in detail modal changed to "查看替换方案 →"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:43:18 +08:00
kkfluous
0785c78382 fix(scheduling): only show candidates that can actually qualify after swap
replace_qualified (换下):
- Exclude already-qualified inventory (totalMileage >= yearTarget, gap=0)
- Only keep candidates where canQualifyAfterSwap=true
- Skip suggestions with no qualifiable candidates (e.g., too few days left)
- Reason text now shows customer's remaining capacity: "日均 318km × 53天 ≈ 1.7万km"

Before: showed 粤AGP9738 (缺口 0, already at target) — pointless
After: shows 粤AGQ5808 (缺口 1.7万, 换后 3.0万, 可达标) — meaningful

All replace_qualified candidates now guaranteed canQualifyAfterSwap=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:41:08 +08:00
kkfluous
afec75a1cc fix(scheduling): rescue candidates should be close-to-qualifying, not zero-mileage
For rescue_hopeless (换走) scenario, completely rethought candidate logic:

Before: showed biggest-gap candidates (0 mileage) → pointless, customer can't
  drive them to target
After: prioritize candidates where customer's remaining driving can push them
  over the target line (canQualifyAfterSwap), sorted by smallest gap first

Example: customer drives 178km/day × 57 days = ~1万km remaining.
- 粤AGR6869 (缺口 1990km) → 换后 3.8万, 可达标  (shown first)
- 浙FF58720 (缺口 6万km) → 换后 1万, 远不达标 (no longer shown first)

Also updated reason text to explain the math:
"该客户剩余57天还能跑约1万km,足以帮缺口小的车冲线"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:36:53 +08:00
kkfluous
1d1f8901aa fix(scheduling): exclude near-qualified vehicles from rescue candidates
For rescue_hopeless (换走) scenario, filter out inventory candidates
where totalMileage/yearTarget >= 80%. These are already near target
and swapping them in adds no value.

Instead, prioritize candidates with biggest mileage gaps — they benefit
most from accumulating any mileage, even at a low-mileage customer.

Before: showed 粤AGR6869 (93% done, 缺口 1990) as "可达标" — pointless
After:  shows 浙FF58720 (0% done, 缺口 60000) — genuinely needs mileage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:34:05 +08:00
kkfluous
81305be2df feat(scheduling): replace spinner with skeleton loading placeholders
- Full-page skeleton on initial load: card placeholders + list row placeholders
- List skeleton on refresh: 6 rows with pulse animation
- Skeleton blocks match actual layout (color bar, plate, badges, info line)
- Uses Tailwind animate-pulse for smooth loading effect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:30:23 +08:00
kkfluous
64f47d5ad6 fix(scheduling): truncate long customer names, prevent list item wrap
- Customer name in list items: truncate with max-w-[40%]
- Daily km and completion rate: flex-shrink-0 to stay on same line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:30 +08:00
kkfluous
9398688829 feat(scheduling): add department/manager filters, refine color palette
- Add department and manager fields to backend types and suggestions API
- Add department/manager to advanced filter panel
- Refine card colors: orange (换下) / blue (换走) / dark slate (全部)
- Selected card uses solid bg color, inactive uses gradient
- Batch pills use dark slate, confirm button uses dark slate
- Background changed to #F0F4F8 for subtle cool tone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:04 +08:00
kkfluous
48fa3bc73f refactor(scheduling): rewrite terminology to match core business logic
Core story: 里程高的车换下来,里程少的车换上去。

- Summary cards: "里程高·需换下" / "里程低·需换走" / "替换建议"
- List tags: "换下" (amber) / "换走" (blue) with matching color bars
- Detail modal title: "里程高·换下此车" / "里程低·换走此车"
- Candidate section: explains WHY these vehicles are recommended
  - 换下: "以下车辆里程缺口大,换到该高里程客户处可加速达标"
  - 换走: "以下车辆里程已充足,可调给当前客户,将此车换走给高里程客户冲刺"
- Reason text: states current situation + clear action recommendation
  with specific numbers (已跑, 缺口, 日均, 完成率)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:23:35 +08:00
kkfluous
1a5a1c1514 feat(scheduling): add advanced filter panel matching prototype
- Filter icon in list header with active count badge
- Expandable filter panel: plate search, region select, vehicle type select, customer select
- FilterSelect component with search for long option lists
- Active filter tags shown as removable pills below header
- Temp/confirmed filter pattern (edit → confirm/cancel)
- Result count displayed when filters active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:18:02 +08:00
kkfluous
73080a605d refactor(scheduling): polish overall color scheme and UX
- Modal header: unified dark slate-800 with directional icon (↓ rescue, ↑ release)
- Modal click-outside to close
- Candidate metrics: table-style with bg-slate-50 + dividers, more scannable
- Send button: dark slate instead of blue (avoids color overload)
- Reason section: warm amber accent
- Consistent font sizing and spacing throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:13:12 +08:00
kkfluous
6f7555a407 feat(scheduling): make summary cards clickable filters + refine color scheme
- Cards filter suggestions by type (已达标/无望达标/全部)
- Toggle: click active card again to reset to all
- Default: white bg + gray border; active: colored bg + ring
- Batch selector: dark pills instead of blue
- Refresh button moved into list header
- Reset type filter when switching batch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:12:02 +08:00
kkfluous
bcbeb64e28 fix(scheduling): use current year mileage for consistent data display
- Add currentYearMileage to SchedulingVehicleInfo (backend + frontend)
- Compute completionRate as currentYearMileage/yearTarget (year-based)
  instead of using overall completion_rate from DB
- Display "本年已跑" instead of "累计" in detail modal
- Fix reason text to show year completion rate

Before: 累计 4.6万, 考核 3.0万, 完成率 12.1% (mismatched periods)
After:  本年已跑 8.3万, 考核 3.0万, 完成率 275% (consistent year-based)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:08:29 +08:00
kkfluous
6ee811c937 refactor(scheduling): optimize UI for clarity and information density
- Summary cards: white bg + color border, remove icons, more compact
- SuggestionList: replace badge stacking with compact 2-line layout,
  use color bars for priority, fix completion rate format (0.16 → 16.4%)
- SuggestionDetail: bottom-sheet on mobile, compact inline metrics
  instead of grid cards, reduce vertical space per candidate
- Follows ui-ux-pro-max Data-Dense Dashboard guidelines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:04:26 +08:00
kkfluous
495f4bf44f feat(scheduling): add 本年考核 field to candidate cards and rename 年度目标 to 本年考核
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:01:01 +08:00
kkfluous
ec3b079311 fix(scheduling): only suggest replacements for rented/operated vehicles
Filter enriched vehicles to only include rent_status = '租赁' or '自营'.
Inventory candidates already filtered by truck_rent_status = 0 (在库).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:52:16 +08:00
kkfluous
033af15814 fix(scheduling): include soft-deleted trucks and infer type from target name
411 of 451 assessment vehicles had is_deleted=1 in tab_truck, causing type
classification to fall back to "其他" and miss all inventory matches. Fix:
- Remove is_deleted=0 filter from truck type query (assessment vehicles need type info regardless)
- Add inferTypeFromTargetName() fallback deriving type from target name when truck record is missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:47:00 +08:00
kkfluous
253cc2f2c0 fix(scheduling): fix vehicle type classification and algorithm candidate matching
- classifyVehicleType now parses dic_type.dic_name (e.g. "4.5吨冷链车") instead of raw model code
- Remove overly strict completionRate >= 0.8 filter for hopeless candidates
- Use vehicle's yearTarget as fallback when inventory has no assessment target
- Filter out suggestions with no candidates (not actionable)
- estimatedGain counts rescue_hopeless suggestions as potential gains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:31:44 +08:00
kkfluous
db5ca2e686 feat(scheduling): wire up scheduling module in app navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:26:53 +08:00
kkfluous
2e82a30893 feat(scheduling): add SuggestionDetail modal with candidate comparison
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:58 +08:00
kkfluous
9c005bebc8 feat(scheduling): add SuggestionList component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:21 +08:00
kkfluous
82ee7f5480 feat(scheduling): add SchedulingModule main entry component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:25:13 +08:00
kkfluous
4169e04a9c feat(scheduling): add suggestions route with data aggregation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:23:56 +08:00
kkfluous
86d5bc8738 feat(scheduling): add notify route and wire up scheduling router
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:23:29 +08:00
kkfluous
460c9906e1 feat(scheduling): add algorithm pure functions and export mapRegion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:21:50 +08:00
kkfluous
569b5ea349 feat(scheduling): add frontend types and API client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:23 +08:00
kkfluous
ebe46c6f73 feat(scheduling): add backend type definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:18 +08:00
kkfluous
32b297c731 docs: 智能调度模块实现计划
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:17:08 +08:00
kkfluous
9bf9bdd8ff docs: 智能调度模块设计规格
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:11:51 +08:00
kkfluous
188d2b105f fix: 关闭全局演示模糊模式,恢复车牌等敏感字段正常显示,版本号 1.1.5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Shell.tsx 的 DemoModeProvider 之前设为 enabled={true},Blur 组件包裹的
车牌/客户名/经理名全部被 blur(5px) 盖住。改为 false 恢复真实显示。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:59:58 +08:00
kkfluous
26d59190c9 feat: 小程序 webview 内点全屏监控自动 CSS 横屏,版本号 1.1.4
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
小程序 webview 无法调用系统旋转 API,竖屏全屏体验很差。检测到微信/抖音/
支付宝小程序 UA 且当前为竖屏时,全屏覆盖层用 transform: rotate(90deg)
配合 100vh × 100vw 的尺寸模拟真横屏,用户用横屏姿势看设备即可获得横屏
监控面板。浏览器会自动把触摸坐标映射回旋转前坐标系,交互不受影响。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:42:43 +08:00
kkfluous
f3b795e8a9 fix: 小程序/webview 内需点两次返回才能退出的问题,版本号 1.1.3
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Shell 首次挂载时用 location.hash= 同步模块到 URL hash,会 push 一条多余
的 history 记录;webview 里第一次返回只是回到 hash 为空的同一页面没有
视觉变化,得再按一次才能真正退出。

改为 history.replaceState 更新 hash,切换 tab 也走 replace,整个应用
只占用一个 history 记录,一次返回即可退出。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:13:01 +08:00
kkfluous
71ff459111 fix: 补提交缺失的 Blur 组件,修复 CI 打包找不到模块,版本号 1.1.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
AssetsModule 等引用 src/components/Blur 但该文件未纳入版本库导致 CI
rollup 报 Could not resolve "../../components/Blur"。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:53:57 +08:00
kkfluous
4acf10ef79 fix: 修复打包 lint 报错,版本号 1.1.1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
middleware.ts 临时跳过认证的早 return 导致后续代码 unreachable,
TS 在不可达分支里不做类型 narrowing 触发 TS18048;
改为 BYPASS_AUTH 常量分支保留完整鉴权逻辑便于恢复。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:53:01 +08:00
kkfluous
820fde5547 feat: 资产总览新增所属公司筛选,支持按归属主体过滤全页数据
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 后端:新增 /api/vehicles/subjects 端点返回公司列表+台数预览;所有聚合端点接受 ?subject= 参数按 tab_truck.org_id 对应的主体公司过滤
- 前端:标题下方新增 Scope Chip 单选下拉,支持搜索+台数预览,选中后全页 KPI/汇总/库存统计按公司联动刷新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:50:25 +08:00
kkfluous
d6c31dd2b6 fix: 实时监控累计总里程少算,G7S 数据源 total_km 为 NULL 时用业务库 vehicle_total_mileage 兜底
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:22:19 +08:00
kkfluous
8660c0d999 fix: 部门列表隐藏非业务员账号(超级用户/刘思宇/潘舒/黄卓华/许铮杰)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:48:07 +08:00
kkfluous
b4c4929dbb feat: 部门/业务负责人列表补齐无车辆业务员
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
/dept-stats 在按车辆聚合后,查询 tab_user 把业务部门内所有在职用户补进 managers 列表,无车辆显示为 0 辆。跳过公务车部门。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:44:01 +08:00
kkfluous
cf8f7cf969 feat(demo): 演示模式脱敏 + 临时跳过认证
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 Blur 组件与 DemoModeProvider,全局包裹 Shell
- 资产/里程模块的客户名、业务经理、车牌、VIN 等敏感字段使用 <Blur> 包裹
- 前端 AuthProvider 无 jumpToken 时放行
- 后端 authMiddleware 开头直接 next(),跳过所有认证

仅用于演示分支,勿合并至 main。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:40:58 +08:00
kkfluous
e4f682dff5 fix: 按部门下钻车辆数与列表不一致,补充 category=Operating 过滤
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
dept-stats 只统计 Operating 车辆,但下钻弹窗调用 /list 时未传 category
参数,导致返回了非运营状态车辆(如业务二部显示233但下钻显示237)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:32:43 +08:00
kkfluous
cbbdb7bb5f fix: 2-3字客户名脱敏改为 首字+* (如 徐*)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:24:43 +08:00
kkfluous
9c9d7a3805 feat: 全局客户名称脱敏(首尾保留+中间三个*)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 2-3字: 首字+*** (徐***)
- 4-6字: 首2字+***+末1字 (嘉兴***司)
- 7字+: 首4字+***+末2字 (嘉兴市乍***公司)
- 覆盖所有接口: monitoring, targets, vehicles, weekly-detail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:23:24 +08:00
kkfluous
e7efe179b7 fix: 未授权页面增加两种进入方式说明
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:19:18 +08:00
kkfluous
39f89c30a2 fix: 库存统计(inventory-stats/inventory-analysis)不设数据权限,对所有人开放
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:15:15 +08:00
kkfluous
8ed2031c45 feat: 水印移到全局Shell,资产和里程页面都有水印
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:12:48 +08:00
kkfluous
c2d227059c feat: 实时监控加载动画 - KPI骨架屏+车辆列表skeleton
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:10:18 +08:00
kkfluous
1680c53279 feat: 支持 URL path 路由进入不同模块
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
/vehicle 或 /assets → 资产管理
/mileage → 里程管理

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:05:24 +08:00
kkfluous
b5806b3148 feat: 生产环境配置 EXTERNAL_API_BASE=lnh2e.com 和 JWT_SECRET
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:01:49 +08:00
kkfluous
840bf30517 fix: 本周动态也基于权限过滤后的车辆计算
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:57:32 +08:00
kkfluous
441f574238 fix: /summary 和 /by-type 接口补上权限过滤(之前遗漏)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:54:29 +08:00
kkfluous
143c1a57bb debug: 添加权限过滤日志定位问题
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 16:48:29 +08:00
kkfluous
4cd76b6a30 feat: 添加 /api/auth/me 调试端点查看当前用户权限
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 16:44:41 +08:00
kkfluous
09719f3cd6 fix: 水印改为 用户名+时间 替代 羚牛氢能+时间
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:38:20 +08:00
kkfluous
bf1f1946e4 fix: 合并 exchange+login 为一步,直接从 jumpToken 响应提取用户信息签发JWT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:04:09 +08:00
kkfluous
a7ec5ba7b0 fix: 防止 StrictMode 双重调用导致 jumpToken 重复消费
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:59:11 +08:00
kkfluous
f66049dcbc feat: 前端认证网关 + API 自动附加 JWT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- AuthProvider 管理 jumpToken 交换和 JWT 生命周期
- 未授权页面(ShieldX 图标 + 提示文字)
- 加载中旋转动画
- fetchJson 全局客户端自动附加 Authorization header
- 401 响应触发重新认证
- JWT 存 sessionStorage,刷新不丢失

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:38:22 +08:00
kkfluous
2575778293 feat: 后端用户认证和权限过滤
- 新增 auth 模块:jumpToken 代理交换、用户信息获取、JWT 签发
- 三级权限:full(所有权限/数智中心/BI-Leader)、department(BI-Leader-Dep)、personal
- 添加 managerId 到车辆数据模型,支持个人级别按 userId 精确过滤
- auth 中间件保护所有 /api/* 端点(跳过 /api/health 和 /api/auth/*)
- 所有路由集成 filterByPermission 权限过滤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:35:29 +08:00
kkfluous
6dbd36dcd3 refactor: replace mileage monolith with modular route files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:35:58 +08:00
kkfluous
7ec422c13a refactor: create targets route handler 2026-04-02 13:33:14 +08:00
kkfluous
890050ed55 refactor: create trend route handler 2026-04-02 13:33:05 +08:00
kkfluous
ac2a16e7b7 refactor: create monitoring route handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:32:32 +08:00
kkfluous
459b0400b4 refactor: extract monitoring cache module 2026-04-02 13:31:07 +08:00
kkfluous
1a169feaa6 refactor: extract vehicle-info query module 2026-04-02 13:30:19 +08:00
kkfluous
bc1e0ea32e refactor: extract mileage shared types 2026-04-02 13:30:02 +08:00
kkfluous
460d17f07f fix: 合并图例到批次栏,压缩间距,最大化表格可见行数
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:59:06 +08:00
kkfluous
ae42893d3e fix: 批次筛选移到全屏顶部为pill按钮,不占表格列
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:23 +08:00
kkfluous
177ac9752f feat: 全屏表格增加批次筛选列,修复筛选依赖
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:48:12 +08:00
kkfluous
997374cf25 feat: 客户多选筛选、统计报表里程与监控看板数据一致
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 资产管理按客户筛选改为多选(支持同时选多个客户)
- 新增 MultiSearchSelect 组件(搜索+标签+复选框)
- 统计报表 todayTotal 改用监控缓存数据,与里程看板一致

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:31:54 +08:00
kkfluous
8822ddf8ae feat: 车牌区域筛选、型号批次筛选、回到顶部修复、删除涨跌幅
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增车牌区域筛选(粤/沪/浙+数量),替代旧地区代码
- 新增型号批次筛选(从考核目标名称筛选车辆)
- 客户/部门增加"无值"选项筛选空值
- 修复回到顶部按钮在iOS上失效
- 删除KPI卡片涨跌幅百分比显示
- 全屏刷新按钮实际触发数据重新加载+加载动画
- 统计报表全屏刷新按钮修复

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:21:38 +08:00
kkfluous
adc9c3a9db feat: 多项优化 - 全屏加载全部数据、无值筛选、刷新按钮、加载动画、负值显示为0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 全屏监控一次加载全部车辆数据,支持完整滚动和筛选
- 客户/部门筛选增加"无值"选项筛选空数据
- 全屏刷新按钮实际触发数据重新加载,带旋转动画
- 全屏筛选时显示加载遮罩
- 负值里程前端显示为0
- 未对接车机显示"未对接"替代"-"
- 删除"未同步"标签
- 统计报表配色统一为白色主题、KPI联动选中项目
- 统计报表全屏表格列合并优化
- 车辆明细面板增加日期选择、租赁状态/部门/客户信息、里程合计
- 每分钟自动刷新数据
- 清除按钮修复租赁状态重置

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:52:45 +08:00
kkfluous
06a2edc470 feat: 租赁状态与部门分列筛选,未同步车辆显示-,卡片增加今/总标签,全屏监控压缩优化
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:01:17 +08:00
kkfluous
affe356f43 fix: 默认日期改用本地时间,凌晨5点前显示前一天
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:13:52 +08:00
kkfluous
e57b8d8801 fix: 全屏模式重新设计为纵向布局
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 去掉 CSS transform 旋转(移动端不兼容)
- KPI 改为单行横排4个卡片
- 标题栏+KPI 紧凑排列在顶部
- 表格区域占满剩余空间,可滚动查看所有列
- 移动端和桌面端统一布局

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:38:26 +08:00
kkfluous
8b95e53098 fix: 回到顶部改为 scrollTo(0) 确保完全回到页面顶端
用 window.scrollTo + documentElement.scrollTop 双重保险,
替代 scrollIntoView 避免只滚动到哨兵位置。
全屏模式改为 CSS transform 旋转实现移动端横屏。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:32:35 +08:00
kkfluous
bfee8344b9 fix: 全屏按钮增加横屏锁定
点击全屏按钮后:进入浏览器全屏 + 锁定横屏方向
退出全屏时:解除横屏锁定

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:27:14 +08:00
kkfluous
ca4a84f84b fix: 查询日期默认为当天
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:22:33 +08:00
kkfluous
94277efc24 fix: 车辆详情清单标题也吸顶,与KPI合为一个sticky块
Tab栏 + KPI统计 + 清单标题 三层吸顶:
- Tab栏 sticky top-0
- KPI + 清单标题 sticky top-[44px]
移动端和Web端都生效。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:19:49 +08:00
kkfluous
787fa27949 fix: overflow-x-hidden 改为 overflow-x:clip 修复 sticky 吸顶
overflow-x:hidden 会创建滚动容器导致 position:sticky 失效,
改用 overflow-x:clip 裁剪溢出但不破坏 sticky 定位。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:15:38 +08:00
kkfluous
c5ee78e892 feat: Tab栏和KPI卡片吸顶固定
滚动列表时:
- Tab栏(实时监控/统计报表/每日汇报)sticky固定在顶部
- KPI统计卡片sticky固定在Tab栏下方,略缩小间距
- 背景色匹配页面避免透出内容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:11:35 +08:00
kkfluous
50eaeb05ae fix: 统计报表用年度完成率替代总完成率
- 完成率改用 current_year_completion_rate 平均值
- 50%达标数改用 current_year_completion_rate >= 0.5
- 修复后数据:40台普货 完成率51.6% 50%达标15台

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:08:46 +08:00
kkfluous
1d8e827374 fix: 回到顶部按钮用 IntersectionObserver 检测+scrollIntoView
- 顶部放哨兵元素,离开视口时显示回到顶部按钮
- 点击用 scrollIntoView 替代 window.scrollTo,兼容各种布局

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:05:42 +08:00
kkfluous
54c8449f7b fix: 用 IntersectionObserver 替代 scroll 事件实现瀑布流
scroll 事件在某些布局下不触发,改用 IntersectionObserver
监听列表底部哨兵元素,进入视口时自动加载下一页,更可靠。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:59:54 +08:00
kkfluous
b7b254546c fix: 改进瀑布流滚动和回到顶部的可靠性
- 使用 ref 避免 loadMore 依赖导致事件重复注册
- 同时监听 window 和 document 的 scroll 事件
- 降低回到顶部按钮触发阈值到 400px
- 增大触底检测距离到 300px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:52:38 +08:00
kkfluous
82dac759be fix: 环比统计跟随筛选条件正确计算
每辆车缓存其昨日里程(yesterdayKm),筛选后的环比基于
相同筛选条件下的车辆计算,而非全局对比。
例如筛选"业务一部"后,今日和昨日都只统计一部的车辆。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:50:07 +08:00
kkfluous
0b8bbbb063 feat: 支持查询指定日期里程+删除搜索关键词和车牌号筛选
- 后端支持 date 参数,指定日期时实时查询数据库(不用缓存)
- 同时查询前一天数据计算环比
- 高级筛选添加"查询日期"日期选择器
- 删除高级筛选中的"搜索关键词"和"车牌号"(已有快捷筛选)
- 筛选标签支持显示日期条件

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:41:03 +08:00
kkfluous
cbf0e18634 fix: 里程环比改为真实值(与前一天对比)
- 后端缓存刷新时查询前一天总里程(yesterdayTotal)
- 前端计算真实环比:(今日-昨日)/昨日*100%
- 上涨显示蓝色↑,下跌显示红色↓
- 昨日无数据时不显示环比

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:33:52 +08:00
kkfluous
66de41d50b fix: KPI统计跟随筛选条件变化+客户筛选修正+部门排序
- KPI统计(总里程/平均单车/监控台数)改为基于筛选后数据计算
- 移除不需要的 onlineCount 字段
- 快捷筛选"按客户"和全屏表格"客户"列改为真正的客户筛选
- 删除混乱的 projects 变量映射
- 部门列表按 一部→二部→三部 顺序排序

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:29:31 +08:00
kkfluous
c73e20bacf fix: 快捷筛选按客户改为客户筛选、删除日期筛选、更新频率文案
- 快捷筛选"按客户"改为真正的客户名称筛选(独立于项目筛选)
- 删除高级筛选中的"日期区间"和"日期"(无后端支持)
- "40MIN更新"改为"每分钟更新"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:26:34 +08:00
kkfluous
8fffa141f4 fix: 修复车牌搜索失效,确保所有筛选条件正常
- 后端新增 plate 查询参数支持
- 前端将 filterPlate 传给 API 并加入依赖数组
- 所有筛选条件(部门/项目/主体/车牌/搜索/里程范围)
  均正确传递到后端并触发数据刷新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:22:12 +08:00
kkfluous
cb620e5101 feat: 筛选条件标签展示+单独删除+清除全部
筛选后在 KPI 卡片上方展示活跃筛选条件标签(蓝色圆角),
每个标签可单独点×删除,右侧"清除"按钮重置所有筛选。
支持:部门/项目/主体/车牌/搜索/里程范围/地区。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:18:55 +08:00
kkfluous
2469da310d fix: 里程范围筛选接入后端
- 后端支持 mileageMin/mileageMax 查询参数
- 前端点击"完成筛选"时将里程范围提交到后端
- "重置所有"同时清除已应用的里程范围

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:14:56 +08:00
kkfluous
863ab17b58 fix: 筛选器和显示优化
- 删除年份筛选
- 项目筛选改用真实项目数据(ln_vehicle_contract.project_name)
- 主体查询改用 tab_truck → tab_org 的 org_name
- 里程区间改为两个独立条件(里程≥ / 里程≤)
- 未分配客户显示为 -
- 统计报表日期格式改为 M.D(如 3.25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:06:56 +08:00
kkfluous
2f6269e071 feat: 实时监控改为瀑布流无限滚动+回到顶部
- 移除分页按钮,改为滚动触底自动加载下一页
- 滚动超过600px时显示蓝色回到顶部按钮
- 底部提示加载状态(加载中.../已加载全部 N 条)
- 筛选/排序变化时自动重置为首页

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:52:05 +08:00
kkfluous
d8f25448d0 fix: 实时监控显示优化
1. KPI 总里程不保留小数
2. 车辆卡片先展示部门再展示客户名称,客户名称不截断
3. 无部门时展示租赁状态(自营/租赁/挂靠等)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:45:46 +08:00
kkfluous
c3300359a0 fix: 所有里程数据添加 km 单位
实时监控:全屏表格、车辆卡片的今日/累计里程添加 km
统计报表:全屏表格、考核详情、侧滑面板的里程值添加 km
统一使用小写 km

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:35:45 +08:00
kkfluous
aa024f1b64 perf: 实时监控改为缓存+分页架构
后端:
- 每2分钟刷新全量数据到内存缓存(并行查询两库)
- 预计算统计信息(totalToday/totalAll/onlineCount/vehicleCount)
- 预提取筛选选项(departments/customers/plates)
- API 直接从缓存读取,支持分页(每页50条)+筛选+排序

前端:
- KPI 统计使用后端返回的 stats
- 车辆列表分页,带翻页控件
- 筛选选项从后端 filters 获取

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:23:25 +08:00
kkfluous
1fb9d53873 perf: 实时监控性能优化
- 后端:车辆关联信息缓存5分钟、两库并行查询、支持服务端
  筛选/排序/分页(默认返回100条)
- 前端:筛选和排序参数传给后端,不再加载全量数据
- 筛选选项(部门/客户/车牌)仅首次加载获取

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:11:52 +08:00
kkfluous
ad17803ed1 fix: 里程数超过10000显示为xx.xx万KM
添加 fmtKm() 格式化函数,统计报表中所有里程数值
超过10000时自动转为万单位显示。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:47:35 +08:00
kkfluous
152935819b fix: 考核区间支持多批次显示
190辆冷链车有3个不同考核区间(40台、50台、100台),
恒运有2个。后端改为查询每个target的不同考核区间并返回
periods数组,前端换行显示每个区间及其车辆数。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:43:17 +08:00
kkfluous
2a10c5ae31 fix: 修复统计报表移动端适屏问题
- Shell main 添加 min-w-0 overflow-x-hidden 防止 flex 子元素溢出
- trend API 排除当天数据 (stat_date < CURDATE()),只返回前7天
- StatisticsView 移除移动端 overflow-hidden,改为仅 landscape 模式
- 图表和列表卡片在移动端正确显示全宽内容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:36:25 +08:00
kkfluous
ee3db94c75 fix: 修复统计报表在移动端的溢出问题
- MileageModule 减少移动端 padding (p-6→p-3)
- StatisticsView 添加 overflow-x-hidden 防止横向溢出
- 图表容器添加 overflow-hidden
- 减少图表右侧 margin 防止标签被截断

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:29:54 +08:00
kkfluous
dd1834477d fix: 修复统计报表完成率格式和项目名称显示
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:26:31 +08:00
kkfluous
7e2eefc3da feat: 实现里程管理统计报表视图(1:1 复刻原型)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:16:39 +08:00
kkfluous
167842408c feat: 实现里程管理实时监控视图(1:1 复刻原型)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:11:26 +08:00
kkfluous
0a2cfc22c4 feat: MileageModule Tab 切换 + DailyReportView 占位
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:06:05 +08:00
kkfluous
75b4e55dca feat: 添加里程管理 API 路由(monitoring/targets/trend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:06:01 +08:00
kkfluous
5ff3372f2a feat: 添加里程管理 API 客户端 2026-04-01 21:05:29 +08:00
kkfluous
a7e617bc6f feat: 添加 hydrogen_energy 数据库连接和里程管理类型定义
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:03:52 +08:00
kkfluous
3d6c31a86e docs: 添加里程管理模块实施计划
7 个 Task:后端数据库连接+API 路由、前端类型+API 客户端、
MileageModule Tab 切换、MonitoringView 1:1 复刻、
StatisticsView 1:1 复刻、集成验证。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:01:58 +08:00
kkfluous
7cf7bc945a docs: 添加里程管理模块设计文档
覆盖架构、API 端点、前端组件、数据映射,
1:1 复刻原型的实时监控和统计报表。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:45 +08:00
kkfluous
968e9369a0 refactor: 重写 App.tsx 为模块化顶层壳 2026-04-01 19:22:30 +08:00
kkfluous
caec13eec5 refactor: 创建 AssetsModule,迁移资产管理逻辑
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:21:39 +08:00
kkfluous
bb3dbde1c7 feat: 创建 Shell 布局组件(侧边栏 + 底部导航)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:19:26 +08:00
kkfluous
40e84a1eaa feat: 创建里程管理占位组件 2026-04-01 19:19:22 +08:00
kkfluous
be6598a940 refactor: 移动 types.ts 和 api.ts 到 modules/assets/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:19:11 +08:00
kkfluous
de0320bfcd refactor: 抽取 SearchSelect 为公共组件
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:18:16 +08:00
kkfluous
b495cac0fe docs: 添加模块化重构实施计划
7 个 Task 的详细步骤,覆盖 SearchSelect 抽取、文件迁移、
AssetsModule 创建、Shell 布局、里程占位、App.tsx 重写和清理。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:07:02 +08:00
kkfluous
cfd81b1b9d docs: 添加模块化重构设计文档
支持多 BI 大类(资产管理、里程管理)的架构重构设计,
包括目录结构、Shell 布局、导航机制和迁移策略。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:03:13 +08:00
67 changed files with 15013 additions and 2843 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -13,8 +13,11 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY src/server ./src/server
COPY src/shared ./src/shared
COPY tsconfig.json ./
EXPOSE 3001
ENV SERVER_PORT=3001
ENV EXTERNAL_API_BASE=https://lnh2e.com
ENV JWT_SECRET=ln-bi-jwt-prod-secret
CMD ["npm", "run", "start"]

View File

@@ -5,12 +5,14 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
DB_HOST: "192.168.130.111"
DB_HOST: "47.101.148.99"
DB_PORT: "3306"
DB_USER: "linsset_01"
DB_PASSWORD: "LN3456#&"
DB_USER: "root"
DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
deploy:
replicas: 1
restart_policy:

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

BIN
docs/superpowers/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,753 @@
# Three Operations Modules Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add department, region, and customer operations statistics sections to the dashboard, ported from lnoneos prototype with real MySQL data.
**Architecture:** All 3 new API endpoints aggregate from the existing `getVehicles()` cache (no new DB queries). A new macro-region mapping function converts province/city to 华东/华南/etc. The frontend adds 3 new collapsible sections below the existing asset summary table, each with desktop table + mobile card views. The existing vehicle list modal is extended with new filter params (manager, customer, isColdChain, isTrailer).
**Tech Stack:** Hono (backend), React + Tailwind CSS + Motion (frontend), TypeScript throughout.
**Reference:** lnoneos prototype at `/Users/kkfluous/Projects/ai-coding/lnoneos/src/App.tsx`
---
### Task 1: Backend — Macro-region mapping + vehicle type classification helpers
**Files:**
- Modify: `src/server/routes/vehicles.ts` (add functions after line ~111)
- [ ] **Step 1: Add macro-region mapping function**
Add after `mapInventoryRegion` (line 111) in `src/server/routes/vehicles.ts`:
```typescript
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const c = (city || '').trim();
const loc = prov + c;
// 华东: 上海/江苏/浙江/安徽/福建/江西/山东
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
// 华南: 广东/广西/海南
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
// 华北: 北京/天津/河北/山西/内蒙古
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
// 华中: 河南/湖北/湖南
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
// 西南: 重庆/四川/贵州/云南/西藏
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
// 西北: 陕西/甘肃/青海/宁夏/新疆
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
return '其他';
}
// Vehicle type classification for per-type counts
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
if (v.type === '18T') return 't18';
if (v.type === '49T') return 't49';
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
return 'other';
}
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
for (const v of vehicles) {
counts[classifyVehicleType(v)]++;
counts.total++;
}
return counts;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: add macro-region mapping and vehicle type classification helpers"
```
---
### Task 2: Backend — Three new API endpoints
**Files:**
- Modify: `src/server/routes/vehicles.ts` (add endpoints before the `/list` endpoint)
- [ ] **Step 1: Add `/dept-stats` endpoint**
Add before the `VEHICLE_TYPE_FILTERS` const (which is before `/list`) in `src/server/routes/vehicles.ts`:
```typescript
// GET /api/vehicles/dept-stats
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
// Only count operating vehicles for department stats (those with a customerManager)
const withManager = vehicles.filter((v) => v.customerManager);
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '未分配';
const mgr = v.customerManager!;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
mgrMap.get(mgr)!.push(v);
}
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
const managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({
manager,
department,
...countByType(mvs),
}))
.sort((a, b) => b.total - a.total);
return {
department,
totalAssets: allDeptVehicles.length,
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
managers,
};
}).sort((a, b) => b.totalAssets - a.totalAssets);
return c.json(result);
});
```
- [ ] **Step 2: Add `/region-stats` endpoint**
```typescript
// GET /api/vehicles/region-stats
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const regionMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const region = mapMacroRegion(v.province, v.city);
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(v);
}
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
.filter((r) => regionMap.has(r))
.map((region) => {
const rv = regionMap.get(region)!;
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
const typeVehicles = rv.filter((v) => v.type === type);
return {
type,
total: typeVehicles.length,
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
};
}).filter((t) => t.total > 0);
return {
region,
totalAssets: rv.length,
operatingCount: rv.filter((v) => v.status === 'Operating').length,
inventoryCount: rv.filter((v) => v.status === 'Inventory').length,
customers,
typeBreakdown,
};
});
return c.json(result);
});
```
- [ ] **Step 3: Add `/customer-stats` endpoint**
```typescript
// GET /api/vehicles/customer-stats
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
const custMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const cust = v.customerName!;
if (!custMap.has(cust)) custMap.set(cust, []);
custMap.get(cust)!.push(v);
}
const result = Array.from(custMap.entries())
.map(([customer, cvs]) => {
const first = cvs[0];
return {
customer,
manager: first.customerManager || '',
brand: first.brandLabel || '',
department: first.departmentName || '',
region: mapMacroRegion(first.province, first.city),
city: first.city || '',
...countByType(cvs),
};
})
.sort((a, b) => b.total - a.total);
return c.json(result);
});
```
- [ ] **Step 4: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: add dept-stats, region-stats, customer-stats API endpoints"
```
---
### Task 3: Backend — Extend `/list` with new filter params
**Files:**
- Modify: `src/server/routes/vehicles.ts` (the `/list` endpoint)
- [ ] **Step 1: Add manager, customer, isColdChain, isTrailer filters**
In the `/list` endpoint, after the existing `category` filter block, add:
```typescript
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query();
```
(Replace the existing destructure line.)
Then after the `if (category)` block, add:
```typescript
if (manager) {
filtered = filtered.filter((v) => v.customerManager === manager);
}
if (customer) {
filtered = filtered.filter((v) => v.customerName === customer);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链'));
}
if (isTrailer !== undefined) {
const wantTrailer = isTrailer === 'true';
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
```
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/vehicles.ts
git commit -m "feat: extend /list endpoint with manager, customer, coldchain, trailer filters"
```
---
### Task 4: Frontend — Types and API client
**Files:**
- Modify: `src/types.ts`
- Modify: `src/api.ts`
- [ ] **Step 1: Add new interfaces to `src/types.ts`**
Append at end of file:
```typescript
export interface ManagerStats {
manager: string;
department: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
export interface DeptGroup {
department: string;
totalAssets: number;
operatingCount: number;
idleCount: number;
managers: ManagerStats[];
}
export interface RegionGroup {
region: string;
totalAssets: number;
operatingCount: number;
inventoryCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
}
export interface CustomerStats {
customer: string;
manager: string;
brand: string;
department: string;
region: string;
city: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
```
- [ ] **Step 2: Add API functions to `src/api.ts`**
Add imports at top:
```typescript
import type {
SummaryData,
TypeSummary,
VehicleListItem,
DeptGroup,
RegionGroup,
CustomerStats,
} from './types';
```
Add after `fetchVehicleList`:
```typescript
export async function fetchDeptStats(): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
}
export async function fetchRegionStats(): Promise<RegionGroup[]> {
return fetchJson<RegionGroup[]>(`${BASE}/region-stats`);
}
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
}
```
Also update `fetchVehicleList` params type to include new filters:
```typescript
export async function fetchVehicleList(params: {
batch?: string;
model?: string;
location?: string;
status?: string;
category?: string;
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: string;
isTrailer?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location);
if (params.status) query.set('status', params.status);
if (params.category) query.set('category', params.category);
if (params.vehicleType) query.set('vehicleType', params.vehicleType);
if (params.manager) query.set('manager', params.manager);
if (params.customer) query.set('customer', params.customer);
if (params.isColdChain) query.set('isColdChain', params.isColdChain);
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add src/types.ts src/api.ts
git commit -m "feat: add frontend types and API client for dept/region/customer stats"
```
---
### Task 5: Frontend — Extend App.tsx state, data loading, imports, and showPlateNumbers
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Update imports**
Replace the existing import lines at top of `src/App.tsx`:
```typescript
import {
Truck,
Warehouse,
Activity,
PlusCircle,
MinusCircle,
History,
ChevronDown,
ChevronRight,
Info,
Loader2,
Search,
Filter,
ArrowRightLeft,
} from 'lucide-react';
```
Update type imports:
```typescript
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api';
```
- [ ] **Step 2: Add new state variables**
After the existing state declarations (after `const [modalLoading, setModalLoading] = useState(false);`), add:
```typescript
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
const [regionData, setRegionData] = useState<RegionGroup[]>([]);
const [customerData, setCustomerData] = useState<CustomerStats[]>([]);
// Dept section state
const [deptViewMode, setDeptViewMode] = useState<'department' | 'manager'>('department');
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [expandedManagerDetails, setExpandedManagerDetails] = useState<Set<string>>(new Set());
const [selectedManager, setSelectedManager] = useState<string>('All');
// Region section state
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
// Customer section state
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
```
- [ ] **Step 3: Update loadData to fetch all 3 new endpoints**
Update the `loadData` callback:
```typescript
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [s, byType, dept, region, cust] = await Promise.all([
fetchSummary(),
fetchByType(),
fetchDeptStats(),
fetchRegionStats(),
fetchCustomerStats(),
]);
setSummary(s);
setProcessedData(byType);
setDeptData(dept);
setRegionData(region);
setCustomerData(cust);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
```
- [ ] **Step 4: Extend showPlateNumbers type**
Update the showPlateNumbers state type:
```typescript
const [showPlateNumbers, setShowPlateNumbers] = useState<{
batch: string;
model: string;
location: string;
category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced' | 'Operating';
vehicleType?: string;
manager?: string;
customer?: string;
isColdChain?: boolean;
isTrailer?: boolean;
} | null>(null);
```
- [ ] **Step 5: Update modal loading to pass new filter params**
In the `useEffect` for modal loading, update the params block (the "Normal vehicle list" section):
```typescript
// Normal vehicle list
setModalWeeklyDetail([]);
const params: Record<string, string> = {};
if (showPlateNumbers.vehicleType) params.vehicleType = showPlateNumbers.vehicleType;
if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch;
if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model;
if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location;
if (cat === 'Inventory') params.status = 'Inventory';
if (cat === 'Operating') params.category = 'Operating';
if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager;
if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer;
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
```
- [ ] **Step 6: Add toggle helpers and derived data**
After the existing `toggleModel` function, add:
```typescript
const toggleDept = (dept: string) => {
const newSet = new Set(expandedDepts);
if (newSet.has(dept)) newSet.delete(dept);
else newSet.add(dept);
setExpandedDepts(newSet);
};
const toggleManagerDetails = (manager: string) => {
const newSet = new Set(expandedManagerDetails);
if (newSet.has(manager)) newSet.delete(manager);
else newSet.add(manager);
setExpandedManagerDetails(newSet);
};
const toggleRegion = (region: string) => {
const newSet = new Set(expandedRegions);
if (newSet.has(region)) newSet.delete(region);
else newSet.add(region);
setExpandedRegions(newSet);
};
const toggleCustomer = (customer: string) => {
const newSet = new Set(expandedCustomers);
if (newSet.has(customer)) newSet.delete(customer);
else newSet.add(customer);
setExpandedCustomers(newSet);
};
// Derived data for dept section
const allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort();
const managerStats = deptData
.flatMap((d) => d.managers)
.filter((m) => selectedManager === 'All' || m.manager === selectedManager)
.sort((a, b) => b.total - a.total);
// Derived data for customer section
const filteredCustomerStats = customerData.filter((s) => {
const mc = !customerFilters.customer || s.customer.toLowerCase().includes(customerFilters.customer.toLowerCase());
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
const md = !customerFilters.department || s.department === customerFilters.department;
const mm = !customerFilters.manager || s.manager.toLowerCase().includes(customerFilters.manager.toLowerCase());
const mr = !customerFilters.region || s.region === customerFilters.region;
return mc && mb && md && mm && mr;
});
const uniqueBrands = Array.from(new Set(customerData.map((s) => s.brand).filter(Boolean)));
const uniqueDepts = Array.from(new Set(customerData.map((s) => s.department).filter(Boolean)));
const uniqueRegions = Array.from(new Set(customerData.map((s) => s.region)));
const uniqueCities = Array.from(new Set(customerData.map((s) => s.city).filter(Boolean)));
// Derived data for region section
const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region);
```
- [ ] **Step 7: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 8: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add state, data loading, and helpers for 3 new modules"
```
---
### Task 6: Frontend — Department Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after the asset summary table's closing `</div>`, before the Plate Number Modal)
- [ ] **Step 1: Add the department operations section**
Insert the department operations section JSX. Reference: lnoneos lines 1362-1880. This section goes right after the closing `</div>` of the asset summary table (`bg-white rounded-sm border...`) and before `{/* Plate Number Modal */}`.
The section includes:
1. Header with title "部门运营统计"
2. Dark summary bar (总资产/运营中/闲置中 — skip 平均出勤)
3. Toggle buttons (按部门 / 按业务员) + manager filter dropdown
4. Desktop table view (`hidden lg:block`)
- Department mode: department rows expandable to show manager cards with 6 vehicle type cells
- Manager mode: flat manager rows expandable to show 6 vehicle type cells
5. Mobile card view (`lg:hidden`)
Port the JSX from lnoneos lines 1362-1880, replacing:
- `MOCK_DEPT_STATS``deptData`
- `DEPT_TOTALS.total``deptData.reduce((s, d) => s + d.totalAssets, 0)`
- `allManagersList` / `managerStats` / `deptViewMode` / `expandedDepts` / `expandedManagerDetails` / `selectedManager` → already defined in Task 5
- `setShowPlateNumbers` calls: keep the same structure but remove `source` field (not needed in ln-bi)
- Remove `ArrowRightLeft` icon usage in toggle buttons — replace with simple text button
- All `rounded-2xl``rounded-sm` to match ln-bi style
- All `shadow-sm` stay as is
The `setShowPlateNumbers` calls from lnoneos use `manager`, `type`, `isColdChain`, `isTrailer` fields which we added to the state type in Task 5.
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add department operations statistics section"
```
---
### Task 7: Frontend — Region Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after department section, before Plate Number Modal)
- [ ] **Step 1: Add the region operations section**
Reference: lnoneos lines 1882-2174. Insert after the department section.
The section includes:
1. Slate-themed header with "区域运营统计" + filter button
2. Filter popover (客户搜索 / 区域下拉 / 城市下拉)
3. Desktop table: expandable region rows → vehicle type sub-rows
4. Mobile cards: expandable region cards with type breakdown
Port the JSX from lnoneos, replacing:
- `MOCK_CUSTOMER_STATS` region-based filtering → use `filteredRegionData` (from Task 5)
- Region stats aggregation in lnoneos used mock data with `Math.floor(totalAssets * 0.8)` for operating — use real `r.operatingCount` and `r.inventoryCount`
- Type breakdown: use `r.typeBreakdown` array from API
- `uniqueRegions` / `uniqueCities` → already defined in Task 5
- `setShowPlateNumbers` calls: use `vehicleType` field for type filtering instead of lnoneos's `type` field
- `rounded-2xl``rounded-sm`
- Filter popover for cities: derive from `regionData` (all unique cities from customers)
Note: The region filter's city dropdown needs city data. Add to Task 5's derived data if not already there. The regionData from API contains customer names but not cities. For the city filter, we can derive from customerData filtered by region.
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add region operations statistics section"
```
---
### Task 8: Frontend — Customer Operations UI
**Files:**
- Modify: `src/App.tsx` (add section after region section, before Plate Number Modal)
- [ ] **Step 1: Add the customer operations section**
Reference: lnoneos lines 2176-2496. Insert after the region section.
The section includes:
1. Emerald-themed header with "客户运营统计" + filter button
2. Filter popover (客户名搜索 / 业务员搜索 / 品牌下拉 / 部门下拉 / 区域下拉)
3. Desktop table: customer rows with 6 vehicle type columns + total, expandable detail cards
4. Mobile cards: customer cards with expandable vehicle type grid
Port the JSX from lnoneos, replacing:
- `MOCK_CUSTOMER_STATS``filteredCustomerStats` (from Task 5)
- `DEPT_TOTALS.total``customerData.reduce((s, c) => s + c.total, 0)` for asset ratio
- `setShowPlateNumbers` calls: use `vehicleType` + `customer` fields
- `uniqueBrands` / `uniqueDepts` / `uniqueRegions` → already defined in Task 5
- `rounded-2xl``rounded-sm`
- [ ] **Step 2: Verify TypeScript compiles and build passes**
Run: `npx tsc --noEmit && npx vite build`
Expected: no errors, successful build
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add customer operations statistics section"
```
---
### Task 9: Final verification and build
**Files:** All modified files
- [ ] **Step 1: Full TypeScript check**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 2: Production build**
Run: `npx vite build`
Expected: successful build with no warnings
- [ ] **Step 3: Verify all sections render**
Run: `npm run dev` and check:
- Department section loads with real data
- Region section loads with real data
- Customer section loads with real data
- Filter popovers work
- Expand/collapse works
- Click-through to plate number modal works
- [ ] **Step 4: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix: address any issues from final review"
```

View File

@@ -0,0 +1,815 @@
# 里程管理模块实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现里程管理 BI 模块1:1 复刻原型 UI接入 hydrogen_energy + lingniu_prod 两个数据库。
**Architecture:** 后端新增 hydrogen_energy 数据库连接 + `/api/mileage/*` 路由4 个端点)。前端在 `src/modules/mileage/` 下拆分为 MileageModuleTab 切换、MonitoringView实时监控、StatisticsView统计报表、DailyReportView占位。UI 1:1 复刻原型 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`
**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Recharts, Motion, Lucide Icons, Hono, mysql2
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 创建 | `src/server/mileage-db.ts` | hydrogen_energy 数据库连接池 |
| 创建 | `src/server/routes/mileage.ts` | 里程管理 API 路由4 个端点) |
| 修改 | `src/server/index.ts` | 注册 `/api/mileage` 路由 |
| 创建 | `src/modules/mileage/types.ts` | 里程管理类型定义 |
| 创建 | `src/modules/mileage/api.ts` | API 客户端函数 |
| 重写 | `src/modules/mileage/MileageModule.tsx` | 主组件:子 Tab 切换 |
| 创建 | `src/modules/mileage/MonitoringView.tsx` | 实时监控视图 |
| 创建 | `src/modules/mileage/StatisticsView.tsx` | 统计报表视图 |
| 创建 | `src/modules/mileage/DailyReportView.tsx` | 每日汇报(占位) |
---
### Task 1: 后端 — 数据库连接 + 类型定义
**Files:**
- Create: `src/server/mileage-db.ts`
- Create: `src/modules/mileage/types.ts`
- [ ] **Step 1: 创建 hydrogen_energy 数据库连接池**
创建 `src/server/mileage-db.ts`
```ts
import mysql from 'mysql2/promise';
const mileagePool = mysql.createPool({
host: '101.133.130.65',
port: 3306,
user: 'bi_reader_02',
password: 'bi_reader_02_Pass',
database: 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default mileagePool;
```
- [ ] **Step 2: 创建前端类型定义**
创建 `src/modules/mileage/types.ts`
```ts
export interface MonitoringVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
}
export interface MonitoringData {
vehicles: MonitoringVehicle[];
updatedAt: string;
}
export interface TargetSummary {
id: number;
targetName: string;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
assessmentYears: number;
period: string;
todayTotal: number;
cumulativeTotal: number;
avgCompletion: number;
qualifiedCount: number;
yearQualifiedCount: number;
halfQualifiedCount: number;
currentYearTarget: number;
currentYearCompleted: number;
remaining: number;
daysLeft: number;
dailyTarget: number;
}
export interface TargetVehicle {
plateNumber: string;
todayMileage: number;
totalMileage: number;
completionRate: number;
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
}
export interface TrendPoint {
date: string;
mileage: number;
}
```
- [ ] **Step 3: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 提交**
```bash
git add src/server/mileage-db.ts src/modules/mileage/types.ts
git commit -m "feat: 添加 hydrogen_energy 数据库连接和里程管理类型定义"
```
---
### Task 2: 后端 — API 路由
**Files:**
- Create: `src/server/routes/mileage.ts`
- Modify: `src/server/index.ts`
- [ ] **Step 1: 创建里程管理路由**
创建 `src/server/routes/mileage.ts`
```ts
import { Hono } from 'hono';
import pool from '../db.js';
import mileagePool from '../mileage-db.js';
const app = new Hono();
// 车辆关联信息 SQL客户名、部门、经理
const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
// GET /monitoring — 实时监控数据
app.get('/monitoring', async (c) => {
try {
// 1. 从 hydrogen_energy 取最新日期的里程数据
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as any;
const latestDate = dateRows[0]?.latest;
if (!latestDate) return c.json({ vehicles: [], updatedAt: new Date().toISOString() });
const [mileageRows] = await mileagePool.execute(
`SELECT plate, vin, daily_km, total_km, source
FROM v_vehicle_daily_stats
WHERE stat_date = ?`,
[latestDate]
) as any;
// 对于同一 plate 可能有多条记录(不同 source取 daily_km 最大的
const mileageMap = new Map<string, any>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
// 2. 从 lingniu_prod 取车辆关联信息
const [infoRows] = await pool.execute(VEHICLE_INFO_SQL) as any;
const infoMap = new Map<string, any>();
for (const row of infoRows) {
infoMap.set(row.plate, row);
}
// 3. 合并
const vehicles = Array.from(mileageMap.values()).map((m: any) => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
};
});
return c.json({ vehicles, updatedAt: new Date().toISOString() });
} catch (e) {
console.error('monitoring error:', e);
return c.json({ vehicles: [], updatedAt: new Date().toISOString() }, 500);
}
});
// GET /targets — 考核项目列表 + 汇总
app.get('/targets', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as any;
const [vehicleStats] = await pool.execute(`
SELECT
target_id,
COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle
WHERE is_deleted = 0
GROUP BY target_id
`) as any;
const statsMap = new Map<number, any>();
for (const s of vehicleStats) {
statsMap.set(s.target_id, s);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const startDate = t.default_start_date
? new Date(t.default_start_date).toISOString().split('T')[0]
: '';
const endDate = t.default_end_date
? new Date(t.default_end_date).toISOString().split('T')[0]
: '';
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
period: `${startDate} ~ ${endDate}`,
todayTotal: Number(s.today_total) || 0,
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e) {
console.error('targets error:', e);
return c.json([], 500);
}
});
// GET /target/:id/vehicles — 某项目的车辆明细
app.get('/target/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as any;
const result = rows.map((r: any) => ({
plateNumber: r.plate_number,
todayMileage: Number(r.today_mileage) || 0,
totalMileage: Number(r.vehicle_total_mileage) || 0,
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
}));
return c.json(result);
} catch (e) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
// GET /trend — 7天里程趋势
app.get('/trend', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as any;
plates = vehicleRows.map((r: any) => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
`;
const params: any[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as any;
const result = rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
}));
return c.json(result);
} catch (e) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;
```
- [ ] **Step 2: 注册路由到 server/index.ts**
`src/server/index.ts` 中,在 `import vehiclesRouter` 之后添加:
```ts
import mileageRouter from './routes/mileage.js';
```
`app.route('/api/vehicles', vehiclesRouter);` 之后添加:
```ts
app.route('/api/mileage', mileageRouter);
```
- [ ] **Step 3: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 验证 API 端点可访问**
Run: `npm run dev:server &` 然后:
```bash
curl -s http://localhost:3001/api/mileage/targets | head -c 200
curl -s http://localhost:3001/api/mileage/trend?days=7 | head -c 200
```
Expected: 返回 JSON 数据(非空数组)
关闭服务器后继续。
- [ ] **Step 5: 提交**
```bash
git add src/server/mileage-db.ts src/server/routes/mileage.ts src/server/index.ts
git commit -m "feat: 添加里程管理 API 路由monitoring/targets/trend"
```
---
### Task 3: 前端 — API 客户端
**Files:**
- Create: `src/modules/mileage/api.ts`
- [ ] **Step 1: 创建 API 客户端**
创建 `src/modules/mileage/api.ts`
```ts
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
const BASE = '/api/mileage';
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
}
export async function fetchMonitoring(): Promise<MonitoringData> {
return fetchJson<MonitoringData>(`${BASE}/monitoring`);
}
export async function fetchTargets(): Promise<TargetSummary[]> {
return fetchJson<TargetSummary[]>(`${BASE}/targets`);
}
export async function fetchTargetVehicles(targetId: number): Promise<TargetVehicle[]> {
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles`);
}
export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoint[]> {
const params = new URLSearchParams();
if (targetId) params.set('targetId', String(targetId));
params.set('days', String(days));
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/modules/mileage/api.ts
git commit -m "feat: 添加里程管理 API 客户端"
```
---
### Task 4: 前端 — MileageModule + DailyReportView
**Files:**
- Rewrite: `src/modules/mileage/MileageModule.tsx`
- Create: `src/modules/mileage/DailyReportView.tsx`
- [ ] **Step 1: 创建 DailyReportView 占位组件**
创建 `src/modules/mileage/DailyReportView.tsx`
```tsx
import { FileText } from 'lucide-react';
export default function DailyReportView() {
return (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</div>
);
}
```
- [ ] **Step 2: 重写 MileageModule.tsx**
这个组件负责子 Tab 切换1:1 复刻原型中 `MileageView` 组件的导航部分(原型文件第 1585-1691 行和第 2296-2301 行)。
用以下内容替换 `src/modules/mileage/MileageModule.tsx`
```tsx
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { motion } from 'motion/react';
import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView';
export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 px-1 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
{/* Sub-navigation */}
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6">
<button
onClick={() => setActiveSubTab('monitoring')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
>
<LayoutDashboard size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'monitoring' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
</div>
</div>
);
}
```
- [ ] **Step 3: 验证 TypeScript 编译**
注意:此时 MonitoringView 和 StatisticsView 尚未创建,可能会报错。先创建空的占位文件:
临时创建 `src/modules/mileage/MonitoringView.tsx`
```tsx
export default function MonitoringView() {
return <div>MonitoringView placeholder</div>;
}
```
临时创建 `src/modules/mileage/StatisticsView.tsx`
```tsx
export default function StatisticsView() {
return <div>StatisticsView placeholder</div>;
}
```
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 提交**
```bash
git add src/modules/mileage/MileageModule.tsx src/modules/mileage/DailyReportView.tsx src/modules/mileage/MonitoringView.tsx src/modules/mileage/StatisticsView.tsx
git commit -m "feat: MileageModule Tab 切换 + DailyReportView 占位"
```
---
### Task 5: 前端 — MonitoringView实时监控
**Files:**
- Rewrite: `src/modules/mileage/MonitoringView.tsx`
**重要:** 此组件需要 1:1 复刻原型。原型代码在 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx` 的以下行范围:
- `MileageView` 组件:第 1585-2303 行(当 `activeSubTab === 'monitoring'` 时渲染的部分:第 1693-2295 行)
- `SearchableSelect` 组件:第 644-722 行
- [ ] **Step 1: 实现 MonitoringView**
用完整实现替换 `src/modules/mileage/MonitoringView.tsx`
**实现要求:**
1. **读取原型文件** `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`,提取以下内容:
- `SearchableSelect` 组件定义(第 644-722 行)— 在 MonitoringView 文件内部定义
- `MileageView``activeSubTab === 'monitoring'` 分支的所有 JSX第 1693-2295 行)
2. **数据来源变更**(将 mock 数据替换为 API 调用):
- 删除对 `MOCK_VEHICLES` 的所有引用
- 添加状态 `const [allVehicles, setAllVehicles] = useState<MonitoringVehicle[]>([]);`
- 使用 `useEffect` 调用 `fetchMonitoring()` 加载数据,每 60 秒刷新
- `filteredVehicles``useMemo` 改为对 `allVehicles` 进行过滤和排序
- `departments``plateNumbers``projects``allVehicles` 提取唯一值
- `stats``useMemo` 保持原逻辑,但基于 `filteredVehicles` 计算
3. **字段映射变更**
- `v.plateNumber``v.plate`
- `v.customer` 保持不变
- `v.department` 保持不变
- `v.todayMileage``v.dailyKm`
- `v.totalMileage``v.totalKm`
- `v.isOnline` 保持不变
- `v.isDataSynced` 保持不变
- `v.id``v.plate`(作为 key
- `v.model` → 不可用,过滤中移除 model 匹配
- `v.assetOwner` → 不可用,移除 entity 过滤逻辑
- `v.location` → 不可用,移除 regionCode 过滤逻辑
4. **保持不变的部分**
- 所有 CSS classNameTailwind 类名 1:1 保留)
- 所有动画motion, AnimatePresence
- 全屏叠加层的完整 JSX
- 高级筛选面板的完整 JSX保留所有筛选器的 UI即使部分过滤器暂无数据源如 entity/regionCode保留 UI 但过滤逻辑可为空操作)
- KPI 卡片布局
- 车辆列表卡片样式
- `toggleFullscreen` 函数
5. **Import 列表**(完整):
```tsx
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, LayoutDashboard,
} from 'lucide-react';
import type { MonitoringVehicle } from './types';
import { fetchMonitoring } from './api';
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 验证 Vite 构建**
Run: `npm run build`
Expected: 构建成功
- [ ] **Step 4: 提交**
```bash
git add src/modules/mileage/MonitoringView.tsx
git commit -m "feat: 实现里程管理实时监控视图1:1 复刻原型)"
```
---
### Task 6: 前端 — StatisticsView统计报表
**Files:**
- Rewrite: `src/modules/mileage/StatisticsView.tsx`
**重要:** 此组件需要 1:1 复刻原型。原型代码在 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx` 的以下行范围:
- `StatisticsView` 组件:第 843-1364 行
- [ ] **Step 1: 实现 StatisticsView**
用完整实现替换 `src/modules/mileage/StatisticsView.tsx`
**实现要求:**
1. **读取原型文件** `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1/src/App.tsx`,提取 `StatisticsView` 组件定义(第 843-1364 行)的完整 JSX。
2. **数据来源变更**
- 删除对 `MOCK_MODEL_STATS``MOCK_PROJECT_MILEAGE``MOCK_VEHICLES` 的引用
- 添加状态:
```tsx
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehicles, setTargetVehicles] = useState<Record<number, TargetVehicle[]>>({});
```
- 使用 `useEffect` 调用 `fetchTargets()` 加载考核项目列表
- `selectedProject` 改为 `selectedTargetId: number | null`,默认选第一个 target 的 id
- 当 `selectedTargetId` 变化时调用 `fetchTrend(selectedTargetId)` 加载趋势数据
- 展开车辆明细时调用 `fetchTargetVehicles(targetId)` 加载
3. **字段映射变更**`MOCK_MODEL_STATS` 的字段 → `TargetSummary` 的字段):
- `row.model` → `target.targetName`
- `row.count` → `target.vehicleCount`
- `row.target` → `target.totalMileagePerVehicle * target.vehicleCount`
- `row.driven` → `target.cumulativeTotal`
- `row.completion` → `target.avgCompletion`
- `row.period` → `target.period`
- `row.year1Target` → `target.annualMileagePerVehicle`
- `row.reachedCount` → `target.yearQualifiedCount`
- `row.halfReachedCount` → `target.halfQualifiedCount`
- `row.todayTotal` → `target.todayTotal`
- `row.currentYearTarget` → `target.currentYearTarget`
- `row.completedAsOf` → `target.currentYearCompleted`
- `row.remaining` → `target.remaining`
- `row.daysLeft` → `target.daysLeft`
- `row.dailyTarget` → `target.dailyTarget`
4. **项目选择器变更**
- `projectList` → `targets.map(t => t.targetName)`
- 选择按钮的 onClick 改为设置 `selectedTargetId`
- `currentData.trend` → `trendData`
5. **车辆明细变更**
- 原型中通过 `MOCK_VEHICLES.filter(v => ...)` 匹配车辆 → 改为使用 `targetVehicles[target.id]`
- 车辆明细中 `v.plateNumber` → `tv.plateNumber`
- `v.isOnline` → 不可用,暂时全部显示为在线
- `v.todayMileage` → `tv.todayMileage`
- `v.totalMileage` → `tv.totalMileage`
6. **查看全部侧滑面板变更**
- `viewAllModel` 改为 `viewAllTargetId: number | null`
- 点击"查看全部"时设置 `viewAllTargetId` 并调用 `fetchTargetVehicles(id)`
- 面板内车辆数据来自 `targetVehicles[viewAllTargetId]`
7. **保持不变**
- 所有 CSS className
- 图表配置Recharts BarChart/LineChart/AreaChart 的 props、样式、颜色
- 全屏表格叠加层的布局和样式
- 侧滑面板的动画和布局
- 展开/折叠的 AnimatePresence 动画
8. **Import 列表**(完整):
```tsx
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, LineChart, Line, AreaChart, Area,
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 验证 Vite 构建**
Run: `npm run build`
Expected: 构建成功
- [ ] **Step 4: 提交**
```bash
git add src/modules/mileage/StatisticsView.tsx
git commit -m "feat: 实现里程管理统计报表视图1:1 复刻原型)"
```
---
### Task 7: 集成验证
**Files:**
- 无新增文件,全面验证
- [ ] **Step 1: 构建验证**
Run: `npm run build`
Expected: 构建成功
- [ ] **Step 2: 启动开发服务器验证**
Run: `npm run dev`
手动检查:
1. 打开 `http://localhost:3000` → 侧边栏/底部导航有"里程管理"入口
2. 点击进入里程管理 → 看到 3 个子 Tab实时监控/统计报表/每日汇报)
3. **实时监控 Tab**
- KPI 卡片显示真实数据(总里程、平均单车、监控台数)
- 车辆列表加载 1004 辆车,按今日里程降序
- 筛选器正常工作(按部门、按客户、按车牌)
- 高级筛选面板可展开/折叠
- 排序切换(今日/累计、升/降序)正常
- 全屏模式正常打开/关闭
4. **统计报表 Tab**
- 5 个项目按钮正确显示
- 趋势图切换(柱状/折线/面积)正常
- 车型考核汇总卡片展开/折叠正常
- 展开详情显示真实考核数据
- 车辆明细前5台正确显示
- "查看全部"侧滑面板正常
- 全屏表格正常
5. **每日汇报 Tab**:显示"开发中"占位
- [ ] **Step 3: 提交(如有修复)**
```bash
git add -A
git commit -m "fix: 里程管理模块集成修复"
```

View File

@@ -0,0 +1,509 @@
# 模块化重构实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将单体 App.tsx 拆分为模块化架构,支持多 BI 大类(资产管理、里程管理)通过全局导航切换。
**Architecture:** 新增 Shell 布局组件管理全局导航Web 侧边栏 / 移动端底部导航),每个 BI 模块作为独立目录modules/assets、modules/mileage通过 hash 路由切换。现有资产管理逻辑原样迁入 modules/assets/,去掉其内部底部导航。
**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Lucide Icons, Vite
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 创建 | `src/components/SearchSelect.tsx` | 从 App.tsx 抽取的公共搜索下拉组件 |
| 创建 | `src/components/Shell.tsx` | 全局布局壳(侧边栏 + 底部导航 + 内容区) |
| 移动 | `src/types.ts``src/modules/assets/types.ts` | 资产管理类型定义 |
| 移动 | `src/api.ts``src/modules/assets/api.ts` | 资产管理 API 客户端 |
| 创建 | `src/modules/assets/AssetsModule.tsx` | 资产管理主组件(现 App.tsx 逻辑迁入) |
| 创建 | `src/modules/mileage/MileageModule.tsx` | 里程管理占位组件 |
| 重写 | `src/App.tsx` | 顶层壳:模块注册 + Shell 渲染 |
---
### Task 1: 抽取 SearchSelect 公共组件
**Files:**
- Create: `src/components/SearchSelect.tsx`
- [ ] **Step 1: 创建 SearchSelect 组件文件**
从现有 `src/App.tsx` 第 38-106 行抽取 SearchSelect 组件,加上必要的 import
```tsx
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown } from 'lucide-react';
export function SearchSelect({ value, onChange, options, placeholder, className }: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const filtered = useMemo(() => {
if (!query) return options;
const q = query.toLowerCase();
return options.filter((o) => o.toLowerCase().includes(q));
}, [options, query]);
const displayValue = value || '';
return (
<div ref={ref} className="relative">
<div
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
onClick={() => setOpen(!open)}
>
<input
type="text"
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
placeholder={displayValue || placeholder}
value={open ? query : displayValue}
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setQuery(''); }}
/>
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
<div
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
>
{placeholder}
</div>
{filtered.map((o) => (
<div
key={o}
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
>
{o}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-xs text-gray-400 text-center"></div>
)}
</div>
)}
</div>
);
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: 无新增错误SearchSelect 尚未被引用,不影响现有代码)
- [ ] **Step 3: 提交**
```bash
git add src/components/SearchSelect.tsx
git commit -m "refactor: 抽取 SearchSelect 为公共组件"
```
---
### Task 2: 移动 types.ts 和 api.ts 到 assets 模块
**Files:**
- Move: `src/types.ts``src/modules/assets/types.ts`
- Move: `src/api.ts``src/modules/assets/api.ts`
- Modify: `src/App.tsx` (更新 import 路径)
- Modify: `src/modules/assets/api.ts` (更新 import 路径)
- [ ] **Step 1: 创建目录并移动文件**
```bash
mkdir -p src/modules/assets
git mv src/types.ts src/modules/assets/types.ts
git mv src/api.ts src/modules/assets/api.ts
```
- [ ] **Step 2: 更新 api.ts 内部的 import 路径**
`src/modules/assets/api.ts` 第 1-9 行import 路径从 `'./types'` 保持不变(同目录),无需修改。
- [ ] **Step 3: 更新 App.tsx 的 import 路径**
`src/App.tsx` 中的两处 import
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
import type { WeeklyDetailItem } from './api';
```
改为:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
import type { WeeklyDetailItem } from './modules/assets/api';
```
- [ ] **Step 4: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS无错误
- [ ] **Step 5: 验证开发服务器启动**
Run: `npm run dev:client` (启动后 Ctrl+C 关闭)
Expected: Vite 正常启动,无编译错误
- [ ] **Step 6: 提交**
```bash
git add -A
git commit -m "refactor: 移动 types.ts 和 api.ts 到 modules/assets/"
```
---
### Task 3: 创建 AssetsModule 组件
**Files:**
- Create: `src/modules/assets/AssetsModule.tsx`
- Modify: `src/App.tsx` (后续 Task 5 重写时替换)
这一步将 `src/App.tsx``export default function App()` 及其上方的 `TABS` 常量迁移为 `AssetsModule`,并做以下调整:
- [ ] **Step 1: 创建 AssetsModule.tsx**
复制 `src/App.tsx` 的全部内容到 `src/modules/assets/AssetsModule.tsx`,然后做以下修改:
**修改 1 — import 路径调整(文件顶部):**
将:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
import type { WeeklyDetailItem } from './modules/assets/api';
```
改为:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
import type { WeeklyDetailItem } from './api';
```
**修改 2 — SearchSelect 改为外部导入:**
删除文件中第 37-106 行的 SearchSelect 组件定义(`// --- SearchSelect Component ---` 到闭合的 `}`),替换为 import
```ts
import { SearchSelect } from '../../components/SearchSelect';
```
**修改 3 — 删除 import 中不再需要的图标:**
从 lucide-react import 中移除 `Users``Building2`(这两个只被底部导航使用,删除底部导航后不再需要)。`MapPin` 保留(在内容区域第 2106 行仍被使用)。
**修改 4 — 组件名改为 AssetsModule**
将:
```ts
export default function App() {
```
改为:
```ts
export default function AssetsModule() {
```
**修改 5 — 删除底部导航栏(原第 2772-2802 行):**
删除从 `{/* Footer / Navigation */}` 到其对应 `</div>` 的整个代码块:
```tsx
{/* Footer / Navigation */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-40">
......
</div>
```
**修改 6 — 去掉底部导航的 padding 留白:**
将根 div 的 className
```
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 pb-20 md:pb-6 relative
```
改为:
```
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative
```
即去掉 `pb-20 md:pb-6`,统一使用 `p-6` 的 padding。
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASSAssetsModule 已自包含App.tsx 仍引用旧路径但即将被重写)
- [ ] **Step 3: 提交**
```bash
git add src/modules/assets/AssetsModule.tsx
git commit -m "refactor: 创建 AssetsModule迁移资产管理逻辑"
```
---
### Task 4: 创建 Shell 布局组件
**Files:**
- Create: `src/components/Shell.tsx`
- [ ] **Step 1: 创建 Shell.tsx**
```tsx
import { useState, useEffect, type ComponentType } from 'react';
import { Truck, Route } from 'lucide-react';
export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component: ComponentType;
}
function getHashModule(modules: ModuleConfig[]): string {
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : modules[0]?.id ?? '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const [activeModule, setActiveModule] = useState(() => getHashModule(modules));
useEffect(() => {
const onHashChange = () => setActiveModule(getHashModule(modules));
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [modules]);
useEffect(() => {
if (!window.location.hash) {
window.location.hash = modules[0]?.id ?? '';
}
}, [modules]);
const switchModule = (id: string) => {
window.location.hash = id;
};
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
return (
<div className="flex min-h-screen">
{/* Web 侧边栏 (md 及以上) */}
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Icon size={22} />
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
</button>
);
})}
</nav>
{/* 内容区 */}
<main className="flex-1 md:ml-16 pb-16 md:pb-0">
{ActiveComponent && <ActiveComponent />}
</main>
{/* 移动端底部导航 (md 以下) */}
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
>
<Icon size={20} />
<span className="text-[10px] mt-1">{m.label}</span>
</button>
);
})}
</nav>
</div>
);
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASSShell 尚未被引用)
- [ ] **Step 3: 提交**
```bash
git add src/components/Shell.tsx
git commit -m "feat: 创建 Shell 布局组件(侧边栏 + 底部导航)"
```
---
### Task 5: 创建里程管理占位组件
**Files:**
- Create: `src/modules/mileage/MileageModule.tsx`
- [ ] **Step 1: 创建 MileageModule.tsx**
```tsx
import { Route } from 'lucide-react';
export default function MileageModule() {
return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center">
<Route size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
mkdir -p src/modules/mileage
git add src/modules/mileage/MileageModule.tsx
git commit -m "feat: 创建里程管理占位组件"
```
---
### Task 6: 重写 App.tsx 为顶层壳
**Files:**
- Rewrite: `src/App.tsx`
- [ ] **Step 1: 重写 App.tsx**
用以下内容完全替换 `src/App.tsx`
```tsx
import { Truck, Route } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
const MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
export default function App() {
return <Shell modules={MODULES} />;
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS无错误
- [ ] **Step 3: 验证 Vite 构建**
Run: `npm run build`
Expected: 构建成功,无错误
- [ ] **Step 4: 本地验证功能**
Run: `npm run dev`
手动检查:
1. 打开 `http://localhost:3000` — 应看到左侧侧边栏Web或底部导航移动端模拟
2. 默认进入资产管理,所有 Tab总览/按部门/按区域/按客户)正常
3. 数据正常加载显示
4. 点击"里程管理"切换到占位页面
5. 点击"资产管理"切回,数据和状态正常
6. URL hash 随切换变化(`#assets` / `#mileage`
7. 原有移动端底部的资产内部导航已消失
- [ ] **Step 5: 提交**
```bash
git add src/App.tsx
git commit -m "refactor: 重写 App.tsx 为模块化顶层壳"
```
---
### Task 7: 清理旧文件引用
**Files:**
- Delete: `src/types.ts` (如果 git mv 未处理干净)
- Delete: `src/api.ts` (如果 git mv 未处理干净)
- Verify: 无残留的旧 import 路径
- [ ] **Step 1: 确认无残留文件**
```bash
ls src/types.ts src/api.ts 2>/dev/null && echo "STALE FILES EXIST" || echo "CLEAN"
```
Expected: `CLEAN`Task 2 已用 `git mv` 移动)
如果有残留,删除它们:
```bash
rm -f src/types.ts src/api.ts
```
- [ ] **Step 2: 确认无旧 import 引用**
```bash
grep -r "from '\./types'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
grep -r "from '\./api'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
```
Expected: 无输出(所有引用都已更新到新路径)
- [ ] **Step 3: 最终构建验证**
Run: `npm run build`
Expected: 构建成功
- [ ] **Step 4: 提交(如有清理)**
```bash
git add -A
git status
# 如果有变更则提交:
git commit -m "chore: 清理残留文件和旧引用"
```

View File

@@ -0,0 +1,926 @@
# Mileage Backend Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor `src/server/routes/mileage.ts` (569 lines) into well-typed, modular files with clear responsibilities, eliminating duplicate logic and `as any` casts.
**Architecture:** Split the monolithic route file into: shared types, a reusable vehicle-info query module, a monitoring cache module, and focused route handlers. The API contract (request/response shapes) stays identical — this is a pure internal refactor with zero frontend changes.
**Tech Stack:** Hono, mysql2/promise, TypeScript strict types
---
## File Structure
| File | Responsibility |
|------|---------------|
| `src/server/routes/mileage/types.ts` | All interfaces for mileage domain (cache, vehicles, filters, API responses) |
| `src/server/routes/mileage/vehicle-info.ts` | Shared SQL + helper to build plate→info Map from `lingniu_prod` |
| `src/server/routes/mileage/cache.ts` | Monitoring cache: refresh logic, data merging, filter precomputation, target mapping |
| `src/server/routes/mileage/monitoring.ts` | `GET /monitoring` route handler |
| `src/server/routes/mileage/targets.ts` | `GET /targets`, `GET /target/:id/vehicles` route handlers |
| `src/server/routes/mileage/trend.ts` | `GET /trend` route handler |
| `src/server/routes/mileage/index.ts` | Hono app assembly: imports routes, starts cache timer, exports app |
After refactor, delete: `src/server/routes/mileage.ts` (the old monolith).
## Constraints
- **Zero API changes** — all request params and response JSON shapes must remain identical
- **Zero frontend changes** — `src/modules/mileage/api.ts` and `types.ts` stay untouched
- **Preserve all existing behavior** including cache refresh interval, date queries, filter logic
---
### Task 1: Create shared types
**Files:**
- Create: `src/server/routes/mileage/types.ts`
- [ ] **Step 1: Create the types file**
```typescript
// src/server/routes/mileage/types.ts
/** 缓存中的单辆车数据 */
export interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
yesterdayKm: number;
}
/** 车牌前缀统计 */
export interface PlatePrefix {
prefix: string;
count: number;
}
/** 筛选选项(前端下拉) */
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
projects: string[];
entities: string[];
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
}
/** 监控缓存 */
export interface MonitoringCache {
vehicles: CachedVehicle[];
stats: { totalToday: number; totalAll: number; vehicleCount: number };
filters: MonitoringFilters;
targetPlatesMap: Map<string, Set<string>>;
updatedAt: string;
}
/** /monitoring 响应中的统计 */
export interface MonitoringStats {
totalToday: number;
totalAll: number;
vehicleCount: number;
yesterdayTotal: number;
}
/** /monitoring 完整响应 */
export interface MonitoringResponse {
vehicles: CachedVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow {
plate: string;
customer: string | null;
department: string | null;
manager: string | null;
rent_status: string | null;
entity: string | null;
project: string | null;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors (new file has no imports/consumers yet)
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/types.ts
git commit -m "refactor: extract mileage shared types"
```
---
### Task 2: Extract vehicle-info query module
**Files:**
- Create: `src/server/routes/mileage/vehicle-info.ts`
- [ ] **Step 1: Create the vehicle-info module**
This extracts the `VEHICLE_INFO_SQL` constant and a helper function to build the info Map. Both the cache builder and the `/target/:id/vehicles` route reuse this.
```typescript
// src/server/routes/mileage/vehicle-info.ts
import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
/** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/vehicle-info.ts
git commit -m "refactor: extract vehicle-info query module"
```
---
### Task 3: Extract monitoring cache module
**Files:**
- Create: `src/server/routes/mileage/cache.ts`
- [ ] **Step 1: Create the cache module**
This contains the cache singleton, refresh logic, and the `queryDateMileage` function. Both used to live in the monolith.
```typescript
// src/server/routes/mileage/cache.ts
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix } from './types.js';
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
return monitoringCache;
}
/** 部门排序顺序 */
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
/** 从车辆列表计算筛选选项 */
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
);
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
}
/** 将里程原始行 + 车辆信息合并为 CachedVehicle 列表 */
function mergeVehicles(
mileageRows: { plate: string; vin: string; daily_km: string; total_km: string | null; source: string }[],
infoMap: Map<string, { customer: string | null; department: string | null; manager: string | null; rent_status: string | null; entity: string | null; project: string | null }>,
yesterdayMap: Map<string, number>,
): CachedVehicle[] {
// 去重:同一 plate 取 daily_km 最大的
const mileageMap = new Map<string, typeof mileageRows[0]>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(mileageMap.values()).map(m => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
}
/** 刷新监控缓存(从两个数据库并行查询) */
export async function refreshMonitoringCache(): Promise<void> {
try {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([
// 最新日期的里程数据
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [any[], unknown];
return rows;
})(),
// 昨日里程(用于环比)
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [any[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
// 车辆关联信息
fetchVehicleInfoMap(),
// 考核批次→车牌映射
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
]);
// 构建批次映射
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: buildFilters(vehicles, targetNames),
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
} catch (e: unknown) {
console.error('[mileage] cache refresh error:', e);
}
}
/** 查询指定日期的里程数据(不使用缓存) */
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as any[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as any[]),
fetchVehicleInfoMap(),
]);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
return mergeVehicles(mileageRows, infoMap, yesterdayMap);
}
/** 构建指定日期数据的筛选选项 */
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/cache.ts
git commit -m "refactor: extract monitoring cache module"
```
---
### Task 4: Create monitoring route handler
**Files:**
- Create: `src/server/routes/mileage/monitoring.ts`
- [ ] **Step 1: Create the monitoring route**
```typescript
// src/server/routes/mileage/monitoring.ts
import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
total: 0,
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
};
/** 应用筛选条件 */
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
if (params.search) {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
return result;
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const filterParams = {
search: c.req.query('search') || '',
dept: c.req.query('dept') || '',
customer: c.req.query('customer') || '',
project: c.req.query('project') || '',
entity: c.req.query('entity') || '',
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};
// 获取数据源
let allVehicles: CachedVehicle[];
let filters: MonitoringFilters;
if (date) {
try {
allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else {
const cache = getCache();
if (!cache) return c.json(EMPTY_RESPONSE);
allVehicles = cache.vehicles;
filters = cache.filters;
}
// 筛选
const filtered = applyFilters(allVehicles, filterParams);
// 统计
const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
// 排序
const sorted = [...filtered].sort((a, b) => {
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
// 分页
const offset = (page - 1) * limit;
const paged = sorted.slice(offset, offset + limit);
const total = filtered.length;
return c.json({
vehicles: paged,
stats,
filters,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
});
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/monitoring.ts
git commit -m "refactor: create monitoring route handler"
```
---
### Task 5: Create targets route handler
**Files:**
- Create: `src/server/routes/mileage/targets.ts`
- [ ] **Step 1: Create the targets route**
```typescript
// src/server/routes/mileage/targets.ts
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
const app = new Hono();
// GET /targets — 考核项目列表 + 汇总
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
const list = periodsMap.get(p.target_id) || [];
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
periodsMap.set(p.target_id, list);
}
// 使用监控缓存里程数据(与里程看板一致)
const cache = getCache();
const cacheVehicleMap = new Map<string, number>();
if (cache) {
for (const v of cache.vehicles) {
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
}
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
const list = targetIdPlatesMap.get(r.target_id) || [];
list.push(r.plate_number);
targetIdPlatesMap.set(r.target_id, list);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
const startDate = t.default_start_date ? new Date(t.default_start_date).toISOString().split('T')[0] : '';
const endDate = t.default_end_date ? new Date(t.default_end_date).toISOString().split('T')[0] : '';
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
}
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('targets error:', e);
return c.json([], 500);
}
});
// GET /target/:id/vehicles — 某项目的车辆明细
app.get('/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as [any[], unknown];
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
// 指定日期时,从里程库查该日里程
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/targets.ts
git commit -m "refactor: create targets route handler"
```
---
### Task 6: Create trend route handler
**Files:**
- Create: `src/server/routes/mileage/trend.ts`
- [ ] **Step 1: Create the trend route**
```typescript
// src/server/routes/mileage/trend.ts
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
})));
} catch (e: unknown) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/server/routes/mileage/trend.ts
git commit -m "refactor: create trend route handler"
```
---
### Task 7: Assemble new index and swap in
**Files:**
- Create: `src/server/routes/mileage/index.ts`
- Delete: `src/server/routes/mileage.ts`
- [ ] **Step 1: Create the new index**
```typescript
// src/server/routes/mileage/index.ts
import { Hono } from 'hono';
import { refreshMonitoringCache } from './cache.js';
import monitoringRouter from './monitoring.js';
import targetsRouter from './targets.js';
import trendRouter from './trend.js';
const app = new Hono();
app.route('/monitoring', monitoringRouter);
app.route('/targets', targetsRouter);
app.route('/target', targetsRouter);
app.route('/trend', trendRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
export default app;
```
- [ ] **Step 2: Delete the old monolith**
```bash
rm src/server/routes/mileage.ts
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Verify the server starts and API works**
Run: `npm run dev` and test:
- `curl http://localhost:3001/api/mileage/monitoring?limit=2` — should return vehicles
- `curl http://localhost:3001/api/mileage/targets` — should return target list
- `curl http://localhost:3001/api/mileage/trend?days=7` — should return trend data
- [ ] **Step 5: Commit**
```bash
git add src/server/routes/mileage/ && git add -u src/server/routes/mileage.ts
git commit -m "refactor: replace mileage monolith with modular route files"
```
---
### Task 8: Fix the stale comment and final cleanup
**Files:**
- Modify: `src/server/routes/mileage/cache.ts`
- [ ] **Step 1: Verify no leftover references to old file**
Run: `grep -r "routes/mileage.js" src/` — should only find `src/server/index.ts` which imports `./routes/mileage.js`. Since we moved to `mileage/index.ts`, the import path `./routes/mileage.js` resolves to `./routes/mileage/index.js` automatically. No change needed.
- [ ] **Step 2: Verify full build**
Run: `npx tsc --noEmit && npm run build`
Expected: no errors
- [ ] **Step 3: Final commit**
```bash
git commit --allow-empty -m "refactor: mileage backend refactor complete — verified build"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
# 三大运营统计模块设计
从 lnoneos 原型迁移到 ln-bi 生产项目,使用真实 MySQL 数据。
## 架构决策
- **数据源**:复用现有 `getVehicles()` 缓存(~1000 辆,内存聚合无性能问题)
- **跳过**出勤率、日均里程无数据源QR Code
- **新增图标**`Search`, `Filter`, `ArrowRightLeft` (lucide-react已安装)
## 模块 1部门运营统计
### 后端 API
**`GET /api/vehicles/dept-stats`** — 返回 `DeptGroup[]`
聚合逻辑:按 `Vehicle.departmentName` 分组,每个部门下按 `Vehicle.customerManager` 分组。每个业务员统计车型分布:
| 车型类别 | 过滤条件 |
|---|---|
| t4_5 | type=4.5T 且 model 不含"冷链" |
| t4_5c | type=4.5T 且 model 含"冷链" |
| t18 | type=18T |
| t49 | type=49T |
| trailer | model 含"挂车" |
| other | 以上都不是 |
部门级别额外字段:`totalAssets`(运营中的)、`operatingCount`status=Operating`idleCount`status=Inventory 或 Abnormal
### 后端:扩展 `/api/vehicles/list`
新增查询参数:
- `manager` — 按客户经理筛选
- `customer` — 按客户名称筛选
- `isColdChain` — true/false筛选冷链/非冷链
- `isTrailer` — true/false筛选挂车/非挂车
### 前端类型
```typescript
interface ManagerStats {
manager: string;
department: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
interface DeptGroup {
department: string;
totalAssets: number;
operatingCount: number;
idleCount: number;
managers: ManagerStats[];
}
```
### 前端 UI
参照 lnoneos 1362-1880 行:
- 顶部深色汇总条(总资产/运营中/闲置中,跳过平均出勤)
- 按部门/按业务员切换
- 桌面表格 + 移动端卡片
- 展开部门显示业务员卡片,展开业务员显示 6 个车型格子(可点击下钻到车牌列表)
## 模块 2区域运营统计
### 后端 API
**`GET /api/vehicles/region-stats`** — 返回 `RegionGroup[]`
新增大区映射函数province/city → 华东/华南/华北/华中/西南/西北/其他)。按大区分组,每个区域下统计:
- 按车型4.5T/18T/49T的资产/运营/库存数
- 列出区域内的客户列表
```typescript
interface RegionGroup {
region: string; // 华东、华南等
totalAssets: number;
operatingCount: number;
inventoryCount: number;
customers: string[];
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
}
```
### 前端 UI
参照 lnoneos 1882-2174 行:
- 筛选弹出框(客户搜索/区域/城市下拉)
- 可展开区域行,展开后显示车型子行
- 桌面表格 + 移动端卡片
## 模块 3客户运营统计
### 后端 API
**`GET /api/vehicles/customer-stats`** — 返回 `CustomerStats[]`
`Vehicle.customerName` 分组(只统计 status=Operating 的车辆),每个客户统计:
- 关联业务员customerManager、品牌brandLabel、部门departmentName
- 大区(从 province/city 映射)、城市
- 6 个车型分列计数 + 合计
```typescript
interface CustomerStats {
customer: string;
manager: string;
brand: string;
department: string;
region: string;
city: string;
t4_5: number;
t4_5c: number;
t18: number;
t49: number;
trailer: number;
other: number;
total: number;
}
```
### 前端 UI
参照 lnoneos 2176-2496 行:
- 筛选弹出框(客户名/业务员搜索,品牌/部门/区域下拉)
- 翡翠绿色主题表头
- 客户表格,各车型列可点击下钻
- 展开后显示 4 个详情卡片(客户详情/主要车型/运营状态/资产占比)
- 桌面表格 + 移动端卡片
## 文件变更清单
| 文件 | 变更 |
|---|---|
| `src/server/routes/vehicles.ts` | 新增 3 个 API 端点 + 扩展 `/list` 的过滤参数 + 大区映射函数 |
| `src/types.ts` | 新增 `DeptGroup`, `ManagerStats`, `CustomerStats`, `RegionGroup` 接口 |
| `src/server/types.ts` | 同步新增后端类型 |
| `src/api.ts` | 新增 `fetchDeptStats`, `fetchRegionStats`, `fetchCustomerStats` |
| `src/App.tsx` | 新增 3 个 section + 相关 state/toggle/filter 逻辑 + 扩展 showPlateNumbers 类型 |
## 实现顺序
1. 后端:大区映射 + 3 个 API + 扩展 list 过滤
2. 前端类型 + API 客户端
3. 部门运营统计 UI
4. 区域运营统计 UI
5. 客户运营统计 UI
6. 验证构建通过

View File

@@ -0,0 +1,242 @@
# 里程管理模块设计
## 背景
在模块化重构的基础上,实现里程管理 BI 模块1:1 复刻原型 `/Users/kkfluous/Projects/ai-coding/ln-yuanxing/lnoneos-1` 中的里程管理部分。包含 3 个子 Tab实时监控、统计报表、每日汇报占位
## 目标
- 1:1 复刻原型 UI样式、动画、交互细节完全一致
- 接入真实数据源(两个数据库)
- 每日汇报 Tab 暂做占位
## 数据源
### 数据库 1lingniu_prod已有连接
- `tab_mileage_assessment_target` — 5 个考核项目定义(目标名称、车辆数、年考核里程、考核年限等)
- `tab_mileage_assessment_vehicle` — 492 辆考核车辆(今日里程、累计里程、完成率、达标状态等)
- `tab_truck``tab_truck_status_info``tab_contract``tab_customer` / `tab_user``tab_department` — 车辆关联客户名、部门、经理
### 数据库 2hydrogen_energy新增连接
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source
## 架构
### 后端
新增 `src/server/mileage-db.ts` — hydrogen_energy 数据库连接池。
新增 `src/server/routes/mileage.ts` — 里程管理 API 路由。
修改 `src/server/index.ts` — 注册新路由 `/api/mileage`
### 前端
```
src/modules/mileage/
├── MileageModule.tsx # 主组件3个子Tab切换实时监控/统计报表/每日汇报)
├── MonitoringView.tsx # 实时监控视图
├── StatisticsView.tsx # 统计报表视图
├── DailyReportView.tsx # 每日汇报(占位)
├── api.ts # API 客户端
└── types.ts # 类型定义
```
### 数据流
```
前端 MileageModule → fetch /api/mileage/*
后端 mileage.ts 路由
├── lingniu_prod 池:考核目标/车辆、车辆关联信息(客户/部门/经理)
└── hydrogen_energy 池v_vehicle_daily_stats日里程/趋势)
↓ 内存合并
前端渲染Recharts 图表 + 列表)
```
## API 端点
### `GET /api/mileage/monitoring`
实时监控数据:全部 1004 辆车的今日里程 + 关联信息。
**查询逻辑:**
1.`v_vehicle_daily_stats` 取最新日期的所有车辆数据plate, daily_km, total_km, source
2.`lingniu_prod` 取车辆关联信息(客户名、部门、经理),使用现有的 `MAIN_SQL` 关联链
3. 内存按 plate 合并
**返回:**
```ts
{
vehicles: Array<{
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string; // TBOX / G7S / NONE
isOnline: boolean; // source !== 'NONE' && dailyKm > 0
isDataSynced: boolean; // source !== 'NONE'
customer: string | null;
department: string | null;
manager: string | null;
}>;
updatedAt: string;
}
```
### `GET /api/mileage/targets`
考核项目列表 + 每个项目的汇总统计。
**查询逻辑:**
1.`tab_mileage_assessment_target` 取全部未删除项目
2.`tab_mileage_assessment_vehicle` 按 target_id 聚合统计
**返回:**
```ts
Array<{
id: number;
targetName: string;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
assessmentYears: number;
period: string; // "YYYY-MM-DD ~ YYYY-MM-DD"
todayTotal: number; // SUM(today_mileage)
cumulativeTotal: number; // SUM(current_mileage)
avgCompletion: number; // AVG(completion_rate) * 100
qualifiedCount: number; // SUM(is_qualified)
yearQualifiedCount: number; // SUM(current_year_is_qualified)
halfQualifiedCount: number; // completion_rate >= 0.5 的车辆数
currentYearTarget: number; // SUM(current_year_mileage_task)
currentYearCompleted: number; // SUM(current_year_mileage)
remaining: number; // currentYearTarget - currentYearCompleted
daysLeft: number; // current_year_assessment_end_date - today
dailyTarget: number; // remaining / daysLeft
}>
```
### `GET /api/mileage/target/:id/vehicles`
某考核项目的车辆明细列表。
**查询逻辑:**
`tab_mileage_assessment_vehicle` WHERE target_id = :id AND is_deleted = 0
**返回:**
```ts
Array<{
plateNumber: string;
todayMileage: number;
totalMileage: number;
completionRate: number;
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
}>
```
### `GET /api/mileage/trend?targetId=...&days=7`
7天里程趋势按考核项目筛选。
**查询逻辑:**
1. 若有 targetId`tab_mileage_assessment_vehicle` 取该项目的所有 plate_number
2.`v_vehicle_daily_stats` WHERE plate IN (...) AND stat_date >= (today - days) GROUP BY stat_date
**返回:**
```ts
Array<{
date: string; // "MM-DD"
mileage: number; // SUM(daily_km)
}>
```
## 前端组件设计
### MileageModule.tsx
主组件,管理子 Tab 切换monitoring / statistics / report包含
- 子导航栏(实时监控/统计报表/每日汇报),带 motion layoutId 动画下划线
- 条件渲染对应 View 组件
### MonitoringView.tsx
1:1 复刻原型实时监控视图。
**状态:**
- activeSubTab 由父组件管理
- searchTerm, filterDept, filterPlate, filterProject, filterEntity, filterRegionCode, filterYear, filterDate, filterDateRange, filterMileageRange
- sortBy ('today' | 'total'), sortOrder ('asc' | 'desc')
- isFilterOpen, isFullscreen
**UI 结构:**
1. 看板头部(标题 + 全屏按钮 + 排序切换)
2. 快捷筛选栏3 个 SearchableSelect + 高级筛选图标)
3. 可展开高级筛选面板
4. KPI 卡片网格4列总里程深色卡、平均单车、监控台数
5. 车辆详情清单motion.div 列表)
6. 全屏叠加层AnimatePresence
**SearchableSelect 组件:** 在 MonitoringView 内部定义(原型中的实现与公共 SearchSelect 不同,它使用 motion 动画、"无限制"默认选项、不同样式)。
### StatisticsView.tsx
1:1 复刻原型统计报表视图。
**状态:**
- selectedProject, chartType ('bar' | 'line' | 'area')
- isTableFullscreen, expandedModel, viewAllModel, viewAllSearch, viewAllSort
**UI 结构:**
1. 项目选择器(横向滚动按钮组)
2. 左侧7天趋势图Recharts BarChart/LineChart/AreaChart 切换)+ landscape KPI 卡片
3. 右侧:车型考核里程汇总卡片列表(可展开详情 + 车辆明细前5台
4. 全屏表格叠加层15列明细表
5. 查看全部侧滑面板(搜索 + 排序 + 车辆列表)
### DailyReportView.tsx
占位组件,显示"每日汇报 - 开发中"。
## 数据映射
### 实时监控
| UI 字段 | 数据来源 |
|---------|---------|
| 车牌号 | `v_vehicle_daily_stats.plate` |
| 今日里程 | `daily_km`(最新日期) |
| 累计里程 | `total_km`(最近非空值,用用户提供的变量填充 SQL |
| 在线状态 | `source !== 'NONE' && daily_km > 0` |
| 数据同步 | `source !== 'NONE'` |
| 客户名 | `lingniu_prod`: tab_truck → tab_truck_status_info → tab_contract → tab_customer.customer_name |
| 部门 | `lingniu_prod`: → tab_user → tab_department.dep_name |
### 统计报表
| UI 字段 | 数据来源 |
|---------|---------|
| 项目列表 | `tab_mileage_assessment_target`target_name, vehicle_count 等) |
| 今日总里程 | `SUM(tab_mileage_assessment_vehicle.today_mileage)` by target_id |
| 累计总里程 | `SUM(current_mileage)` by target_id |
| 平均完成率 | `AVG(completion_rate) * 100` by target_id |
| 达标车辆数 | `SUM(current_year_is_qualified)` by target_id |
| 50%达标数 | `COUNT(completion_rate >= 0.5)` by target_id |
| 考核区间 | `default_start_date ~ default_end_date` |
| 年考核任务/辆 | `annual_mileage_per_vehicle` |
| 本年需完成 | `SUM(current_year_mileage_task)` |
| 已完成 | `SUM(current_year_mileage)` |
| 未完成总数 | 本年需完成 - 已完成 |
| 剩余天数 | `current_year_assessment_end_date - today`(取 vehicle 中的值) |
| 日均需完成 | 未完成 / 剩余天数 |
| 7天趋势 | `v_vehicle_daily_stats` 按项目车牌过滤聚合 |
| 车辆明细 | `tab_mileage_assessment_vehicle` 的 plate_number, today_mileage, total_mileage 等 |
## 不在范围内
- 每日汇报 Tab 具体实现(占位)
- landscape 适配(原型中有 landscape: 前缀样式,照搬即可但不做额外适配工作)
- 后端缓存
- 新增依赖

View File

@@ -0,0 +1,106 @@
# 模块化重构设计:支持多 BI 大类
## 背景
当前项目是一个单体 BI 看板(资产管理),所有前端逻辑集中在 `App.tsx`~2800 行)。后续需要扩展"里程管理"等新 BI 大类。本次重构目标是建立模块化架构,让新模块可以低成本接入,同时不影响现有功能。
## 目标
- 建立模块级拆分架构,每个 BI 大类作为独立模块
- 新增全局导航Web 端侧边栏、移动端底部导航栏
- 去掉现有资产管理内部的移动端底部导航(与顶部 Tab 重复)
- 里程管理模块仅做占位,具体功能后续实现
- 后端不动
## 重构后目录结构
```
src/
├── App.tsx # 顶层Shell + hash 路由分发
├── main.tsx # 入口(不变)
├── index.css # 全局样式(不变)
├── components/ # 公共组件
│ ├── Shell.tsx # 布局壳(侧边栏 + 底部导航 + 内容区)
│ └── SearchSelect.tsx # 搜索下拉组件(从 App.tsx 抽出)
├── modules/
│ ├── assets/ # 资产管理模块
│ │ ├── AssetsModule.tsx # 现有 App.tsx 全部逻辑迁入
│ │ ├── api.ts # 现有 src/api.ts 迁入
│ │ └── types.ts # 现有 src/types.ts 迁入
│ │
│ └── mileage/ # 里程管理模块(占位)
│ └── MileageModule.tsx # 占位组件
└── server/ # 后端完全不动
```
## Shell 布局设计
### 模块注册
```ts
const MODULES = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
// 未来新模块只需往数组加一项
```
### Web 端md 及以上)— 左侧侧边栏
- 侧边栏窄版(约 64px图标 + 文字纵向排列
- 固定定位,内容区有左 margin
- 选中项高亮
### 移动端md 以下)— 底部导航栏
- 底部固定导航栏,切换 BI 大类(资产管理 / 里程管理)
- 替换掉现有资产管理内部的底部导航
### 路由机制
- `window.location.hash``#assets``#mileage`
- 默认无 hash 时进入 `#assets`
- Shell 监听 `hashchange` 事件切换模块
- 不引入任何路由库
## AssetsModule 迁移策略
### 改动
1. 去掉底部导航栏(原 App.tsx 2773-2802 行)
2. 去掉底部导航的 padding 留白
3. SearchSelect 改为从 `components/SearchSelect.tsx` 导入
4. 组件名从 `App` 改为 `AssetsModule`
### 不改
- 所有内部状态管理、Tab 切换、数据加载、图表、弹窗逻辑
- 顶部 Tab 栏(总览/按部门/按区域/按客户)
- API 调用逻辑(仅调整 import 路径)
## 文件迁移映射
| 原文件 | 目标 | 操作 |
|--------|------|------|
| `src/App.tsx` | `src/modules/assets/AssetsModule.tsx` | 迁移内容,去掉底部导航和 SearchSelect |
| `src/api.ts` | `src/modules/assets/api.ts` | 直接移动 |
| `src/types.ts` | `src/modules/assets/types.ts` | 直接移动 |
| App.tsx 中 SearchSelect | `src/components/SearchSelect.tsx` | 抽取为独立文件 |
## 新增文件
| 文件 | 内容 |
|------|------|
| `src/App.tsx`(重写) | 模块注册 + Shell 渲染 |
| `src/components/Shell.tsx` | 全局布局(侧边栏 / 底部导航 + 内容区) |
| `src/modules/mileage/MileageModule.tsx` | 占位页面 |
## 不在范围内
- AssetsModule 内部进一步拆分(保持现有结构)
- 里程管理具体功能实现
- 后端改动
- 新增依赖

View File

@@ -0,0 +1,224 @@
# 智能调度模块设计
基于里程考核数据,通过贪心优先级匹配算法,生成车辆替换建议,帮助调度员优化车队里程分布,最大化达标车辆数。
## 业务背景
公司有多批次考核车辆40台普货、190台冷藏车等每批次有年度里程考核目标。车辆租赁给不同客户客户实际使用强度差异大。考核的是**车辆本身的里程**,因此需要通过替换车辆来均衡里程:
- 高里程客户的已达标车换下来,换上里程缺口大的车(让新车追赶)
- 低里程客户的无望达标车换下来给高里程客户(抢救),给低里程客户换上已达标的车
## 核心算法
### 车辆分类
`tab_mileage_assessment_vehicle` 获取所有考核车辆,按客户聚合计算**客户日均里程**(客户下所有车辆近 30 天日均里程的平均值),然后对每辆车计算:
```
预测年终里程 = 当前累计里程 + 客户日均里程 × 剩余天数
达标概率 = 预测年终里程 / 年度目标里程
```
分为三类:
| 类型 | 条件 | 含义 |
|------|------|------|
| qualified | `currentYearIsQualified = true` 或 达标概率 ≥ 120% | 已完成或铁定完成 |
| hopeless | 达标概率 < 60% | 按当前客户使用强度,年底肯定完不成 |
| normal | 60% ≤ 达标概率 < 120% | 有希望但不确定,暂不干预 |
### 替换建议生成
**场景 Areplace_qualified高里程客户的已达标车辆**
- 目标:把已达标的车换下来,换上里程缺口大的库存车
- 候选池库存车rent_status='在库'+ 同车型 + 同区域
- 排序:优先选剩余缺口最大但换后仍可达标的车
- 校验:`候选车当前累计 + 客户日均 × 剩余天数 ≥ 年度目标` 才推荐
**场景 Brescue_hopeless低里程客户的无望达标车辆**
- 目标:把无望车换给高里程客户抢救,给低里程客户换上已达标/库存车
- 候选池:库存中已达标或将达标的同车型同区域车辆
- 排序:优先选已达标且里程最高的车(对低里程客户无影响)
### 车型匹配规则
| 源车型 | 可替换为 | 说明 |
|--------|---------|------|
| 4.5T冷链 | 4.5T冷链、4.5T普货 | 冷链不开空调可当普货用 |
| 4.5T普货 | 4.5T普货 | 不能反向替换冷链 |
| 18T | 18T | 同型号互换 |
| 49T | 49T | 同型号互换 |
| 挂车 | 挂车 | 同型号互换 |
### 区域匹配规则
复用已有 `mapRegion()` 函数,将 province/city 映射到大区(嘉兴/广东/北京/新疆/其他)。同一大区内可替换,跨大区不推荐。
### 优先级排序
干预清单排序:
1. **hopeless + 有可行替换方案** → priority: high最紧急还能抢救
2. **qualified + 高里程客户 + 有库存可换** → priority: medium释放达标车让新车追赶
## 后端 API
### GET /api/scheduling/suggestions
获取调度建议列表。每次请求实时计算(不使用定时缓存),因为用户操作后需要立即看到最新结果。
**请求参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| targetId | number (可选) | 按批次筛选,不传则全部 |
**响应**
```typescript
{
summary: {
qualifiedCount: number; // 已达标车辆数
hopelessCount: number; // 无望达标车辆数
suggestionCount: number; // 可干预建议数
estimatedGain: number; // 预计干预后可新增达标数
};
suggestions: SchedulingSuggestion[];
targets: { id: number; name: string; vehicleCount: number }[];
}
```
`SchedulingSuggestion` 结构:
```typescript
{
id: string; // 建议唯一ID如 "s-{plate}-{timestamp}"
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: {
plateNumber: string;
targetId: number;
targetName: string; // 所属批次
vehicleType: string; // "4.5T冷链" / "18T" 等
totalMileage: number;
completionRate: number; // 0-1
yearTarget: number; // 年度目标里程
region: string; // 大区(嘉兴/广东等)
province: string; // 原始省份
customer: string;
customerAvgDaily: number; // 客户日均里程
predictedYearEnd: number; // 预测年终里程
daysLeft: number;
};
candidates: {
plateNumber: string;
targetId: number | null; // 库存车可能无批次
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
region: string;
province: string;
mileageGap: number; // 剩余缺口
predictedAfterSwap: number; // 换到该客户后预测年终里程
canQualifyAfterSwap: boolean;
}[];
reason: string; // 建议原因文案
}
```
### POST /api/scheduling/notify
发送替换通知。成功后前端立即重新拉取 suggestions。
**请求体**
```typescript
{
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}
```
操作人从 JWT auth 中获取。
**响应**`{ success: boolean; message: string }`
**行为**:调用外部回调接口发送通知(具体回调 URL 后续配置)。成功后在本地记录已操作状态,后续 GET suggestions 时排除已操作的建议。
### 数据查询流程
后端一次请求聚合以下数据:
1. 所有考核车辆 — `tab_mileage_assessment_vehicle`(里程进度、达标状态)
2. 所有考核目标 — `tab_mileage_assessment_target`(批次名称、年度目标)
3. 库存车辆 — `tab_truck WHERE truck_rent_status = 0`(在库)+ 同表获取车型
4. 车辆实时位置 — `tab_truck_remote_sync_realtime_info`province, city
5. 合同/客户信息 — 复用 `vehicle-info.ts` 已有的 JOIN 查询
6. 客户日均里程 — 按客户聚合 `v_vehicle_daily_stats` 近 30 天均值
## 前端结构
### 文件组织
```
src/modules/scheduling/
├── SchedulingModule.tsx // 主入口,状态管理和数据加载
├── SuggestionList.tsx // 干预建议清单列表
├── SuggestionDetail.tsx // 单条建议展开详情(含替换车辆对比)
├── api.ts // fetchSuggestions(), sendNotify()
└── types.ts // SchedulingSuggestion 等类型定义
```
后端:
```
src/server/routes/scheduling/
├── index.ts // 路由注册
├── suggestions.ts // GET /suggestions 算法核心
└── notify.ts // POST /notify 回调通知
```
### 页面层级
```
智能调度 Tab
├── 顶部:批次选择器(复用里程统计的批次 tabs默认"全部"
├── 统计卡片区3 个)
│ ├── 已达标车辆数(绿色)
│ ├── 无望达标车辆数(红色)
│ └── 可干预建议数 + 预计可新增达标数(蓝色)
├── 干预建议清单(主列表,按优先级排序)
│ ├── 每条:车牌、批次、客户、客户日均、完成率、区域、类型标签(已达标/无望)
│ └── 点击 → 展开干预详情
└── 干预详情(弹窗)
├── 当前车辆信息卡片
├── 推荐替换车辆列表(最多 5 辆)
│ └── 每辆显示对比:替换前后的区域、车型、里程、预测达标
├── 建议原因说明
└── 「发送替换通知」按钮 → notify 接口 → 成功后刷新列表
```
### UI 设计要求
- 以原型 `SmartSchedulingView` 组件为基础风格
- 使用 ui-ux-pro-max 优化视觉质量
- 适配移动端(竖屏卡片流)和 Web 端landscape 横屏大表格)
- 干预详情弹窗需截图友好:完整卡片布局、替换前后对比一屏可见、关键数据醒目
- 统计卡片区保持与原型一致的三列 grid 布局
- 批次选择器横向滚动 pill 按钮样式
### 技术栈
复用项目已有React 19 + Tailwind CSS + motion/react动画+ recharts图表+ lucide-react图标
## 约束与边界
- 替换仅为建议,不直接操作数据库修改车辆归属
- 不能推荐已租赁给其他客户的车辆,只从库存(在库)中推荐
- 跨批次可替换,但车型必须匹配(含冷链→普货单向规则)
- 同大区内替换,不跨大区
- notify 操作后数据立即更新(不使用定时缓存)
- 客户名称展示需使用已有的脱敏/Blur 组件

155
package-lock.json generated
View File

@@ -1,16 +1,18 @@
{
"name": "ln-bi",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"dotenv": "^16.4.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
@@ -1583,6 +1585,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -1721,6 +1739,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001781",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
@@ -2031,6 +2055,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.325",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
@@ -2346,6 +2379,61 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2607,6 +2695,48 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2702,7 +2832,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -3000,6 +3129,26 @@
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.0",
"version": "1.1.5",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -13,8 +13,10 @@
},
"dependencies": {
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"dotenv": "^16.4.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",

View File

@@ -0,0 +1,178 @@
沪A00113F
沪A00220F
沪A00333F
沪A00607F
沪A01056F
沪A01311F
沪A01775F
沪A01813F
沪A01855F
沪A02303F
沪A02311F
沪A02326F
沪A02361F
沪A02720F
沪A03086F
沪A03397F
沪A03565F
沪A03620F
沪A03659F
沪A03801F
沪A03870F
沪A05035F
沪A05113F
沪A05223F
沪A05501F
沪A05675F
沪A05697F
沪A05830F
沪A06335F
沪A06599F
沪A06695F
沪A07006F
沪A07153F
沪A07806F
沪A08037F
沪A08150F
沪A08315F
沪A08598F
沪A08786F
沪A09100F
沪A09251F
沪A09276F
沪A09303F
沪A09313F
沪A09322F
沪A09689F
沪A30010F
沪A30399F
沪A31031F
沪A31211F
沪A31281F
沪A31308F
沪A31381F
沪A31613F
沪A32269F
沪A33216F
沪A35236F
沪A35798F
沪A35879F
沪A35898F
沪A36133F
沪A36169F
沪A36569F
沪A36980F
沪A37785F
沪A38795F
沪A39287F
沪A39289F
沪A39585F
沪A39608F
沪A39626F
沪A39815F
沪A39835F
沪A39912F
沪A50026F
沪A50069F
沪A50309F
沪A51580F
沪A51612F
沪A51677F
沪A51893F
沪A52331F
沪A52511F
沪A53309F
沪A53322F
沪A53506F
沪A53960F
沪A55179F
沪A55297F
沪A55339F
沪A55666F
沪A55695F
沪A56122F
沪A56701F
沪A56959F
沪A56988F
沪A57139F
沪A57167F
沪A57198F
沪A57838F
沪A57850F
沪A57895F
沪A58087F
沪A58159F
沪A58185F
沪A58307F
沪A58533F
沪A58538F
沪A58593F
沪A58922F
沪A59095F
沪A59510F
沪A59613F
沪A59682F
沪A59799F
沪A59932F
沪A60339F
沪A60691F
沪A60820F
沪A61187F
沪A61193F
沪A61312F
沪A61559F
沪A61600F
沪A61711F
沪A61738F
沪A62322F
沪A62772F
沪A62928F
沪A63013F
沪A63305F
沪A63522F
沪A63660F
沪A63697F
沪A65036F
沪A65181F
沪A65522F
沪A65995F
沪A66216F
沪A66256F
沪A66329F
沪A66593F
沪A66710F
沪A66921F
沪A67018F
沪A67033F
沪A67872F
沪A68115F
沪A68139F
沪A68332F
沪A68613F
沪A68658F
沪A68752F
沪A69311F
沪A69826F
沪A69997F
沪A85021F
沪A89315F
沪A89385F
沪A89662F
浙F00885F
浙F08889F
浙F09898F
粤A00255F
粤A02683F
粤A02956F
粤A03502F
粤A03532F
粤A03569F
粤A05106F
粤A05391F
粤A05428F
粤A05839F
粤A05985F
粤A05995F
粤A06569F
粤A06931F
粤A06932F

60
scripts-tmp/find_extra.ts Normal file
View File

@@ -0,0 +1,60 @@
import mysql from 'mysql2/promise';
import fs from 'node:fs';
const pool = mysql.createPool({
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
port: 3306,
user: 'root',
password: 'LN#Passw0rd@2026',
database: 'lingniu_prod',
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
});
async function main() {
const excelPlates = new Set(
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
);
console.log('excel plates:', excelPlates.size);
// 按 dept-stats 逻辑查金可鹏 18T Operating
const [rows] = await pool.query<any[]>(`
SELECT truck.plate_number AS plate,
dic_type.dic_name AS type_label,
dic_status.dic_name AS status_label,
cus.customer_name AS customer,
org_truck.org_name AS subject_org
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
WHERE truck.is_deleted=0 AND truck.is_operation=1
AND u.user_name='金可鹏'
AND dic_type.dic_name LIKE '%18吨%'
AND dic_status.dic_name IN ('租赁','自营','挂靠')
ORDER BY truck.plate_number
`);
console.log('DB 金可鹏 18T operating:', rows.length);
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
console.log('数量:', extra.length);
for (const p of extra) {
const r = (rows as any[]).find((x) => x.plate === p);
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
}
console.log('\n=== Excel 有但 DB 没有 ===');
console.log('数量:', missing.length);
for (const p of missing) console.log(' ', p);
await pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });

File diff suppressed because it is too large Load Diff

111
src/auth/AuthProvider.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { AuthContext, type AuthState } from './useAuth';
import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth';
export default function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
error: null,
});
const tokenRef = useRef<string | null>(null);
const authStarted = useRef(false);
useEffect(() => {
// 设置全局 token getter
setTokenGetter(() => tokenRef.current);
// 防止 StrictMode 双重调用jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件
const onUnauthorized = () => {
tokenRef.current = null;
sessionStorage.removeItem('bi_jwt');
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
};
window.addEventListener('auth:unauthorized', onUnauthorized);
authenticate();
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
}, []);
async function authenticate() {
// 1. 检查 sessionStorage 中是否有 JWT
const savedToken = sessionStorage.getItem('bi_jwt');
if (savedToken) {
tokenRef.current = savedToken;
// 验证 token 是否仍然有效(尝试请求 health
try {
const res = await fetch('/api/health', {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (res.ok) {
const savedUser = sessionStorage.getItem('bi_user');
setState({
isLoading: false,
isAuthenticated: true,
user: savedUser ? JSON.parse(savedUser) : null,
error: null,
});
return;
}
} catch { /* token 无效,继续流程 */ }
sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
}
// 2. 从 URL 提取 jumpToken
const params = new URLSearchParams(window.location.search);
const jumpToken = params.get('jumpToken');
if (!jumpToken) {
// 演示模式:无 token 时直接放行
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
return;
}
try {
// 3. 一步完成jumpToken → 用户信息 + JWT
const res = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`);
const data = await res.json();
if (!res.ok || !data.token) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: data.message || '跳转令牌无效或已过期' });
return;
}
// 4. 存储 JWT
tokenRef.current = data.token;
sessionStorage.setItem('bi_jwt', data.token);
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
// 6. 清除 URL 中的 jumpToken
params.delete('jumpToken');
const cleanUrl = params.toString()
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
: `${window.location.pathname}${window.location.hash}`;
window.history.replaceState({}, '', cleanUrl);
setState({
isLoading: false,
isAuthenticated: true,
user: data.user,
error: null,
});
} catch (e) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
}
}
return (
<AuthContext.Provider value={state}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,37 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
export default function UnauthorizedPage({ message }: { message?: string }) {
return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center max-w-sm">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
<ShieldX size={36} className="text-slate-400" />
</div>
<h1 className="text-lg font-black text-slate-800 mb-2">访</h1>
<p className="text-xs text-slate-400 mb-4">
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
</p>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center"></p>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-slate-700">PC端</p>
<p className="text-[10px] text-slate-400 mt-0.5">BI BI</p>
</div>
</div>
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
<Smartphone size={16} className="text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs font-bold text-slate-700"></p>
<p className="text-[10px] text-slate-400 mt-0.5">BI BI</p>
</div>
</div>
</div>
</div>
</div>
);
}

24
src/auth/api-client.ts Normal file
View File

@@ -0,0 +1,24 @@
/** 全局认证 fetch 客户端 */
let tokenGetter: () => string | null = () => null;
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn;
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
}

25
src/auth/useAuth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react';
export interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
user: {
userId: string;
userName: string;
permissionLevel: string;
depName: string;
roles?: string[];
} | null;
error: string | null;
}
export const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
error: null,
});
export function useAuth() {
return useContext(AuthContext);
}

17
src/components/Blur.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { createContext, useContext, type ReactNode } from 'react';
const DemoModeContext = createContext(false);
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
}
export function useDemoMode() {
return useContext(DemoModeContext);
}
export default function Blur({ children }: { children: ReactNode }) {
const demo = useContext(DemoModeContext);
if (!demo) return <>{children}</>;
return <span className="blur-[5px] select-none">{children}</span>;
}

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown } from 'lucide-react';
export function MultiSearchSelect({ value, onChange, options, placeholder, className }: {
value: string[];
onChange: (v: string[]) => void;
options: string[];
placeholder: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const filtered = useMemo(() => {
if (!query) return options;
const q = query.toLowerCase();
return options.filter((o) => o.toLowerCase().includes(q));
}, [options, query]);
const toggle = (name: string) => {
onChange(value.includes(name) ? value.filter(c => c !== name) : [...value, name]);
};
return (
<div ref={ref} className="relative">
{value.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{value.map(c => (
<span key={c} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-medium">
{c}
<button onClick={() => toggle(c)} className="hover:text-red-500 ml-0.5 leading-none">&times;</button>
</span>
))}
</div>
)}
<div
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-emerald-500/20 focus-within:border-emerald-500 ${className || 'text-xs py-1.5 px-2'}`}
onClick={() => setOpen(!open)}
>
<input
type="text"
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
placeholder={value.length > 0 ? `已选 ${value.length} 个客户` : placeholder}
value={open ? query : ''}
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setQuery(''); }}
/>
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
{value.length > 0 && (
<div
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50 border-b border-gray-100"
onClick={() => onChange([])}
>
</div>
)}
{filtered.map(o => (
<div
key={o}
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-emerald-50 transition-colors flex items-center gap-1.5 ${value.includes(o) ? 'bg-emerald-50 text-emerald-700 font-medium' : 'text-gray-700'}`}
onClick={() => toggle(o)}
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${value.includes(o) ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-gray-300'}`}>
{value.includes(o) && <span className="text-[9px]">&#10003;</span>}
</span>
{o}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-xs text-gray-400 text-center"></div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown } from 'lucide-react';
export function SearchSelect({ value, onChange, options, placeholder, className }: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const filtered = useMemo(() => {
if (!query) return options;
const q = query.toLowerCase();
return options.filter((o) => o.toLowerCase().includes(q));
}, [options, query]);
const displayValue = value || '';
return (
<div ref={ref} className="relative">
<div
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
onClick={() => setOpen(!open)}
>
<input
type="text"
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
placeholder={displayValue || placeholder}
value={open ? query : displayValue}
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setQuery(''); }}
/>
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
<div
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
>
{placeholder}
</div>
{filtered.map((o) => (
<div
key={o}
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
>
{o}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-xs text-gray-400 text-center"></div>
)}
</div>
)}
</div>
);
}

131
src/components/Shell.tsx Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,21 +7,33 @@ import type {
CustomerStats,
RegionalInventoryStats,
} from './types';
import { fetchJson } from '../../auth/api-client';
const BASE = '/api/vehicles';
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
export interface SubjectOption {
name: string;
total: number;
inventory: number;
operating: number;
}
export async function fetchSummary(): Promise<SummaryData> {
return fetchJson<SummaryData>(`${BASE}/summary`);
function withSubject(path: string, subject?: string | null): string {
if (!subject) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}subject=${encodeURIComponent(subject)}`;
}
export async function fetchByType(): Promise<TypeSummary[]> {
return fetchJson<TypeSummary[]>(`${BASE}/by-type`);
export async function fetchSubjects(): Promise<SubjectOption[]> {
return fetchJson<SubjectOption[]>(`${BASE}/subjects`);
}
export async function fetchSummary(subject?: string | null): Promise<SummaryData> {
return fetchJson<SummaryData>(withSubject(`${BASE}/summary`, subject));
}
export async function fetchByType(subject?: string | null): Promise<TypeSummary[]> {
return fetchJson<TypeSummary[]>(withSubject(`${BASE}/by-type`, subject));
}
export async function fetchVehicleList(params: {
@@ -37,6 +49,7 @@ export async function fetchVehicleList(params: {
isTrailer?: string;
department?: string;
attendance?: string;
subject?: string | null;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.batch) query.set('batch', params.batch);
@@ -51,6 +64,7 @@ export async function fetchVehicleList(params: {
if (params.isTrailer) query.set('isTrailer', params.isTrailer);
if (params.department) query.set('department', params.department);
if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
@@ -62,29 +76,40 @@ export interface WeeklyDetailItem {
customer_name: string | null;
}
export async function fetchDeptStats(): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(`${BASE}/dept-stats`);
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
}
export async function fetchRegionStats(params?: { customer?: string; city?: string; region?: string }): Promise<RegionGroup[]> {
export async function fetchRegionStats(
params?: { customer?: string; city?: string; region?: string },
subject?: string | null,
): Promise<RegionGroup[]> {
const query = new URLSearchParams();
if (params?.customer) query.set('customer', params.customer);
if (params?.city) query.set('city', params.city);
if (params?.region) query.set('region', params.region);
if (subject) query.set('subject', subject);
const qs = query.toString();
return fetchJson<RegionGroup[]>(`${BASE}/region-stats${qs ? `?${qs}` : ''}`);
}
export async function fetchCustomerStats(): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(`${BASE}/customer-stats`);
export async function fetchCustomerStats(subject?: string | null): Promise<CustomerStats[]> {
return fetchJson<CustomerStats[]>(withSubject(`${BASE}/customer-stats`, subject));
}
export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> {
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`);
export async function fetchInventoryStats(subject?: string | null): Promise<RegionalInventoryStats[]> {
return fetchJson<RegionalInventoryStats[]>(withSubject(`${BASE}/inventory-stats`, subject));
}
export async function fetchRegionChart(groupBy: string, top = 8, source = 'realtime'): Promise<{ name: string; value: number }[]> {
return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`);
export async function fetchRegionChart(
groupBy: string,
top = 8,
source = 'realtime',
subject?: string | null,
): Promise<{ name: string; value: number }[]> {
return fetchJson<{ name: string; value: number }[]>(
withSubject(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`, subject),
);
}
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {

View File

@@ -0,0 +1,13 @@
import { FileText } from 'lucide-react';
export default function DailyReportView() {
return (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FileText size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { LayoutDashboard, BarChart3, FileText } from 'lucide-react';
import { motion } from 'motion/react';
import MonitoringView from './MonitoringView';
import StatisticsView from './StatisticsView';
import DailyReportView from './DailyReportView';
export default function MileageModule() {
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
{/* Sub-navigation — sticky */}
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
<button
onClick={() => setActiveSubTab('monitoring')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'monitoring' ? 'text-blue-600' : 'text-slate-400'}`}
>
<LayoutDashboard size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'monitoring' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('statistics')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'statistics' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BarChart3 size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'statistics' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveSubTab('report')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeSubTab === 'report' ? 'text-blue-600' : 'text-slate-400'}`}
>
<FileText size={14} />
<span className="text-[11px] font-bold"></span>
{activeSubTab === 'report' && (
<motion.div layoutId="activeSubTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
{activeSubTab === 'monitoring' ? (
<MonitoringView />
) : activeSubTab === 'statistics' ? (
<StatisticsView />
) : (
<DailyReportView />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,929 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp,
} from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api';
import Blur from '../../components/Blur';
const SearchableSelect = ({
options,
value,
onChange,
placeholder
}: {
options: string[],
value: string,
onChange: (val: string) => void,
placeholder: string
}) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
return (
<div className="relative">
<div className="relative">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder={value === 'All' ? placeholder : value === '__EMPTY__' ? '无值' : value}
value={search}
onFocus={() => setIsOpen(true)}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => {
// Delay to allow clicking an option
setTimeout(() => setIsOpen(false), 200);
}}
/>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl max-h-40 overflow-y-auto"
>
<div
className="px-3 py-2 text-[10px] font-bold text-blue-600 hover:bg-slate-50 cursor-pointer"
onClick={() => {
onChange('All');
setSearch('');
setIsOpen(false);
}}
>
</div>
{filtered.map((opt: string) => (
<div
key={opt}
className="px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50"
onClick={() => {
onChange(opt);
setSearch('');
setIsOpen(false);
}}
>
{opt === '__EMPTY__' ? '无值' : opt}
</div>
))}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
const [sortBy, setSortBy] = useState<'today' | 'total'>('today');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false);
// New filters from image
const [filterPlate, setFilterPlate] = useState('All');
const [filterCustomer, setFilterCustomer] = useState('All');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetName, setFilterTargetName] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [filterDate, setFilterDate] = useState(() => {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
});
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] });
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [showBackToTop, setShowBackToTop] = useState(false);
const PAGE_SIZE = 50;
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
// 加载首页数据
const loadFirstPage = useCallback(() => {
setPageLoading(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
}).then(d => {
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
setTotal(d.total);
setPage(1);
setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, appliedMileageRange, filterDate]);
// 加载更多
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
setLoadingMore(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
date: filterDate || undefined,
}).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlate, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(loadFirstPage, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore);
loadMoreRef.current = loadMore;
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreRef.current();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
const topSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = topSentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setShowBackToTop(!entry.isIntersecting),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const scrollToTop = () => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
};
const filteredVehicles = vehicles;
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
setFullscreenLoading(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
plate: filterPlate !== 'All' ? filterPlate : undefined,
date: filterDate || undefined,
}).then(d => {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterPlate, filterDate, fullscreenRefresh]);
// 全屏时禁止背景滚动
useEffect(() => {
if (isFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isFullscreen]);
// 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏
// 小程序 webview 无法调用系统旋转 API只能用 CSS rotate 强制横屏
const forceLandscape = useMemo(() => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent || '';
const isMiniProgram =
/miniProgram/i.test(ua) ||
/toutiaomicroapp/i.test(ua) ||
/AlipayClient/i.test(ua) ||
(window as any).__wxjs_environment === 'miniprogram';
const isPortrait = window.innerHeight > window.innerWidth;
return isMiniProgram && isPortrait;
}, [isFullscreen]);
return (
<>
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
<div ref={topSentinelRef} className="h-0" />
{/* Fullscreen Landscape View Overlay */}
<AnimatePresence>
{isFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
style={
forceLandscape
? {
// 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏
top: 0,
left: '100vw',
width: '100vh',
height: '100vw',
transform: 'rotate(90deg)',
transformOrigin: 'top left',
}
: { top: 0, left: 0, right: 0, bottom: 0 }
}
>
{/* Top bar: compact inline KPI */}
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs"></h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setFullscreenRefresh(n => n + 1); }}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
<Minimize2 size={14} />
</button>
</div>
</div>
{/* Batch selector + legend */}
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
<button
onClick={() => setFilterTargetName('All')}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
></button>
{filterOptions.targetNames.map(n => (
<button
key={n}
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
))}
</div>
<div className="flex items-center gap-2 text-[8px] text-slate-500 flex-shrink-0 ml-2">
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>线</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-slate-500 inline-block"></span>线</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span></span>
</div>
</div>
{/* Table Area */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
<RotateCcw size={14} className="animate-spin" />
...
</div>
</div>
)}
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterPlate}
onChange={(e) => setFilterPlate(e.target.value)}
>
<option value="All"></option>
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterCustomer}
onChange={(e) => setFilterCustomer(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterRentStatus}
onChange={(e) => setFilterRentStatus(e.target.value)}
>
<option value="All"></option>
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span></span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
</select>
</div>
</th>
<th
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'today') {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
setSortBy('today');
setSortOrder('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span></span>
{sortBy === 'today' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
<th
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'total') {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
setSortBy('total');
setSortOrder('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span></span>
{sortBy === 'total' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center">
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : v.isDataSynced ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
</td>
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50"></span>}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Ultra Compact Header - Two Rows */}
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
{/* Top Row: Title & Sort */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-black text-slate-900 leading-none"></h1>
<button
onClick={toggleFullscreen}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
title="全屏视图"
>
<Maximize2 size={14} />
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight"> </span>
</div>
</div>
</div>
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
<div className="flex gap-0.5">
<button
onClick={() => setSortBy('today')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
</button>
<button
onClick={() => setSortBy('total')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
</button>
</div>
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
</div>
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<SearchableSelect
options={['__EMPTY__', ...departments]}
value={filterDept}
onChange={setFilterDept}
placeholder="按部门"
/>
<SearchableSelect
options={['__EMPTY__', ...filterOptions.customers]}
value={filterCustomer}
onChange={setFilterCustomer}
placeholder="按客户"
/>
<SearchableSelect
options={plateNumbers}
value={filterPlate}
onChange={setFilterPlate}
placeholder="按车牌"
/>
</div>
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
>
<Filter size={16} />
</button>
</div>
</div>
{/* Expandable Filter Panel */}
<AnimatePresence>
{isFilterOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Date */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
/>
</div>
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
>
<option value="All"></option>
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
<option value="__EMPTY__"></option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
{/* Rent Status */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterRentStatus}
onChange={(e) => setFilterRentStatus(e.target.value)}
>
<option value="All"></option>
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Entity */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterEntity}
onChange={(e) => setFilterEntity(e.target.value)}
>
<option value="All"></option>
{filterOptions.entities.map(e => <option key={e} value={e}>{e}</option>)}
</select>
</div>
</div>
{/* Target Name */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterTargetName}
onChange={(e) => setFilterTargetName(e.target.value)}
>
<option value="All"></option>
{filterOptions.targetNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
{/* Plate Prefix */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterPlatePrefix}
onChange={(e) => setFilterPlatePrefix(e.target.value)}
>
<option value="All"></option>
{filterOptions.platePrefixes.map(p => <option key={p.prefix} value={p.prefix}>{p.prefix}{p.count}</option>)}
</select>
</div>
{/* Mileage Range */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"> (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.min}
onChange={(e) => setFilterMileageRange(prev => ({ ...prev, min: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"> (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.max}
onChange={(e) => setFilterMileageRange(prev => ({ ...prev, max: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
<button
onClick={() => {
setSearchTerm('');
setFilterDept('All');
setFilterPlate('All');
setFilterCustomer('All');
setFilterProject('All');
setFilterEntity('All');
setFilterPlatePrefix('All');
setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' });
}}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
>
</button>
<button
onClick={() => {
setAppliedMileageRange({ ...filterMileageRange });
setIsFilterOpen(false);
}}
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Active Filter Tags */}
{(() => {
const tags: { label: string; onClear: () => void }[] = [];
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
if (filterPlatePrefix !== 'All') tags.push({ label: `区域: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
if (tags.length === 0) return null;
const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlate('All'); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate('');
};
return (
<div className="flex items-center gap-2 flex-wrap">
{tags.map((tag, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
{tag.label}
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">&times;</button>
</span>
))}
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
</button>
</div>
);
})()}
{/* Sticky header: KPI + 清单标题 */}
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div>
</div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400">km/</div>
</div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
<div className="text-[7px] text-slate-400"></div>
</div>
</div>
<div className="flex items-center justify-between px-2">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span>
</div>
</div>
{/* Vehicle List */}
<div className="space-y-1.5">
{pageLoading && (
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
<div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-100"></div>
<div className="space-y-1.5 flex-1">
<div className="h-3 bg-slate-100 rounded w-24"></div>
<div className="h-2 bg-slate-50 rounded w-36"></div>
</div>
</div>
<div className="space-y-1.5 text-right">
<div className="h-4 bg-slate-100 rounded w-16 ml-auto"></div>
<div className="h-2 bg-slate-50 rounded w-20 ml-auto"></div>
</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.map((v) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
onClick={() => {
navigator.clipboard.writeText(v.plate);
}}
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
<div className="flex items-center gap-1 mb-0.5">
{!v.isDataSynced && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)}
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70"></span>}
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none"></span>
<span className="text-[8px] font-bold text-slate-300">
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
</span>
</div>
</div>
</motion.div>
))}
</div>
{filteredVehicles.length === 0 && !loadingMore && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
<p className="text-xs font-bold text-slate-300"></p>
</div>
)}
{/* 加载更多提示 */}
{loadingMore && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-400">...</span>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>
)}
{/* 哨兵元素:进入视口时触发加载更多 */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* 回到顶部按钮 */}
<AnimatePresence>
{showBackToTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={scrollToTop}
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 w-10 h-10 bg-blue-600 text-white rounded-full shadow-lg shadow-blue-200 flex items-center justify-center active:scale-95 transition-transform"
>
<ChevronsUp size={18} />
</motion.button>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,598 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, LineChart, Line, AreaChart, Area,
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
function getDefaultDate(): string {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
function shortTargetName(name: string): string {
// Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
// Simplify common patterns
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
desc = desc.replace('18T', '18T');
return `${count}${desc}`;
}
export default function StatisticsView() {
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
// Load targets on mount
useEffect(() => {
fetchTargets().then(data => {
setTargets(data);
if (data.length > 0 && !selectedTargetId) {
setSelectedTargetId(data[0].id);
}
}).catch(() => {});
}, []);
// Load trend when selectedTargetId changes
useEffect(() => {
if (selectedTargetId === null) return;
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
if (viewAllTargetId === null) return;
setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
onClick={() => setSelectedTargetId(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(target.targetName)}
</button>
))}
</div>
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
{/* KPI Cards in Landscape — linked to selected target */}
{(() => {
const sel = targets.find(t => t.id === selectedTargetId);
return (
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.todayTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.cumulativeTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{sel?.vehicleCount ?? 0}
<span className="text-blue-500 text-[10px] ml-1"></span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{(sel?.avgCompletion ?? 0).toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
</div>
);
})()}
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800">7</h3>
</div>
<div className="flex bg-slate-50 p-1 rounded-lg">
{(['bar', 'line', 'area'] as const).map(type => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
</button>
))}
</div>
</div>
<div className="flex-1 w-full min-h-[250px] relative">
<div className="absolute inset-0">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
{trendData.map((_entry: TrendPoint, index: number) => (
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
))}
</Bar>
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Line>
</LineChart>
) : (
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Area>
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
</div>
{/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest"></h3>
</div>
<button
onClick={() => setIsTableFullscreen(true)}
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{targets.map((target, idx) => (
<div
key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
const name = target.targetName;
setExpandedModel(expandedModel === name ? null : name);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">:</span>
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">:</span>
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
: {fmtKm(target.cumulativeTotal)} KM
</div>
</div>
<motion.div
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
className="text-slate-300"
>
<ChevronDown size={14} />
</motion.div>
</div>
</div>
<AnimatePresence>
{expandedModel === target.targetName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div>
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500"></span>
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} </span>
</div>
{/* Vehicle List Detail */}
<div className="col-span-2 space-y-2 mt-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"> (5)</span>
<button
onClick={(e) => {
e.stopPropagation();
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
</button>
</div>
<div className="space-y-1">
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
</div>
{/* Fullscreen Table Overlay */}
<AnimatePresence>
{isTableFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
>
{/* Top bar: compact inline KPI */}
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs"></h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
</button>
<button
onClick={() => setIsTableFullscreen(false)}
className="p-1.5 text-slate-500 hover:text-white transition-colors"
>
<Minimize2 size={14} />
</button>
</div>
</div>
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">/</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right"></th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14"></th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, idx) => (
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(target.cumulativeTotal)}</span>
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
{/* View All Vehicles Side Panel */}
<AnimatePresence>
{viewAllTargetId !== null && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setViewAllTargetId(null)}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"></p>
</div>
<button
onClick={() => {
setViewAllTargetId(null);
setViewAllSearch('');
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={viewAllSearch}
onChange={(e) => setViewAllSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
>
<ArrowUpDown size={12} />
{viewAllSort === 'desc' ? '降序' : '升序'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
).sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={() => setViewAllTargetId(null)}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchJson } from '../../auth/api-client';
const BASE = '/api/mileage';
export async function fetchMonitoring(params?: {
sortBy?: string;
sortOrder?: string;
limit?: number;
page?: number;
search?: string;
dept?: string;
customer?: string;
project?: string;
entity?: string;
rentStatus?: string;
platePrefix?: string;
targetName?: string;
plate?: string;
mileageMin?: string;
mileageMax?: string;
date?: string;
}): Promise<MonitoringData> {
const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy);
if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
if (params?.limit) query.set('limit', String(params.limit));
if (params?.page) query.set('page', String(params.page));
if (params?.search) query.set('search', params.search);
if (params?.dept) query.set('dept', params.dept);
if (params?.customer) query.set('customer', params.customer);
if (params?.project) query.set('project', params.project);
if (params?.entity) query.set('entity', params.entity);
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName);
if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
if (params?.mileageMax) query.set('mileageMax', params.mileageMax);
if (params?.date) query.set('date', params.date);
const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
}
export async function fetchTargets(): Promise<TargetSummary[]> {
return fetchJson<TargetSummary[]>(`${BASE}/targets`);
}
export async function fetchTargetVehicles(targetId: number, date?: string): Promise<TargetVehicle[]> {
const params = new URLSearchParams();
if (date) params.set('date', date);
const qs = params.toString();
return fetchJson<TargetVehicle[]>(`${BASE}/target/${targetId}/vehicles${qs ? `?${qs}` : ''}`);
}
export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoint[]> {
const params = new URLSearchParams();
if (targetId) params.set('targetId', String(targetId));
params.set('days', String(days));
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
}

View File

@@ -0,0 +1,83 @@
export interface MonitoringVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
}
export interface MonitoringStats {
totalToday: number;
totalAll: number;
vehicleCount: number;
yesterdayTotal: number;
}
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
projects: string[];
entities: string[];
rentStatuses: string[];
platePrefixes: { prefix: string; count: number }[];
targetNames: string[];
}
export interface MonitoringData {
vehicles: MonitoringVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}
export interface TargetSummary {
id: number;
targetName: string;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
assessmentYears: number;
periods: string[];
todayTotal: number;
cumulativeTotal: number;
avgCompletion: number;
qualifiedCount: number;
yearQualifiedCount: number;
halfQualifiedCount: number;
currentYearTarget: number;
currentYearCompleted: number;
remaining: number;
daysLeft: number;
dailyTarget: number;
}
export interface TargetVehicle {
plateNumber: string;
todayMileage: number;
totalMileage: number;
completionRate: number;
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
rentStatus: string | null;
department: string | null;
customer: string | null;
isOnline: boolean;
}
export interface TrendPoint {
date: string;
mileage: number;
}

View File

@@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchNotifications, updateNotification } from './api';
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
interface Props {
onClose: () => void;
onChange?: () => void;
/** When true, pre-filter to the last 7 days (excluding cancelled). */
recentOnly?: boolean;
/** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */
suggestions?: SchedulingSuggestion[];
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function shortDept(dept: string | null | undefined): string {
return (dept || '').replace('业务', '');
}
type StatusTab = 'all' | NotificationStatus;
const STATUS_TABS: { key: StatusTab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'sent', label: '待执行' },
{ key: 'executed', label: '已执行' },
{ key: 'cancelled', label: '已取消' },
];
function statusBadge(status: NotificationStatus) {
if (status === 'sent') return { text: '待执行', icon: <Send size={9} />, cls: 'text-amber-700 bg-amber-50' };
if (status === 'executed') return { text: '已执行', icon: <CheckCircle2 size={9} />, cls: 'text-emerald-700 bg-emerald-50' };
return { text: '已取消', icon: <XCircle size={9} />, cls: 'text-slate-500 bg-slate-100' };
}
function fmtDateTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
export default function NotificationHistory({ onClose, onChange, recentOnly = false, suggestions }: Props) {
const [records, setRecords] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<StatusTab>('all');
const [recent7d, setRecent7d] = useState(recentOnly);
const [mutatingId, setMutatingId] = useState<number | null>(null);
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
const [afterMileageInput, setAfterMileageInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null);
const suggestionById = useMemo(() => {
const map = new Map<string, SchedulingSuggestion>();
for (const s of suggestions ?? []) map.set(s.id, s);
return map;
}, [suggestions]);
const visibleRecords = recent7d
? records.filter(r => {
const t = Date.parse(r.createdAt);
return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS;
})
: records;
const load = useCallback(async () => {
setLoading(true);
try {
const resp = await fetchNotifications(tab === 'all' ? undefined : tab);
setRecords(resp.records);
} finally {
setLoading(false);
}
}, [tab]);
useEffect(() => { load(); }, [load]);
const handleExecuteClick = (rec: NotificationRecord) => {
setExecuteTarget(rec);
setAfterMileageInput('');
setNotesInput('');
};
const handleExecuteConfirm = async () => {
if (!executeTarget) return;
setMutatingId(executeTarget.id);
try {
const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' };
if (notesInput.trim()) body.notes = notesInput.trim();
const parsed = Number(afterMileageInput);
if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed;
await updateNotification(executeTarget.id, body);
setExecuteTarget(null);
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
const handleCancel = async (rec: NotificationRecord) => {
if (!confirm(`确定取消 ${rec.currentPlate}${rec.candidatePlate} 的干预?`)) return;
setMutatingId(rec.id);
try {
await updateNotification(rec.id, { status: 'cancelled' });
await load();
onChange?.();
} finally {
setMutatingId(null);
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4"
>
{/* Header */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<Clock size={16} className="text-white" />
<span className="text-white font-bold text-sm"></span>
</div>
<div className="flex items-center gap-1">
<button onClick={load} disabled={loading} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<RotateCcw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button onClick={onClose} className="text-slate-300 hover:text-white p-1 cursor-pointer">
<X size={18} />
</button>
</div>
</div>
{/* Status tabs */}
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0 flex-wrap items-center">
{STATUS_TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
tab === t.key ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{t.label}
</button>
))}
<div className="ml-auto">
<button
onClick={() => setRecent7d(v => !v)}
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
recent7d ? 'bg-emerald-600 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
title="仅看最近 7 天"
>
7
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading && records.length === 0 ? (
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
<Loader2 size={14} className="animate-spin" />
</div>
) : visibleRecords.length === 0 ? (
<div className="py-16 text-center text-slate-400">
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
<p className="text-sm">{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}</p>
</div>
) : (
<div className="divide-y divide-slate-50">
{visibleRecords.map(rec => {
const badge = statusBadge(rec.status);
const busy = mutatingId === rec.id;
const suggestion = suggestionById.get(rec.suggestionId);
const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null;
const canDrill = !!suggestion && !!candidate;
const v = suggestion?.currentVehicle;
const handleRowClick = () => {
if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate });
};
return (
<div
key={rec.id}
onClick={handleRowClick}
className={`px-4 py-3 transition-colors ${canDrill ? 'cursor-pointer hover:bg-slate-50/60 active:bg-slate-100' : ''}`}
>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 text-xs min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
{badge.icon} {badge.text}
</span>
{canDrill && <ChevronRight size={12} className="text-slate-300" />}
</div>
</div>
{v && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
{v.manager && <span>{v.manager}</span>}
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-400">
{rec.operatorName && <span> {rec.operatorName}</span>}
<span>{fmtDateTime(rec.createdAt)}</span>
{rec.status === 'executed' && rec.executedAt && (
<span className="text-emerald-500"> {fmtDateTime(rec.executedAt)}</span>
)}
</div>
{rec.notes && (
<div className="mt-1 text-[10px] text-slate-500 bg-slate-50 rounded px-2 py-1">{rec.notes}</div>
)}
{rec.status === 'sent' && (
<div className="mt-2 flex items-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleExecuteClick(rec)}
disabled={busy}
className="text-[10px] font-bold text-white bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-2.5 py-1 rounded cursor-pointer transition-colors flex items-center gap-1"
>
<CheckCircle2 size={10} />
</button>
<button
onClick={() => handleCancel(rec)}
disabled={busy}
className="text-[10px] font-medium text-slate-500 hover:text-rose-600 disabled:opacity-50 px-2 py-1 rounded cursor-pointer transition-colors"
>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</motion.div>
{/* Execute confirmation modal */}
<AnimatePresence>
{executeTarget && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[80] flex items-end sm:items-center justify-center"
onClick={() => mutatingId === null && setExecuteTarget(null)}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4"
>
<div className="bg-emerald-600 px-4 py-3 flex items-center justify-between">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => mutatingId === null && setExecuteTarget(null)}
disabled={mutatingId !== null}
className="text-emerald-100 hover:text-white p-1 cursor-pointer disabled:opacity-50"
>
<X size={16} />
</button>
</div>
<div className="px-4 py-4 space-y-3">
<div className="text-xs text-slate-500">
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
<span className="mx-1.5"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> (km, )</label>
<input
type="number"
inputMode="numeric"
value={afterMileageInput}
onChange={e => setAfterMileageInput(e.target.value)}
placeholder="例如 45230"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all"
/>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> ()</label>
<textarea
value={notesInput}
onChange={e => setNotesInput(e.target.value)}
rows={2}
placeholder="例如:司机已到位,交接完成"
className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all resize-none"
/>
</div>
</div>
<div className="border-t border-slate-100 px-4 py-3 flex gap-2">
<button
onClick={() => setExecuteTarget(null)}
disabled={mutatingId !== null}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleExecuteConfirm}
disabled={mutatingId !== null}
className="flex-1 py-2 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{mutatingId !== null ? '保存中...' : '确认'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Drill-down: replacement plan */}
{drillTarget && (
<SwapPreview
suggestion={drillTarget.suggestion}
candidate={drillTarget.candidate}
onClose={() => setDrillTarget(null)}
onSuccess={() => { load(); onChange?.(); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,637 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchSuggestions, sendNotifyBatch } from './api';
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
import SuggestionList from './SuggestionList';
import SuggestionDetail from './SuggestionDetail';
import NotificationHistory from './NotificationHistory';
import { exportSuggestionsCsv } from './csv-export';
import Blur from '../../components/Blur';
type TypeFilter = 'all' | 'qualified' | 'hopeless';
interface AdvancedFilters {
plateSearch: string;
region: string;
vehicleType: string;
customer: string;
department: string;
manager: string;
}
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
function shortTargetName(name: string): string {
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
return `${count}${desc}`;
}
function hasActiveFilters(f: AdvancedFilters): boolean {
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
}
function FilterSelect({ label, options, value, onChange, placeholder }: {
label: string; options: string[]; value: string; onChange: (v: string) => void; placeholder: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const ref = useRef<HTMLDivElement>(null);
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
return (
<div ref={ref} className="space-y-1">
<label className="text-[10px] text-slate-400 uppercase font-bold">{label}</label>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 text-xs text-left cursor-pointer hover:bg-slate-100 transition-colors"
>
<span className={value ? 'text-slate-800 font-medium' : 'text-slate-400'}>{value || placeholder}</span>
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10 relative">
{options.length > 5 && (
<div className="p-1.5 border-b border-slate-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索..." autoFocus
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
</div>
</div>
)}
<div className="overflow-y-auto max-h-36">
<button onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${!value ? 'text-blue-600 font-bold' : 'text-slate-400'}`}></button>
{filtered.map(opt => (
<button key={opt} onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${value === opt ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700'}`}>{opt}</button>
))}
</div>
</div>
)}
</div>
);
}
/** Skeleton pulse block */
function Sk({ className }: { className?: string }) {
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
}
function SkeletonPage() {
return (
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* Cards skeleton */}
<div className="grid grid-cols-3 gap-2.5">
{[0, 1, 2].map(i => (
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
<Sk className="h-3 w-16" />
<Sk className="h-7 w-12" />
<Sk className="h-2.5 w-24" />
</div>
))}
</div>
{/* List card skeleton */}
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Sk className="h-4 w-28" />
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
</div>
<div className="flex gap-2">
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
</div>
</div>
{/* Rows */}
<div className="divide-y divide-slate-50">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
// Business rule: at most one active intervention per suggestion. If ANY
// candidate is already intervened, skip the whole suggestion in batch flow.
const hasActive = s.candidates.some(
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
);
if (hasActive) return null;
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
}
export default function SchedulingModule() {
const [data, setData] = useState<SchedulingResponse | null>(null);
const [loading, setLoading] = useState(false);
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [showFilter, setShowFilter] = useState(false);
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
const [batchInFlight, setBatchInFlight] = useState(false);
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
}, [selectedTargetId]);
useEffect(() => { loadData(); }, [loadData]);
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
// Keep selectedSuggestion synced with latest data so candidate notification
// status changes (登记 / 取消干预) propagate into the open detail modal.
useEffect(() => {
if (!selectedSuggestion || !data) return;
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
if (!fresh) setSelectedSuggestion(null);
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
}, [data, selectedSuggestion]);
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const exitSelectMode = useCallback(() => {
setSelectMode(false);
setSelectedIds(new Set());
setShowBatchConfirm(false);
}, []);
const batchItems = useMemo(() => {
if (!data) return [];
return [...selectedIds]
.map(id => data.suggestions.find(s => s.id === id))
.filter((s): s is SchedulingSuggestion => !!s)
.map(s => {
const candidate = pickBestCandidate(s);
if (!candidate) return null;
return { suggestion: s, candidate };
})
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
}, [data, selectedIds]);
const handleBatchSubmit = useCallback(async () => {
if (batchItems.length === 0) return;
setBatchInFlight(true);
try {
const resp = await sendNotifyBatch({
items: batchItems.map(i => ({
suggestionId: i.suggestion.id,
currentPlate: i.suggestion.currentVehicle.plateNumber,
candidatePlate: i.candidate.plateNumber,
})),
});
setBatchResultMsg(resp.message);
await loadData();
exitSelectMode();
} catch (e) {
console.error('batch notify failed:', e);
setBatchResultMsg('批量干预失败,请重试');
} finally {
setBatchInFlight(false);
}
}, [batchItems, loadData, exitSelectMode]);
const filterOptions = useMemo(() => {
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
for (const s of data.suggestions) {
const v = s.currentVehicle;
if (v.region) r.add(v.region);
if (v.vehicleType) t.add(v.vehicleType);
if (v.customer) c.add(v.customer);
if (v.department) d.add(v.department);
if (v.manager) m.add(v.manager);
}
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
}, [data]);
const filteredSuggestions = useMemo(() => {
if (!data) return [];
let list = data.suggestions;
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
if (filters.plateSearch) { const q = filters.plateSearch.toLowerCase(); list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q)); }
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
return list;
}, [data, typeFilter, filters]);
const summary = data?.summary;
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
// Initial load — full page skeleton
if (loading && !data) return <SkeletonPage />;
return (
<div className="min-h-screen bg-[#F0F4F8] text-slate-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
{/* ===== Summary Cards ===== */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{/* 里程高·换下 — warm orange */}
<button
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'qualified'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
</div>
</button>
{/* 里程低·换走 — cool blue */}
<button
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'hopeless'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
</div>
</button>
{/* 替换建议 — neutral dark */}
<button
onClick={() => setTypeFilter('all')}
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
typeFilter === 'all'
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
}`}
>
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
</div>
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}></span>
</div>
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
+{summary?.estimatedGain ?? 0}
</div>
</button>
{/* 近期已干预 — emerald */}
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
>
<div className="text-[10px] font-bold mb-1 text-emerald-600">
</div>
<div className="text-2xl font-black text-emerald-700">
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
<span className="text-[10px] font-normal ml-1 text-emerald-400"></span>
</div>
<div className="text-[9px] mt-0.5 text-emerald-400">
7 ·
</div>
</button>
</div>
{/* ===== List Card ===== */}
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-slate-900"></h3>
<div className="flex items-center gap-1">
<button onClick={loadData} disabled={loading}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer">
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => exportSuggestionsCsv(filteredSuggestions)}
disabled={filteredSuggestions.length === 0}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="导出 CSV"
>
<Download size={15} />
</button>
<button
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
title="调度记录"
>
<Clock size={15} />
</button>
<button
onClick={() => {
if (selectMode) exitSelectMode();
else { setSelectMode(true); setSelectedSuggestion(null); }
}}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
title={selectMode ? '退出多选' : '多选模式'}
>
<CheckSquare size={15} />
</button>
<button
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
showFilter || activeFilterCount > 0 ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
}`}
>
<Filter size={15} />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">{activeFilterCount}</span>
)}
</button>
</div>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar">
<button
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === undefined ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
</button>
{data?.targets.map(t => (
<button key={t.id}
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedTargetId === t.id ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{shortTargetName(t.name)}
</button>
))}
</div>
</div>
{/* Filter Panel */}
<AnimatePresence>
{showFilter && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden border-b border-slate-100">
<div className="px-4 py-4 bg-slate-50/60 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-700"></span>
{hasActiveFilters(tempFilters) && (
<button onClick={() => setTempFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-400 uppercase font-bold"></label>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" value={tempFilters.plateSearch} onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
placeholder="搜索车牌号..." className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<FilterSelect label="运营区域" options={filterOptions.regions} value={tempFilters.region} onChange={v => setTempFilters(prev => ({ ...prev, region: v }))} placeholder="全部区域" />
<FilterSelect label="车辆类型" options={filterOptions.vehicleTypes} value={tempFilters.vehicleType} onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))} placeholder="全部类型" />
</div>
<div className="grid grid-cols-2 gap-3">
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
</div>
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
<div className="flex gap-2 pt-1">
<button onClick={() => setShowFilter(false)} className="flex-1 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors"></button>
<button onClick={() => { setFilters(tempFilters); setShowFilter(false); }} className="flex-1 py-2 text-xs font-bold text-white bg-slate-800 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors shadow-sm"></button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Active filter tags */}
{activeFilterCount > 0 && !showFilter && (
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-400">:</span>
{filters.plateSearch && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1"> "{filters.plateSearch}" <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} /></span>}
{filters.region && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.region} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} /></span>}
{filters.vehicleType && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.vehicleType} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} /></span>}
{filters.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.customer} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} /></span>}
<button onClick={() => setFilters(EMPTY_FILTERS)} className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"></button>
</div>
)}
{(activeFilterCount > 0 || typeFilter !== 'all') && (
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400"> {filteredSuggestions.length} </div>
)}
{loading ? (
/* List skeleton while refreshing */
<div className="divide-y divide-slate-50">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex items-center gap-3">
<Sk className="w-1 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Sk className="h-3.5 w-20" />
<Sk className="h-3 w-10 rounded-full" />
<Sk className="h-3 w-14" />
</div>
<div className="flex items-center gap-3">
<Sk className="h-2.5 w-28" />
<Sk className="h-2.5 w-16" />
<Sk className="h-2.5 w-14" />
</div>
</div>
<Sk className="h-4 w-8" />
</div>
))}
</div>
) : (
<SuggestionList
suggestions={filteredSuggestions}
onSelect={setSelectedSuggestion}
selectMode={selectMode}
selectedIds={selectedIds}
onToggleSelect={toggleSelect}
/>
)}
</div>
{selectedSuggestion && (
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
)}
{showHistory && (
<NotificationHistory
onClose={() => setShowHistory(false)}
onChange={loadData}
recentOnly={historyRecentOnly}
suggestions={data?.suggestions}
/>
)}
{/* Batch action bar */}
<AnimatePresence>
{selectMode && (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium"></span>
<span className="text-lg font-black">{selectedIds.size}</span>
<span className="text-xs text-slate-400"></span>
</div>
<div className="flex items-center gap-2">
<button
onClick={exitSelectMode}
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
>
</button>
<button
onClick={() => setShowBatchConfirm(true)}
disabled={selectedIds.size === 0}
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
>
<Send size={12} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Batch confirmation modal */}
{showBatchConfirm && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
>
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<span className="text-white font-bold text-sm"></span>
<button
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
disabled={batchInFlight}
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
>
<X size={18} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto flex-1">
<p className="text-xs text-slate-500 mb-3">
<span className="font-bold text-slate-800">{batchItems.length}</span>
</p>
<div className="space-y-2">
{batchItems.map(({ suggestion, candidate }) => (
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
<span className="text-slate-400"></span>
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
</div>
{candidate.canQualifyAfterSwap ? (
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0"></span>
) : (
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0"></span>
)}
</div>
))}
</div>
{batchResultMsg && (
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
)}
</div>
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
<button
onClick={() => setShowBatchConfirm(false)}
disabled={batchInFlight}
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleBatchSubmit}
disabled={batchInFlight || batchItems.length === 0}
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
>
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length}`}
</button>
</div>
</motion.div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import { useState, useMemo } from 'react';
import {
X, MapPin, AlertTriangle, CheckCircle, ArrowDown, ArrowUp, ArrowRight, ArrowUpDown, Lock,
} from 'lucide-react';
import { motion } from 'motion/react';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview';
type SortKey = 'predicted' | 'current';
type SortDir = 'asc' | 'desc';
interface Props {
suggestion: SchedulingSuggestion;
onClose: () => void;
onNotifySuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return Math.round(value).toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
const CUSTOMER_REASON_LABELS = new Set(['客户日均']);
export default function SuggestionDetail({ suggestion: s, onClose, onNotifySuccess }: Props) {
const [previewCandidate, setPreviewCandidate] = useState<CandidateVehicle | null>(null);
const [sentPlates, setSentPlates] = useState<Set<string>>(new Set());
const [batchFilter, setBatchFilter] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('predicted');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const v = s.currentVehicle;
const isRescue = s.type === 'rescue_hopeless';
// Business rule: a current vehicle can have AT MOST ONE active intervention.
// Find the active candidate (if any) — other candidates are blocked until
// this one is cancelled.
const activeIntervention = s.candidates.find(
cc => cc.notificationStatus === 'sent' || cc.notificationStatus === 'executed',
);
// Batch options from candidates
const batchOptions = useMemo(() => {
const set = new Set<string>();
for (const c of s.candidates) if (c.targetName) set.add(c.targetName);
return [...set].sort();
}, [s.candidates]);
// Filtered + sorted candidates, grouped by region
const { sameRegion, crossRegion } = useMemo(() => {
let list = s.candidates;
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
const sorted = [...list].sort((a, b) => {
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
return sortDir === 'desc' ? vb - va : va - vb;
});
return {
sameRegion: sorted.filter(c => c.isSameRegion),
crossRegion: sorted.filter(c => !c.isSameRegion),
};
}, [s.candidates, batchFilter, sortKey, sortDir]);
const displayCount = sameRegion.length + crossRegion.length;
const toggleSort = (key: SortKey) => {
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
else { setSortKey(key); setSortDir('desc'); }
};
const renderCandidate = (c: CandidateVehicle) => {
const sent =
sentPlates.has(c.plateNumber) ||
c.notificationStatus === 'sent' ||
c.notificationStatus === 'executed';
const blockedByOther = !!activeIntervention && activeIntervention.plateNumber !== c.plateNumber;
return (
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
</span>
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
<span className="text-[9px] text-slate-400">{c.daysLeft}</span>
</div>
{c.canQualifyAfterSwap ? (
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
<CheckCircle size={10} />
</span>
) : (
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
<AlertTriangle size={10} />
</span>
)}
</div>
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
</div>
</div>
</div>
<div className="px-3 pb-2.5">
{blockedByOther ? (
<div className="w-full flex items-center justify-center gap-1.5 text-[11px] font-medium py-2 rounded-lg bg-slate-50 text-slate-400 cursor-not-allowed">
<Lock size={11} />
</div>
) : (
<button
onClick={() => setPreviewCandidate(c)}
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
sent
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
}`}
>
{sent ? <><CheckCircle size={12} /> · <ArrowRight size={12} /></> : <> <ArrowRight size={12} /></>}
</button>
)}
</div>
</div>
);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
onClick={e => e.stopPropagation()}
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-lg overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[85vh] sm:mx-4"
>
{/* Header — unified dark slate */}
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
{isRescue
? <ArrowDown size={14} className="text-blue-300" />
: <ArrowUp size={14} className="text-amber-300" />
}
<span className="text-white font-bold text-sm">
{isRescue ? '里程低·换走此车' : '里程高·换下此车'}
</span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 no-scrollbar">
{/* Current Vehicle — same format as candidate cards */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="rounded-xl border border-slate-200 overflow-hidden bg-white">
{/* Header — same style as candidate header */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{v.region}</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">{v.targetName}</span>
<span className="text-[9px] text-slate-400">{v.daysLeft}</span>
</div>
<span className={`text-sm font-black tabular-nums ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>
{fmtRate(v.completionRate)}
</span>
</div>
{/* Customer + dept/manager info */}
<div className="px-3 pb-1.5 flex items-center gap-2 text-[10px] text-slate-500 flex-wrap">
{v.department && <span><b className="text-slate-700">{v.department}</b></span>}
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
<span> <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
<span>
30 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
</span>
</div>
{/* Metrics */}
<div className="px-3 pb-2">
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className="font-bold text-slate-700">{fmtKm(v.currentYearMileage)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-slate-400"></div>
<div className={`font-bold ${v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}`}>{fmtKm(v.currentYearMileage + v.customerAvgDaily * v.daysLeft)}</div>
</div>
<div className="flex-1 py-1.5 px-2 text-center">
<div className="text-blue-400"></div>
<div className="font-bold text-blue-700">{fmtKm(v.yearTarget)}</div>
</div>
</div>
</div>
</div>
</div>
{/* Reason — customer vs vehicle columns */}
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
<div className="grid grid-cols-2 gap-x-5">
{(() => {
const customerLines = s.reason.lines.filter(l => CUSTOMER_REASON_LABELS.has(l.label));
const vehicleLines = s.reason.lines.filter(l => !CUSTOMER_REASON_LABELS.has(l.label));
return (
<>
<div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1"></div>
<div className="space-y-1">
{customerLines.map((line, i) => (
<div key={i} className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{line.label}</span>
<span className="text-slate-700 font-medium">{line.value}</span>
</div>
))}
</div>
</div>
<div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mb-1"></div>
<div className="space-y-1">
{vehicleLines.map((line, i) => (
<div key={i} className="flex items-center justify-between text-[11px]">
<span className="text-slate-500">{line.label}</span>
<span className="text-slate-700 font-medium">{line.value}</span>
</div>
))}
</div>
</div>
</>
);
})()}
</div>
<div className="mt-2 pt-2 border-t border-slate-200">
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
</div>
</div>
{/* Candidates */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-bold text-slate-700"></span>
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} </span>
</div>
{activeIntervention && (
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
<Lock size={12} className="mt-0.5 flex-shrink-0" />
<span>
<b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>
</span>
</div>
)}
{/* Filter + Sort controls */}
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
{/* Batch multi-select pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setBatchFilter(new Set())}
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
batchFilter.size === 0 ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
}`}
>
</button>
{batchOptions.map(b => {
const active = batchFilter.has(b);
return (
<button
key={b}
onClick={() => setBatchFilter(prev => {
const next = new Set(prev);
if (active) next.delete(b); else next.add(b);
return next;
})}
className={`text-[10px] px-2 py-1 rounded-lg border cursor-pointer transition-colors ${
active ? 'border-blue-300 bg-blue-50 text-blue-700 font-bold' : 'border-slate-200 text-slate-500'
}`}
>
{b}
</button>
);
})}
</div>
{/* Sort buttons */}
<button
onClick={() => toggleSort('predicted')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'predicted' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'predicted' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'predicted' && <ArrowUpDown size={10} />}
</button>
<button
onClick={() => toggleSort('current')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'current' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'current' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'current' && <ArrowUpDown size={10} />}
</button>
</div>
{sameRegion.length > 0 && (
<div className="space-y-2">
{sameRegion.map(c => renderCandidate(c))}
</div>
)}
{crossRegion.length > 0 && (
<>
<div className="flex items-center gap-2 my-3">
<div className="flex-1 h-px bg-slate-200" />
<span className="text-[10px] text-slate-400 font-medium"> · {crossRegion.length} </span>
<div className="flex-1 h-px bg-slate-200" />
</div>
<div className="space-y-2">
{crossRegion.map(c => renderCandidate(c))}
</div>
</>
)}
{displayCount === 0 && (
<div className="py-8 text-center text-xs text-slate-400"></div>
)}
</div>
</div>
{/* Footer */}
<div className="border-t border-slate-100 px-4 py-2.5 flex-shrink-0">
<button
onClick={onClose}
className="w-full py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer"
>
</button>
</div>
</motion.div>
{/* Full-screen swap preview */}
{previewCandidate && (
<SwapPreview
suggestion={s}
candidate={previewCandidate}
onClose={() => setPreviewCandidate(null)}
onSuccess={() => {
setSentPlates(prev => new Set(prev).add(previewCandidate.plateNumber));
setPreviewCandidate(null);
onNotifySuccess();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState, useMemo } from 'react';
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
import { motion } from 'motion/react';
import type { SchedulingSuggestion } from './types';
import Blur from '../../components/Blur';
interface Props {
suggestions: SchedulingSuggestion[];
onSelect: (s: SchedulingSuggestion) => void;
selectMode?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
}
function hasActiveNotification(s: SchedulingSuggestion): boolean {
return s.candidates.some(c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
type SortKey = 'default' | 'avgDaily' | 'completion';
type SortDir = 'asc' | 'desc';
export default function SuggestionList({ suggestions, onSelect, selectMode = false, selectedIds, onToggleSelect }: Props) {
const [sortKey, setSortKey] = useState<SortKey>('default');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleSort = (key: SortKey) => {
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
else { setSortKey(key); setSortDir('desc'); }
};
const sorted = useMemo(() => {
if (sortKey === 'default') return suggestions;
return [...suggestions].sort((a, b) => {
const va = sortKey === 'avgDaily' ? a.currentVehicle.customerAvgDaily : a.currentVehicle.completionRate;
const vb = sortKey === 'avgDaily' ? b.currentVehicle.customerAvgDaily : b.currentVehicle.completionRate;
return sortDir === 'desc' ? vb - va : va - vb;
});
}, [suggestions, sortKey, sortDir]);
if (suggestions.length === 0) {
return (
<div className="py-16 text-center">
<ArrowRightLeft className="w-8 h-8 text-slate-200 mx-auto mb-2" />
<p className="text-sm text-slate-400"></p>
</div>
);
}
return (
<div>
{/* Sort controls */}
<div className="px-4 py-2 border-b border-slate-50 flex items-center gap-2">
<button
onClick={() => toggleSort('avgDaily')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'avgDaily' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'avgDaily' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'avgDaily' && <ArrowUpDown size={10} />}
</button>
<button
onClick={() => toggleSort('completion')}
className={`text-[10px] px-2 py-1 rounded-lg border flex items-center gap-1 cursor-pointer transition-colors ${
sortKey === 'completion' ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'
}`}
>
{sortKey === 'completion' && (sortDir === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />)}
{sortKey !== 'completion' && <ArrowUpDown size={10} />}
</button>
</div>
<div className="divide-y divide-slate-50">
{sorted.map((s, idx) => {
const isRescue = s.type === 'rescue_hopeless';
const v = s.currentVehicle;
const notified = hasActiveNotification(s);
const isSelected = selectedIds?.has(s.id) ?? false;
const canSelect = selectMode && !notified;
const handleClick = () => {
if (selectMode) {
if (canSelect) onToggleSelect?.(s.id);
} else {
onSelect(s);
}
};
return (
<motion.div
key={s.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(idx * 0.02, 0.3) }}
className={`px-4 py-3 hover:bg-slate-50/60 transition-colors flex items-center gap-3 ${
canSelect || !selectMode ? 'cursor-pointer active:bg-slate-100' : 'cursor-default opacity-60'
} ${isSelected ? 'bg-blue-50/60' : ''}`}
onClick={handleClick}
>
{/* Checkbox (select mode) */}
{selectMode && (
<div
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-colors ${
isSelected
? 'bg-blue-600 border-blue-600 text-white'
: notified
? 'bg-slate-100 border-slate-200'
: 'bg-white border-slate-300'
}`}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
)}
{/* Color bar */}
<div className={`w-1 h-10 rounded-full flex-shrink-0 ${isRescue ? 'bg-blue-400' : 'bg-amber-400'}`} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-black text-slate-900 font-mono">
<Blur>{v.plateNumber}</Blur>
</span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">·</span>
<span className="text-[9px] text-slate-400">{v.region}</span>
{notified && (
<span className="text-[9px] font-bold text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded flex items-center gap-0.5">
<CheckCircle size={9} />
</span>
)}
</div>
<span className="text-[10px] flex-shrink-0">
<span className="text-slate-500"> </span>
<span className={`font-medium ${v.completionRate >= 1 ? 'text-emerald-600' : v.completionRate >= 0.5 ? 'text-amber-600' : 'text-rose-500'}`}>{fmtRate(v.completionRate)}</span>
</span>
</div>
<div className="flex items-center justify-between mt-0.5 text-[10px] overflow-hidden">
<div className="flex items-center gap-1.5 text-slate-400 truncate">
{v.department && <span className="text-slate-500 font-medium">{v.department.replace('业务', '')}</span>}
{v.manager && <span className="text-slate-500">{v.manager}</span>}
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<span className="text-slate-500">
<span className="text-slate-700 font-medium">{Math.round(v.customerAvgDaily)}</span> km
</span>
</div>
</div>
</div>
{/* Right */}
{!selectMode && (
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-[9px] text-slate-400"></span>
<ChevronRight size={14} className="text-slate-300" />
</div>
)}
</motion.div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
import { sendNotify, updateNotification } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
interface Props {
suggestion: SchedulingSuggestion;
candidate: CandidateVehicle;
onClose: () => void;
onSuccess: () => void;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(1) + '万';
return Math.round(value).toLocaleString();
}
function fmtRate(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSuccess }: Props) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [cancelling, setCancelling] = useState(false);
const v = s.currentVehicle;
const alreadyIntervened =
!sent && (c.notificationStatus === 'sent' || c.notificationStatus === 'executed');
const isExecuted = c.notificationStatus === 'executed';
const handleSend = async () => {
if (sending || sent || alreadyIntervened) return;
setSending(true);
try {
const result = await sendNotify({ suggestionId: s.id, currentPlate: v.plateNumber, candidatePlate: c.plateNumber });
if (result.success) { setSent(true); onSuccess(); } else { alert(result.message || '发送失败'); }
} catch { alert('网络错误'); } finally { setSending(false); }
};
const handleCancel = async () => {
if (!c.notificationId || cancelling) return;
if (isExecuted) {
if (!confirm('此干预已标记为执行。确定要取消吗?')) return;
} else {
if (!confirm(`确定取消 ${v.plateNumber}${c.plateNumber} 的干预?`)) return;
}
setCancelling(true);
try {
const result = await updateNotification(c.notificationId, { status: 'cancelled' });
if (result.success) { onSuccess(); onClose(); }
else { alert(result.message || '取消失败'); }
} catch { alert('网络错误'); } finally { setCancelling(false); }
};
return (
<div className="fixed inset-0 z-[80] bg-[#F0F4F8] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 bg-white border-b border-slate-200 flex-shrink-0">
<span className="text-sm font-bold text-slate-800"></span>
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 cursor-pointer"><X size={20} /></button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-5 py-5">
<div className="max-w-sm mx-auto space-y-4">
{/* === Swap Cards === */}
<div className="relative">
{/* Current vehicle */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {fmtKm(v.yearTarget)} km</div>
<div className="text-[10px] text-slate-400"> <b className="text-slate-700">{v.daysLeft}</b> </div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span><Blur>{v.customer || '-'}</Blur></span>
<span> <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
<span> <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
</div>
</div>
{/* Arrow bridge */}
<div className="flex justify-center -my-3 relative z-10">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center shadow-lg">
<ArrowDownUp size={16} className="text-white" />
</div>
</div>
{/* Replacement vehicle */}
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
</div>
<div className="text-right">
<div className="text-base font-black text-slate-800">{fmtKm(c.totalMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
<div className="text-[10px] text-slate-400"> {c.yearTarget ? fmtKm(c.yearTarget) : '-'} km</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
<span> <b className="text-rose-500">{fmtKm(c.mileageGap)}</b></span>
</div>
</div>
</div>
{/* === Result === */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-3"></div>
<div className="flex items-end gap-6">
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{fmtKm(c.predictedAfterSwap)} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div>
<div className="text-[9px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-black text-slate-800">{c.yearTarget ? fmtKm(c.yearTarget) : '-'} <span className="text-[10px] font-normal text-slate-400">km</span></div>
</div>
<div className={`text-sm font-black px-3 py-1 rounded-lg ${c.canQualifyAfterSwap ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
{c.canQualifyAfterSwap ? '可达标' : '需关注'}
</div>
</div>
</div>
</div>
</div>
{/* Bottom */}
<div className="px-5 pb-6 pt-2 flex-shrink-0 bg-[#F0F4F8]">
<div className="max-w-sm mx-auto space-y-2">
{alreadyIntervened && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700 flex items-center gap-2">
<CheckCircle size={13} />
<span>{isExecuted ? '执行干预' : '登记干预'}</span>
</div>
)}
{alreadyIntervened ? (
<button
onClick={handleCancel}
disabled={cancelling}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold bg-white text-rose-600 border border-rose-200 hover:bg-rose-50 active:scale-[0.98] transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Ban size={16} /> {cancelling ? '取消中...' : '取消干预'}
</button>
) : (
<button
onClick={handleSend}
disabled={sending || sent}
className={`w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all cursor-pointer ${
sent ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-lg'
}`}
>
{sent ? <><CheckCircle size={16} /> </> : <><Send size={16} /> </>}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { fetchJson } from '../../auth/api-client';
import type {
SchedulingResponse,
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationRecord,
NotificationStatus,
UpdateNotificationRequest,
} from './types';
const BASE = '/api/scheduling';
export async function fetchSuggestions(targetId?: number): Promise<SchedulingResponse> {
const params = new URLSearchParams();
if (targetId !== undefined) params.set('targetId', String(targetId));
const qs = params.toString();
return fetchJson<SchedulingResponse>(`${BASE}/suggestions${qs ? `?${qs}` : ''}`);
}
export async function sendNotify(
body: NotifyRequest,
): Promise<{ success: boolean; message: string; record?: NotificationRecord }> {
return fetchJson(`${BASE}/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
export async function sendNotifyBatch(
body: NotifyBatchRequest,
): Promise<{ success: boolean; message: string; result: NotifyBatchResult }> {
return fetchJson(`${BASE}/notify/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
export async function fetchNotifications(
status?: NotificationStatus,
limit?: number,
): Promise<{ records: NotificationRecord[] }> {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (limit) params.set('limit', String(limit));
const qs = params.toString();
return fetchJson(`${BASE}/notify${qs ? `?${qs}` : ''}`);
}
export async function updateNotification(
id: number,
body: UpdateNotificationRequest,
): Promise<{ success: boolean; record?: NotificationRecord; message?: string }> {
return fetchJson(`${BASE}/notify/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,103 @@
import type { SchedulingSuggestion, CandidateVehicle } from './types';
function csvCell(v: string | number | null | undefined): string {
if (v === null || v === undefined) return '';
const s = typeof v === 'number' ? String(v) : v;
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function pickTopCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
if (s.candidates.length === 0) return null;
const sameRegion = s.candidates.filter(c => c.isSameRegion);
const pool = sameRegion.length > 0 ? sameRegion : s.candidates;
return pool.find(c => c.canQualifyAfterSwap) ?? pool[0];
}
function pctString(rate: number): string {
return (rate * 100).toFixed(1) + '%';
}
function typeLabel(s: SchedulingSuggestion): string {
return s.type === 'replace_qualified' ? '里程高·换下' : '里程低·换走';
}
const HEADERS = [
'车牌号',
'业务部门',
'业务负责人',
'客户',
'车型',
'运营区域',
'调度类型',
'当前年里程(km)',
'年度考核(km)',
'年度完成率',
'客户30日均(km)',
'客户7日均(km)',
'剩余天数',
'最优候选车牌',
'候选当前里程(km)',
'候选替换后预估(km)',
'候选可达标',
'候选区域',
'干预状态',
] as const;
export function buildSuggestionsCsv(suggestions: SchedulingSuggestion[]): string {
const rows: string[] = [HEADERS.map(csvCell).join(',')];
for (const s of suggestions) {
const v = s.currentVehicle;
const top = pickTopCandidate(s);
const notifStatus =
s.candidates.find(c => c.notificationStatus === 'executed') ? '已执行'
: s.candidates.find(c => c.notificationStatus === 'sent') ? '待执行'
: '';
rows.push([
csvCell(v.plateNumber),
csvCell(v.department ?? ''),
csvCell(v.manager ?? ''),
csvCell(v.customer ?? ''),
csvCell(v.vehicleType),
csvCell(v.region),
csvCell(typeLabel(s)),
csvCell(Math.round(v.currentYearMileage)),
csvCell(Math.round(v.yearTarget)),
csvCell(pctString(v.completionRate)),
csvCell(Math.round(v.customerAvgDaily)),
csvCell(Math.round(v.customerAvgDaily7d)),
csvCell(v.daysLeft),
csvCell(top?.plateNumber ?? ''),
csvCell(top ? Math.round(top.totalMileage) : ''),
csvCell(top ? Math.round(top.predictedAfterSwap) : ''),
csvCell(top ? (top.canQualifyAfterSwap ? '是' : '否') : ''),
csvCell(top?.region ?? ''),
csvCell(notifStatus),
].join(','));
}
return rows.join('\r\n');
}
export function downloadCsv(filename: string, csv: string): void {
// UTF-8 BOM so Excel opens Chinese characters correctly
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function exportSuggestionsCsv(suggestions: SchedulingSuggestion[], prefix = '调度建议'): void {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const csv = buildSuggestionsCsv(suggestions);
downloadCsv(`${prefix}_${y}${m}${d}_${hh}${mm}.csv`, csv);
}

View File

@@ -0,0 +1,16 @@
export type {
SchedulingVehicleInfo,
CandidateVehicle,
SchedulingSuggestion,
SchedulingSummary,
SchedulingTargetOption,
SchedulingResponse,
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationStatus,
NotificationRecord,
UpdateNotificationRequest,
ReasonLine,
ReasonBlock,
} from '../../shared/scheduling/types';

96
src/server/auth/login.ts Normal file
View File

@@ -0,0 +1,96 @@
import { Hono } from 'hono';
import jwt from 'jsonwebtoken';
import pool from '../db.js';
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
const app = new Hono();
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
app.get('/exchange', async (c) => {
const jumpToken = c.req.query('jumpToken');
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
try {
const res = await fetch(
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
);
const data = await res.json() as {
code: number;
data: {
userInfo: {
id: string;
userName: string;
loginName: string;
depCode: string;
orgId: string;
roles: { roleName: string; id: string }[];
};
token: string;
} | null;
message: string;
};
if (data.code !== 0 || !data.data?.userInfo) {
return c.json({ error: 'Token exchange failed', message: data.message }, 401);
}
const userInfo = data.data.userInfo;
const roleNames = userInfo.roles.map(r => r.roleName);
// 确定权限级别
let permissionLevel: PermissionLevel = 'personal';
if (roleNames.some(r => FULL_ACCESS_ROLES.includes(r))) {
permissionLevel = 'full';
} else if (roleNames.some(r => DEPT_ACCESS_ROLES.includes(r))) {
permissionLevel = 'department';
}
// 查询 depCode 对应的部门名称
let depName = '';
if (userInfo.depCode) {
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
}
const payload: JwtPayload = {
userId: userInfo.id,
userName: userInfo.userName,
loginName: userInfo.loginName,
depCode: userInfo.depCode,
depName,
permissionLevel,
roles: roleNames,
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
const authUser: AuthUser = { ...payload };
return c.json({ token, user: authUser });
} catch (e: unknown) {
console.error('auth exchange error:', e);
return c.json({ error: 'Authentication failed' }, 500);
}
});
/** GET /api/auth/me — 查看当前用户信息(调试用) */
app.get('/me', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'No token' }, 401);
}
try {
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET) as JwtPayload;
return c.json(payload);
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
});
export default app;

View File

@@ -0,0 +1,45 @@
import type { Context, Next } from 'hono';
import jwt from 'jsonwebtoken';
import type { JwtPayload, AuthUser } from './types.js';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
// 演示分支:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = true;
export async function authMiddleware(c: Context, next: Next) {
const path = c.req.path;
if (BYPASS_AUTH) {
return next();
}
// 跳过不需要认证的路径
if (path === '/api/health' || path.startsWith('/api/auth/')) {
return next();
}
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
const user: AuthUser = {
userId: payload.userId,
userName: payload.userName,
loginName: payload.loginName,
depCode: payload.depCode,
depName: payload.depName,
permissionLevel: payload.permissionLevel,
roles: payload.roles ?? [],
};
c.set('user', user);
return next();
} catch {
return c.json({ error: 'Invalid or expired token' }, 401);
}
}

View File

@@ -0,0 +1,47 @@
import type { AuthUser } from './types.js';
/** 客户名称脱敏 */
export function maskCustomerName(name: string | null): string | null {
if (!name) return name;
const len = name.length;
if (len <= 1) return '*';
if (len <= 3) return name[0] + '*';
if (len <= 6) return name.slice(0, 2) + '***' + name.slice(-1);
return name.slice(0, 4) + '***' + name.slice(-2);
}
/** 对数据列表中的客户名称进行脱敏 */
export function maskCustomerNames<T>(items: T[]): T[] {
return items.map(v => {
const obj = v as any;
const copy = { ...obj };
if ('customer' in copy && copy.customer) copy.customer = maskCustomerName(copy.customer);
if ('customerName' in copy && copy.customerName) copy.customerName = maskCustomerName(copy.customerName);
return copy as T;
});
}
/**
* 通用权限过滤函数
* 适配 CachedVehicledepartment, manager, managerId和 VehicledepartmentName, customerManager, managerId
*/
export function filterByPermission<T>(
items: T[],
user: AuthUser,
): T[] {
if (user.permissionLevel === 'full') return items;
if (user.permissionLevel === 'department') {
return items.filter(v => {
const obj = v as any;
const dept = obj.departmentName || obj.department || null;
return dept === user.depName;
});
}
// personal: 仅看自己负责的车辆 (bd = userId)
return items.filter(v => {
const obj = v as any;
return obj.managerId === user.userId;
});
}

32
src/server/auth/types.ts Normal file
View File

@@ -0,0 +1,32 @@
export type PermissionLevel = 'full' | 'department' | 'personal';
export interface AuthUser {
userId: string;
userName: string;
loginName: string;
depCode: string;
depName: string;
permissionLevel: PermissionLevel;
roles: string[];
}
export interface JwtPayload {
userId: string;
userName: string;
loginName: string;
depCode: string;
depName: string;
permissionLevel: PermissionLevel;
roles: string[];
iat?: number;
exp?: number;
}
// Re-export role constants and helpers from the shared module so existing
// server imports (`from './types.js'`) keep working.
export {
FULL_ACCESS_ROLES,
DEPT_ACCESS_ROLES,
SCHEDULING_ACCESS_ROLES,
canAccessScheduling,
} from '../../shared/auth/roles.js';

View File

@@ -4,13 +4,27 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
dotenv.config();
const app = new Hono();
app.use('/api/*', cors());
// Auth 路由(不需要中间件)
app.route('/api/auth', authRouter);
// Auth 中间件(保护后续所有 /api/* 路由)
app.use('/api/*', authMiddleware);
app.route('/api/vehicles', vehiclesRouter);
app.route('/api/mileage', mileageRouter);
app.route('/api/scheduling', schedulingRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
@@ -21,6 +35,7 @@ app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
const port = Number(process.env.SERVER_PORT) || 3001;
console.log(`Server starting on port ${port}...`);
ensureSchedulingTables().catch(e => console.error('scheduling bootstrap error:', e));
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running at http://localhost:${port}`);
});

14
src/server/mileage-db.ts Normal file
View File

@@ -0,0 +1,14 @@
import mysql from 'mysql2/promise';
const mileagePool = mysql.createPool({
host: '101.133.130.65',
port: 3306,
user: 'bi_reader_02',
password: 'bi_reader_02_Pass',
database: 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default mileagePool;

View File

@@ -0,0 +1,200 @@
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
return monitoringCache;
}
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
);
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
}
interface MileageRow {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
}
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.vehicle_total_mileage);
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
bizTotalMap: Map<string, number>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(mileageMap.values()).map(m => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
const bizTotal = bizTotalMap.get(m.plate);
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: gpsTotal !== null ? gpsTotal : (bizTotal ?? null),
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
managerId: info?.manager_id || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
}
export async function refreshMonitoringCache(): Promise<void> {
try {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [MileageRow[], unknown];
return rows;
})(),
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [{ plate: string; daily_km: string }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
fetchVehicleInfoMap(),
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
fetchBizTotalMileageMap(),
]);
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: buildFilters(vehicles, targetNames),
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
} catch (e: unknown) {
console.error('[mileage] cache refresh error:', e);
}
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap, bizTotalMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as MileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
fetchBizTotalMileageMap(),
]);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
}

View File

@@ -0,0 +1,18 @@
import { Hono } from 'hono';
import { refreshMonitoringCache } from './cache.js';
import monitoringRouter from './monitoring.js';
import targetsRouter from './targets.js';
import trendRouter from './trend.js';
const app = new Hono();
app.route('/monitoring', monitoringRouter);
app.route('/targets', targetsRouter);
app.route('/target', targetsRouter);
app.route('/trend', trendRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
export default app;

View File

@@ -0,0 +1,128 @@
import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
total: 0,
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
};
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
if (params.search) {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
return result;
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const filterParams = {
search: c.req.query('search') || '',
dept: c.req.query('dept') || '',
customer: c.req.query('customer') || '',
project: c.req.query('project') || '',
entity: c.req.query('entity') || '',
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};
let allVehicles: CachedVehicle[];
let filters: MonitoringFilters;
if (date) {
try {
allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else {
const cache = getCache();
if (!cache) return c.json(EMPTY_RESPONSE);
allVehicles = cache.vehicles;
filters = cache.filters;
}
// 权限过滤
const user = (c as any).get('user') as AuthUser | undefined;
if (user) {
allVehicles = filterByPermission(allVehicles, user);
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
}
const filtered = applyFilters(allVehicles, filterParams);
const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
const sorted = [...filtered].sort((a, b) => {
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
const offset = (page - 1) * limit;
const paged = sorted.slice(offset, offset + limit);
const total = filtered.length;
return c.json({
vehicles: maskCustomerNames(paged),
stats,
filters,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
});
});
export default app;

View File

@@ -0,0 +1,183 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
const app = new Hono();
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
const list = periodsMap.get(p.target_id) || [];
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
periodsMap.set(p.target_id, list);
}
const cache = getCache();
const cacheVehicleMap = new Map<string, number>();
if (cache) {
for (const v of cache.vehicles) {
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
}
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
const list = targetIdPlatesMap.get(r.target_id) || [];
list.push(r.plate_number);
targetIdPlatesMap.set(r.target_id, list);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
const startDate = t.default_start_date ? new Date(t.default_start_date).toISOString().split('T')[0] : '';
const endDate = t.default_end_date ? new Date(t.default_end_date).toISOString().split('T')[0] : '';
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
}
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('targets error:', e);
return c.json([], 500);
}
});
app.get('/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as [any[], unknown];
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
const user = (c as any).get('user') as import('../../auth/types.js').AuthUser | undefined;
const filtered = user ? filterByPermission(result, user) : result;
return c.json(maskCustomerNames(filtered));
} catch (e: unknown) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
export default app;

View File

@@ -0,0 +1,48 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
})));
} catch (e: unknown) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;

View File

@@ -0,0 +1,76 @@
/** 缓存中的单辆车数据 */
export interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
managerId: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
yesterdayKm: number;
}
/** 车牌前缀统计 */
export interface PlatePrefix {
prefix: string;
count: number;
}
/** 筛选选项(前端下拉) */
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
projects: string[];
entities: string[];
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
}
/** 监控缓存 */
export interface MonitoringCache {
vehicles: CachedVehicle[];
stats: { totalToday: number; totalAll: number; vehicleCount: number };
filters: MonitoringFilters;
targetPlatesMap: Map<string, Set<string>>;
updatedAt: string;
}
/** /monitoring 响应中的统计 */
export interface MonitoringStats {
totalToday: number;
totalAll: number;
vehicleCount: number;
yesterdayTotal: number;
}
/** /monitoring 完整响应 */
export interface MonitoringResponse {
vehicles: CachedVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow {
plate: string;
customer: string | null;
department: string | null;
manager: string | null;
manager_id: string | null;
rent_status: string | null;
entity: string | null;
project: string | null;
}

View File

@@ -0,0 +1,47 @@
import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
CAST(c.bd AS CHAR) AS manager_id,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
/** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}

View File

@@ -0,0 +1,257 @@
import type {
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
CandidateVehicle, VehicleClassification, SchedulingSummary,
ReasonBlock,
} from './types.js';
function fmtKmSimple(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
return Math.round(v).toLocaleString();
}
// ---------------------------------------------------------------------------
// 1. Vehicle type compatibility
// ---------------------------------------------------------------------------
export function isTypeCompatible(sourceType: string, candidateType: string): boolean {
if (sourceType === candidateType) return true;
// Cold-chain 4.5T can replace plain-cargo 4.5T
if (candidateType === '4.5T冷链' && (sourceType === '4.5T冷链' || sourceType === '4.5T普货')) return true;
return false;
}
// ---------------------------------------------------------------------------
// 2. Vehicle classification
// ---------------------------------------------------------------------------
export function classifyVehicle(
currentYearIsQualified: boolean,
currentYearMileage: number,
yearTarget: number,
predictedYearEnd: number,
): VehicleClassification {
// qualified: current year mileage already >= target (actually done, not just predicted)
const actualRate = yearTarget > 0 ? currentYearMileage / yearTarget : 0;
if (currentYearIsQualified || actualRate >= 1.0) return 'qualified';
// hopeless: even with remaining days, predicted < 60% of target
if (yearTarget > 0 && predictedYearEnd / yearTarget < 0.6) return 'hopeless';
return 'normal';
}
// ---------------------------------------------------------------------------
// 3. Helper convert EnrichedVehicle to SchedulingVehicleInfo shape
// ---------------------------------------------------------------------------
import type { SchedulingVehicleInfo } from './types.js';
export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
// Use current year completion rate instead of overall
const yearCompletionRate = v.yearTarget > 0 ? v.currentYearMileage / v.yearTarget : 0;
return {
plateNumber: v.plateNumber,
targetId: v.targetId,
targetName: v.targetName,
vehicleType: v.vehicleType,
totalMileage: v.totalMileage,
currentYearMileage: v.currentYearMileage,
completionRate: yearCompletionRate,
yearTarget: v.yearTarget,
region: v.region,
province: v.province,
customer: v.customer,
department: v.department,
manager: v.manager,
customerAvgDaily: v.customerAvgDaily,
customerAvgDaily7d: v.customerAvgDaily7d,
predictedYearEnd: v.predictedYearEnd,
daysLeft: v.daysLeft,
};
}
// ---------------------------------------------------------------------------
// 4. Main algorithm generate scheduling suggestions
// ---------------------------------------------------------------------------
export function generateSuggestions(
vehicles: EnrichedVehicle[],
inventoryVehicles: InventoryVehicle[],
): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } {
const qualified = vehicles.filter((v) => v.classification === 'qualified');
const hopeless = vehicles.filter((v) => v.classification === 'hopeless');
const suggestions: SchedulingSuggestion[] = [];
// --- rescue_hopeless (high priority) ---
// Take the hopeless car away → give to high-mileage customer to sprint.
// Replace with an inventory car that is CLOSE to qualifying — the low-mileage
// customer's remaining driving days can push it over the finish line.
//
// Key insight: pick candidates where
// candidate.totalMileage + customer.avgDaily × daysLeft >= yearTarget
// i.e., the customer's daily driving is enough to finish the candidate's target.
// Among those, prefer the one with the smallest gap (easiest to finish).
// Exclude already-qualified (>= 100%) — no value in swapping those.
for (const vehicle of hopeless) {
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true;
})
.map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
isSameRegion: inv.region === vehicle.region,
notificationId: null,
notificationStatus: null,
};
})
.sort((a, b) => {
// 1. Same-region first (business rule: prefer same-region swaps)
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
// 2. Can-qualify next
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// 3. Smallest gap (closest to target)
return a.mileageGap - b.mileageGap;
})
;
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
const reason: ReasonBlock = {
lines: [
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
{ label: '年度考核剩余', value: `${vehicle.daysLeft}` },
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
],
conclusion: '预估无法达标,需替换',
};
suggestions.push({
id: `hopeless-${vehicle.plateNumber}`,
priority: 'high',
type: 'rescue_hopeless',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// --- replace_qualified (medium priority) ---
// Every qualified vehicle gets a suggestion row so the list count matches
// `qualifiedCount`. Candidates may be empty when no inventory vehicle can
// reach target at this customer — the row still surfaces for manual review.
for (const vehicle of qualified) {
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true;
})
.map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
isSameRegion: inv.region === vehicle.region,
notificationId: null,
notificationStatus: null,
};
})
// Only keep candidates that can actually qualify at this customer —
// swapping in a car that still can't reach target wastes the high-mileage customer
.filter(c => c.canQualifyAfterSwap)
.sort((a, b) => {
// 1. Same-region first
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
// 2. Biggest gap first (most value from the swap)
return b.mileageGap - a.mileageGap;
})
;
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
const reason: ReasonBlock = {
lines: [
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
{ label: '年度完成率', value: `${yearRate}%` },
{ label: '年度考核剩余', value: `${vehicle.daysLeft}` },
{ label: '可为新车贡献', value: `${fmtKmSimple(Math.round(canAddKm))} km` },
],
conclusion: '已达标,建议换上未达标车辆',
};
suggestions.push({
id: `qualified-${vehicle.plateNumber}`,
priority: 'medium',
type: 'replace_qualified',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// Drop rescue_hopeless with no candidates — no actionable rescue available.
// Keep every replace_qualified so the list count matches the qualifiedCount card.
const filteredSuggestions = suggestions.filter(
(s) => s.type === 'replace_qualified' || s.candidates.length > 0,
);
// Sort: high priority first
filteredSuggestions.sort((a, b) => {
if (a.priority === b.priority) return 0;
return a.priority === 'high' ? -1 : 1;
});
// estimatedGain uses strict definition: count suggestions that have at least
// one candidate able to qualify after swap. The API layer recomputes this
// post permission-filtering, so keep both sides consistent.
const estimatedGain = filteredSuggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap),
).length;
const summary: SchedulingSummary = {
qualifiedCount: qualified.length,
hopelessCount: hopeless.length,
suggestionCount: filteredSuggestions.length,
estimatedGain,
recentInterventionCount: 0,
};
return { suggestions: filteredSuggestions, summary };
}

View File

@@ -0,0 +1,34 @@
import pool from '../../db.js';
const CREATE_NOTIFICATIONS_TABLE = `
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
suggestion_id VARCHAR(128) NOT NULL,
current_plate VARCHAR(32) NOT NULL,
candidate_plate VARCHAR(32) NOT NULL,
operator_id VARCHAR(64),
operator_name VARCHAR(128),
status VARCHAR(16) NOT NULL DEFAULT 'sent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
executed_at DATETIME NULL,
notes VARCHAR(500) NULL,
before_mileage INT NULL,
after_mileage INT NULL,
INDEX idx_suggestion_id (suggestion_id),
INDEX idx_current_plate (current_plate),
INDEX idx_candidate_plate (candidate_plate),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
`;
export async function ensureSchedulingTables(): Promise<void> {
try {
await pool.query(CREATE_NOTIFICATIONS_TABLE);
console.log('[scheduling] notifications table ready');
} catch (e) {
console.error('[scheduling] failed to ensure tables:', e);
throw e;
}
}

View File

@@ -0,0 +1,23 @@
import { Hono } from 'hono';
import suggestionsRouter from './suggestions.js';
import notifyRouter from './notify.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessScheduling } from '../../auth/types.js';
const app = new Hono();
// Module-level access guard. When auth middleware is active, `user` is set and
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
// When auth is bypassed (dev), `user` is undefined and requests pass through.
app.use('*', async (c, next) => {
const user = (c as any).get('user') as AuthUser | undefined;
if (user && !canAccessScheduling(user.roles)) {
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
}
return next();
});
app.route('/suggestions', suggestionsRouter);
app.route('/notify', notifyRouter);
export default app;

View File

@@ -0,0 +1,281 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import type {
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationRecord,
NotificationStatus,
UpdateNotificationRequest,
} from './types.js';
const app = new Hono();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rowToRecord(row: any): NotificationRecord {
return {
id: Number(row.id),
suggestionId: row.suggestion_id,
currentPlate: row.current_plate,
candidatePlate: row.candidate_plate,
operatorId: row.operator_id,
operatorName: row.operator_name,
status: row.status,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
notes: row.notes,
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
};
}
/**
* Count non-cancelled interventions created within the last 7 days.
*/
export async function fetchRecentInterventionCount(): Promise<number> {
const [rows] = (await pool.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
}
/**
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
*/
export async function fetchActiveNotificationMap(): Promise<
Map<string, { id: number; status: NotificationStatus }>
> {
const [rows] = (await pool.execute(
`SELECT id, suggestion_id, candidate_plate, status, created_at
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [any[], unknown];
const map = new Map<string, { id: number; status: NotificationStatus }>();
for (const row of rows) {
const key = `${row.suggestion_id}::${row.candidate_plate}`;
if (!map.has(key)) {
map.set(key, { id: Number(row.id), status: row.status });
}
}
return map;
}
async function insertNotification(
req: NotifyRequest,
operator: { id: string | null; name: string | null },
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
// active intervention at a time. Any non-cancelled record for the same
// suggestion_id blocks further interventions until it is cancelled.
const [existing] = (await pool.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[req.suggestionId],
)) as [any[], unknown];
if (existing.length > 0) {
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
}
const [result] = (await pool.execute(
`INSERT INTO tab_scheduling_notifications
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
VALUES (?, ?, ?, ?, ?, 'sent')`,
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
)) as [any, unknown];
const insertedId = Number(result.insertId);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[insertedId],
)) as [any[], unknown];
return rowToRecord(rows[0]);
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// POST /api/scheduling/notify — single notify
app.post('/', async (c) => {
try {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!suggestionId || !currentPlate || !candidatePlate) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const result = await insertNotification(body, operator);
if ('skipped' in result) {
return c.json(
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
409,
);
}
console.log(
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
);
return c.json({
success: true,
message: `干预已登记:${currentPlate}${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
}
});
// POST /api/scheduling/notify/batch — bulk notify
app.post('/batch', async (c) => {
try {
const body = await c.req.json<NotifyBatchRequest>();
if (!Array.isArray(body.items) || body.items.length === 0) {
return c.json({ success: false, message: '缺少 items' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
result.failed++;
continue;
}
try {
const r = await insertNotification(item, operator);
if ('skipped' in r) result.skipped++;
else {
result.success++;
result.records.push(r);
}
} catch {
result.failed++;
}
}
console.log(
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
);
return c.json({
success: true,
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result,
});
} catch (e: unknown) {
console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量干预失败' }, 500);
}
});
// GET /api/scheduling/notify — list all notifications (history)
app.get('/', async (c) => {
try {
const status = c.req.query('status');
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
const where: string[] = [];
const params: (string | number)[] = [];
if (status) {
where.push('status = ?');
params.push(status);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(limit);
const [rows] = (await pool.query(
`SELECT * FROM tab_scheduling_notifications
${whereSql}
ORDER BY created_at DESC
LIMIT ?`,
params,
)) as [any[], unknown];
return c.json({ records: rows.map(rowToRecord) });
} catch (e: unknown) {
console.error('scheduling notifications list error:', e);
return c.json({ records: [] }, 500);
}
});
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
app.patch('/:id', async (c) => {
try {
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) {
return c.json({ success: false, message: 'id 无效' }, 400);
}
const body = await c.req.json<UpdateNotificationRequest>();
if (!body.status) {
return c.json({ success: false, message: '缺少 status' }, 400);
}
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
if (!validStatuses.includes(body.status)) {
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const fields: string[] = ['status = ?'];
const params: (string | number | null)[] = [body.status];
if (body.status === 'executed') {
fields.push('executed_at = CURRENT_TIMESTAMP');
}
if (body.notes !== undefined) {
fields.push('notes = ?');
params.push(body.notes);
}
if (body.afterMileage !== undefined) {
fields.push('after_mileage = ?');
params.push(body.afterMileage);
}
params.push(id);
await pool.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [any[], unknown];
if (rows.length === 0) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({ success: true, record: rowToRecord(rows[0]) });
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
}
});
export default app;

View File

@@ -0,0 +1,376 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
import type { AuthUser } from '../../auth/types.js';
// ---------------------------------------------------------------------------
// Helper: vehicle type classification
// ---------------------------------------------------------------------------
/**
* Infer vehicle type from target name when truck table has no match.
* e.g. "交投190辆4.5T冷链车" → "4.5T冷链", "羚牛100辆18T" → "18T"
*/
function inferTypeFromTargetName(targetName: string): string {
const t = targetName || '';
if (t.includes('冷链')) return '4.5T冷链';
if (t.includes('普货') || (t.includes('4.5') && !t.includes('冷链'))) return '4.5T普货';
if (t.includes('18T') || t.includes('18t')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
return '其他';
}
/**
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/
function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
return t || '其他';
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
const app = new Hono();
app.get('/', async (c) => {
try {
const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
for (const t of targets) {
targetMap.set(t.id, {
targetName: t.target_name,
annualMileage: Number(t.annual_mileage_per_vehicle) || 0,
});
}
// ---- Query 2: Assessment vehicles ----
const [assessmentRows] = await pool.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from tab_truck ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) {
truckTypeMap.set(row.plate_number, {
typeName: row.type_name || '',
modelRaw: row.model_raw || '',
});
}
// ---- Query 5: Real-time location ----
const [locationRows] = await pool.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`) as [any[], unknown];
const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) {
locationMap.set(row.plate_number, {
province: row.province || '',
city: row.city || '',
});
}
// ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
const customerAvgDailyMap = new Map<string, number>();
const customerAvgDaily7dMap = new Map<string, number>();
if (allPlates.length > 0) {
const placeholders = allPlates.map(() => '?').join(',');
// Single query returning both windows per plate.
const [dailyRows] = await mileagePool.execute(
`SELECT plate,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE()
AND plate IN (${placeholders})
GROUP BY plate`,
allPlates,
) as [any[], unknown];
const plateAvg30Map = new Map<string, number>();
const plateAvg7Map = new Map<string, number>();
for (const row of dailyRows) {
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
}
const customerPlates30 = new Map<string, number[]>();
const customerPlates7 = new Map<string, number[]>();
for (const plate of allPlates) {
const info = vehicleInfoMap.get(plate);
const customer = info?.customer || '未知客户';
if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
const v30 = plateAvg30Map.get(plate);
const v7 = plateAvg7Map.get(plate);
if (v30 !== undefined) customerPlates30.get(customer)!.push(v30);
if (v7 !== undefined) customerPlates7.get(customer)!.push(v7);
}
for (const [customer, avgs] of customerPlates30) {
if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
}
for (const [customer, avgs] of customerPlates7) {
if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
}
}
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND truck.truck_rent_status = 0
`) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>();
for (const row of assessmentRows) {
assessmentByPlate.set(row.plate_number, row);
}
// ---- Enrich assessment vehicles ----
const now = new Date();
const yearEnd = new Date(now.getFullYear(), 11, 31); // Dec 31
const enrichedVehicles: EnrichedVehicle[] = [];
for (const row of assessmentRows) {
const targetId = row.target_id as number;
if (filterTargetId !== null && targetId !== filterTargetId) continue;
const target = targetMap.get(targetId);
if (!target) continue;
const plate = row.plate_number as string;
const info = vehicleInfoMap.get(plate);
// Only include vehicles that are actively rented/operated (租赁 or 自营)
const rentStatus = info?.rent_status || '';
if (rentStatus !== '租赁' && rentStatus !== '自营') continue;
const loc = locationMap.get(plate);
const truckType = truckTypeMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
// Determine vehicle type: prefer truck table, fallback to target name
let vehicleType = '其他';
if (truckType) {
vehicleType = classifyVehicleType(truckType.typeName, truckType.modelRaw);
} else {
// Fallback: infer from target name (e.g. "交投190辆4.5T冷链车" → "4.5T冷链")
vehicleType = inferTypeFromTargetName(target.targetName);
}
const endDate = row.current_year_assessment_end_date
? new Date(row.current_year_assessment_end_date)
: yearEnd;
const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
const customer = info?.customer || null;
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
const currentYearMileage = Number(row.current_year_mileage) || 0;
const yearTarget = Number(row.current_year_mileage_task) || 0;
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
const currentYearIsQualified = row.current_year_is_qualified === 1;
const classification = classifyVehicle(currentYearIsQualified, currentYearMileage, yearTarget, predictedYearEnd);
enrichedVehicles.push({
plateNumber: plate,
targetId,
targetName: target.targetName,
vehicleType,
totalMileage: Number(row.vehicle_total_mileage) || 0,
currentYearMileage,
completionRate: Number(row.completion_rate) || 0,
yearTarget,
isQualified: row.is_qualified === 1,
currentYearIsQualified,
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
region,
province,
customer,
department: info?.department || null,
manager: info?.manager || null,
customerAvgDaily,
customerAvgDaily7d,
predictedYearEnd,
daysLeft,
classification,
});
}
// ---- Build inventory vehicles ----
const inventoryVehicles: InventoryVehicle[] = [];
for (const row of inventoryTruckRows) {
const plate = row.plate_number as string;
const loc = locationMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
// Cross-reference with assessment data
const assessment = assessmentByPlate.get(plate);
// Compute this vehicle's own daysLeft from its assessment end date
let invDaysLeft = 0;
if (assessment?.current_year_assessment_end_date) {
const endDate = new Date(assessment.current_year_assessment_end_date);
invDaysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
} else {
invDaysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
}
inventoryVehicles.push({
plateNumber: plate,
vehicleType,
region,
province,
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
daysLeft: invDaysLeft,
targetId: assessment ? (assessment.target_id as number) : null,
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
completionRate: assessment ? Number(assessment.completion_rate) || 0 : 0,
});
}
// ---- Run algorithm ----
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Attach notification status to candidates ----
const notificationMap = await fetchActiveNotificationMap();
for (const s of suggestions) {
for (const c of s.candidates) {
const key = `${s.id}::${c.plateNumber}`;
const notif = notificationMap.get(key);
if (notif) {
c.notificationId = notif.id;
c.notificationStatus = notif.status;
}
}
}
// ---- Permission filtering & customer name masking ----
const user = (c as any).get('user') as AuthUser | undefined;
// Attach department/manager info so filterByPermission can work
const suggestionsWithPermFields = suggestions.map((s) => {
const info = vehicleInfoMap.get(s.currentVehicle.plateNumber);
return {
...s,
department: info?.department || null,
departmentName: info?.department || null,
managerId: info?.manager_id || null,
};
});
const filtered = user
? filterByPermission(suggestionsWithPermFields, user)
: suggestionsWithPermFields;
// Mask customer names in suggestions
const masked = maskCustomerNames(
filtered.map((s) => {
// Strip permission-filtering fields from response
const { department, departmentName, managerId, ...rest } = s;
return rest;
}),
);
// ---- Build target options list for filter UI ----
const targetVehicleCounts = new Map<number, number>();
for (const v of enrichedVehicles) {
targetVehicleCounts.set(v.targetId, (targetVehicleCounts.get(v.targetId) || 0) + 1);
}
const targetOptions = targets.map((t: any) => ({
id: t.id as number,
name: t.target_name as string,
vehicleCount: targetVehicleCounts.get(t.id) || 0,
}));
// Recalculate summary based on permission-filtered results
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
const recentInterventionCount = await fetchRecentInterventionCount();
const filteredSummary: SchedulingSummary = {
qualifiedCount: filteredQualified,
hopelessCount: filteredHopeless,
suggestionCount: masked.length,
estimatedGain: masked.filter((s: any) =>
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
).length,
recentInterventionCount,
};
const response: SchedulingResponse = {
summary: filteredSummary,
suggestions: masked,
targets: targetOptions,
};
return c.json(response);
} catch (e: unknown) {
console.error('scheduling suggestions error:', e);
return c.json(
{
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
suggestions: [],
targets: [],
} satisfies SchedulingResponse,
500,
);
}
});
export default app;

View File

@@ -0,0 +1,59 @@
export type {
SchedulingVehicleInfo,
CandidateVehicle,
SchedulingSuggestion,
SchedulingSummary,
SchedulingTargetOption,
SchedulingResponse,
NotifyRequest,
NotifyBatchRequest,
NotifyBatchResult,
NotificationStatus,
NotificationRecord,
UpdateNotificationRequest,
ReasonLine,
ReasonBlock,
} from '../../../shared/scheduling/types.js';
// ---------------------------------------------------------------------------
// Server-only types
// ---------------------------------------------------------------------------
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
export interface EnrichedVehicle {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number;
yearTarget: number;
isQualified: boolean;
currentYearIsQualified: boolean;
dailyRequiredMileage: number;
region: string;
province: string;
customer: string | null;
department: string | null;
manager: string | null;
customerAvgDaily: number;
customerAvgDaily7d: number;
predictedYearEnd: number;
daysLeft: number;
classification: VehicleClassification;
}
export interface InventoryVehicle {
plateNumber: string;
vehicleType: string;
region: string;
province: string;
totalMileage: number;
daysLeft: number;
targetId: number | null;
targetName: string | null;
yearTarget: number | null;
completionRate: number;
}

View File

@@ -10,6 +10,9 @@ import type {
BatchGroup,
InventoryTypeSummary,
} from '../types.js';
import { filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
import type { AuthUser } from '../auth/types.js';
import type { Context } from 'hono';
const app = new Hono();
@@ -39,7 +42,8 @@ const MAIN_SQL = `SELECT
dep.dep_name AS 合同归属部门,
org_truck.org_name AS 主体,
c.project_name AS 项目名称,
u.user_name AS 客户经理
u.user_name AS 客户经理,
CAST(c.bd AS CHAR) AS 经理ID
FROM tab_truck truck
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.id = truck.id
@@ -87,7 +91,7 @@ WHERE truck.is_deleted = 0
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
function mapRegion(province: string | null, city: string | null): string {
export function mapRegion(province: string | null, city: string | null): string {
if (!province && !city) return '其他';
const loc = (city || province || '').trim();
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
@@ -285,6 +289,7 @@ function transformRow(row: VehicleRow): Vehicle {
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
managerId: row.经理ID || null,
brandLabel: row.车辆品牌Label,
};
}
@@ -305,6 +310,26 @@ async function getVehicles(): Promise<Vehicle[]> {
return cachedVehicles;
}
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
const all = await getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
let list = user ? filterByPermission(all, user) : all;
list = applySubjectFilter(c, list);
return maskCustomerNames(list);
}
// 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null;
}
function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
const subject = getSubjectParam(c);
if (!subject) return vehicles;
return vehicles.filter((v) => (v.subjectOrg || '') === subject);
}
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
return regions.reduce((acc, reg) => {
acc[reg] = vehicles.filter((v) => v.location === reg).length;
@@ -491,7 +516,8 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
// GET /api/vehicles/summary
app.get('/summary', async (c) => {
const [vehicles, weekly] = await Promise.all([getVehicles(), getWeeklyStats()]);
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), getWeeklyTruckIds()]);
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
const summary: SummaryData = {
totalAssets: vehicles.length,
operating: {
@@ -507,18 +533,18 @@ app.get('/summary', async (c) => {
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
},
pendingDelivery: vehicles.filter((v) => v.status === 'Pending').length,
weeklyNew: weekly.weeklyNew,
weeklyRemoved: weekly.weeklyRemoved,
weeklyDelivered: weekly.weeklyDelivered,
weeklyReturned: weekly.weeklyReturned,
weeklyReplaced: weekly.weeklyReplaced,
weeklyNew: 0,
weeklyRemoved: 0,
weeklyDelivered: [...weeklyIds.delivered].filter(id => vehicleIds.has(id)).length,
weeklyReturned: [...weeklyIds.returned].filter(id => vehicleIds.has(id)).length,
weeklyReplaced: [...weeklyIds.replaced].filter(id => vehicleIds.has(id)).length,
};
return c.json(summary);
});
// GET /api/vehicles/by-type
app.get('/by-type', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehicles(), getWeeklyTruckIds()]);
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), getWeeklyTruckIds()]);
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
@@ -566,7 +592,7 @@ app.get('/by-type', async (c) => {
// GET /api/vehicles/by-batch
app.get('/by-batch', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
.filter(Boolean)
.sort()
@@ -593,9 +619,9 @@ app.get('/by-batch', async (c) => {
return c.json(result);
});
// GET /api/vehicles/inventory-analysis
// GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放
app.get('/inventory-analysis', async (c) => {
const vehicles = await getVehicles();
const vehicles = applySubjectFilter(c, await getVehicles());
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
@@ -649,7 +675,7 @@ app.get('/inventory-analysis', async (c) => {
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const withManager = vehicles.filter((v) => v.status === 'Operating');
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
@@ -664,16 +690,43 @@ app.get('/dept-stats', async (c) => {
if (plate) todayMileageMap.set(plate, Number(row.day_mileage) || 0);
}
// 不在部门列表展示的用户(非业务员或管理账号)
const EXCLUDED_MANAGERS = new Set(['超级用户', '刘思宇', '潘舒', '黄卓华', '许铮杰']);
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '公务车';
const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue;
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
mgrMap.get(mgr)!.push(v);
}
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
if (deptNames.length > 0) {
const placeholders = deptNames.map(() => '?').join(',');
const [userRows] = await pool.query<any[]>(
`SELECT u.user_name, dep.dep_name
FROM tab_user u
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE u.is_deleted = 0
AND dep.dep_name IN (${placeholders})`,
deptNames,
);
for (const r of userRows as any[]) {
const dept = r.dep_name as string | null;
const mgr = r.user_name as string | null;
if (!dept || !mgr) continue;
if (EXCLUDED_MANAGERS.has(mgr)) continue;
const mgrMap = deptMap.get(dept);
if (!mgrMap) continue;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
}
}
// Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
@@ -723,7 +776,7 @@ app.get('/dept-stats', async (c) => {
// GET /api/vehicles/region-stats — macro-region with city drill-down
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const { customer, city: filterCity, region: filterRegion } = c.req.query();
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
if (customer) operating = operating.filter((v) => v.customerName === customer);
@@ -799,7 +852,7 @@ app.get('/region-stats', async (c) => {
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const operating = vehicles.filter((v) => v.status === 'Operating');
const custMap = new Map<string, Vehicle[]>();
@@ -839,7 +892,7 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
// GET /api/vehicles/list — flat list with optional filters
app.get('/list', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
let filtered = vehicles;
@@ -933,9 +986,9 @@ app.get('/list', async (c) => {
);
});
// GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => {
const vehicles = await getVehicles();
const vehicles = applySubjectFilter(c, await getVehicles());
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
const TYPE_NAME_MAP: Record<string, string> = {
@@ -990,14 +1043,39 @@ app.get('/weekly-detail', async (c) => {
return c.json([]);
}
const [rows] = await pool.query<any[]>(sql);
return c.json(rows);
const masked = (rows as any[]).map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
return c.json(masked);
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
const all = await getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
const visible = user ? filterByPermission(all, user) : all;
const map = new Map<string, { total: number; inventory: number; operating: number }>();
for (const v of visible) {
const name = (v.subjectOrg || '').trim();
if (!name) continue;
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
const s = map.get(name)!;
s.total += 1;
if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1;
if (v.status === 'Operating') s.operating += 1;
}
const result = Array.from(map.entries())
.map(([name, stats]) => ({ name, ...stats }))
.sort((a, b) => b.total - a.total);
return c.json(result);
});
// GET /api/vehicles/refresh — force cache refresh
app.get('/refresh', async (c) => {
lastFetchTime = 0;
weeklyStatsLastFetch = 0;
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
return c.json({ message: 'Cache refreshed', count: vehicles.length });
});
@@ -1032,7 +1110,7 @@ app.get('/debug', async (c) => {
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
app.get('/region-chart', async (c) => {
const vehicles = await getVehicles();
const vehicles = await getVehiclesForUser(c);
const operating = vehicles.filter((v) => v.status === 'Operating');
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'

View File

@@ -25,6 +25,7 @@ export interface VehicleRow {
主体: string | null;
项目名称: string | null;
客户经理: string | null;
经理ID: string | null;
}
export interface Vehicle {
@@ -48,6 +49,7 @@ export interface Vehicle {
subjectOrg: string | null;
projectName: string | null;
customerManager: string | null;
managerId: string | null;
brandLabel: string | null;
}

17
src/shared/auth/roles.ts Normal file
View File

@@ -0,0 +1,17 @@
// Role constants and role-based access helpers shared between server (JWT
// issuance / API guards) and client (nav visibility / module gating).
/** 全量权限角色名 */
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
/** 部门级权限角色名 */
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
/** 智能调度模块访问角色 */
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
}

View File

@@ -0,0 +1,123 @@
// Shared scheduling types — used by both client (modules/scheduling) and server
// (server/routes/scheduling). Keep server-only types (EnrichedVehicle etc.) in
// server/routes/scheduling/types.ts.
export interface SchedulingVehicleInfo {
plateNumber: string;
targetId: number;
targetName: string;
vehicleType: string;
totalMileage: number;
currentYearMileage: number;
completionRate: number;
yearTarget: number;
region: string;
province: string;
customer: string | null;
department: string | null;
manager: string | null;
customerAvgDaily: number;
customerAvgDaily7d: number;
predictedYearEnd: number;
daysLeft: number;
}
export type NotificationStatus = 'sent' | 'executed' | 'cancelled';
export interface CandidateVehicle {
plateNumber: string;
targetId: number | null;
targetName: string | null;
vehicleType: string;
totalMileage: number;
completionRate: number;
yearTarget: number | null;
daysLeft: number;
region: string;
province: string;
mileageGap: number;
predictedAfterSwap: number;
canQualifyAfterSwap: boolean;
isSameRegion: boolean;
notificationId: number | null;
notificationStatus: NotificationStatus | null;
}
export interface NotificationRecord {
id: number;
suggestionId: string;
currentPlate: string;
candidatePlate: string;
operatorId: string | null;
operatorName: string | null;
status: NotificationStatus;
createdAt: string;
updatedAt: string;
executedAt: string | null;
notes: string | null;
beforeMileage: number | null;
afterMileage: number | null;
}
export interface NotifyBatchRequest {
items: NotifyRequest[];
}
export interface NotifyBatchResult {
success: number;
skipped: number;
failed: number;
records: NotificationRecord[];
}
export interface UpdateNotificationRequest {
status: NotificationStatus;
notes?: string;
afterMileage?: number;
}
export interface ReasonLine {
label: string;
value: string;
}
export interface ReasonBlock {
lines: ReasonLine[];
conclusion: string;
}
export interface SchedulingSuggestion {
id: string;
priority: 'high' | 'medium';
type: 'replace_qualified' | 'rescue_hopeless';
currentVehicle: SchedulingVehicleInfo;
candidates: CandidateVehicle[];
reason: ReasonBlock;
}
export interface SchedulingSummary {
qualifiedCount: number;
hopelessCount: number;
suggestionCount: number;
estimatedGain: number;
/** Count of interventions created within the last 7 days (excluding cancelled). */
recentInterventionCount: number;
}
export interface SchedulingTargetOption {
id: number;
name: string;
vehicleCount: number;
}
export interface SchedulingResponse {
summary: SchedulingSummary;
suggestions: SchedulingSuggestion[];
targets: SchedulingTargetOption[];
}
export interface NotifyRequest {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
}