xyc
4 天以前 f329f504040f3608478c687a99aa36a3cf5720b7
feat(observation): 新增观测站分日调查数据导入与明细查看
7个文件已添加
4个文件已修改
770 ■■■■■ 已修改文件
HANDOFF.md 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pack-deploy.ps1 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -37,6 +37,18 @@
- **验证**:`HolidayBulletinServiceTest` 4 ä¸ªç”¨ä¾‹é€šè¿‡ï¼ˆæ–°å¢ž `cumulativeParagraphUsesWanUnitAndSingleRounding` å›ºåŒ–「万人次 + ä¸€æ¬¡å–整」);重启后端后重新生成 2026-10-02 å¿«æŠ¥ï¼Œæ­£æ–‡ä¸º `累计3088.63万人次,比2025年同期下降4.4%,比2024年同期下降1.6%`,与手工稿仅差既定的取整口径;2026-10-01 å›žå½’正常(`累计1643.17万人次`)。
- **状态**:待用户「验收通过」后提交/推送。
### 10-03 æ™šï¼ˆ4):新增「观测站分日调查数据」原始数据导入(待验收)
- **背景(用户 2026-10-03 æå‡ºï¼‰**:普通国省道车流量的来源之一是观测站分日调查数据(模板 `docs/专项统计/重要节假日/观测站分日调查数据 (2026å¹´10月1日).xls`,其中字段「中小客流量」即普通国省道车流量来源)。**系统内不做关联**,节假日统计仍走原有导入;本功能只做原始数据留存,为后续分析挖掘积累数据基础。
- **数据表**:新增 `observation_station_flow`,DDL è§ `docs/sql_observation_station.sql`,部署包同步为 `deploy-templates/db/sql_observation_station.sql`。保留模板全部 26 åˆ—(观测站编号/名称/观测里程/地市/区县/行驶方向 + å„车型流量 + å®¢è½¦/货车/汽车/机动车流量 + å„类当量 + æœºåŠ¨è½¦é€Ÿåº¦ + æ‹¥æŒ¤åº¦ + æ¥æºæ–‡ä»¶åï¼‰ã€‚唯一键 `(obs_date, station_code, direction)`;**数值列一律 `DECIMAL(30,15)`**,Java å®žä½“字段一律 `BigDecimal`,与节假日表同口径,无 float/double。
- **导入实现**:新增包 `com.trafficaudit.observation`(entity / mapper / dto / service / controller)。用 POI `WorkbookFactory` åŒæ—¶æ”¯æŒ `.xls` å’Œ `.xlsx`;**表头按列名匹配**(列顺序变化不影响导入),扫描前 10 è¡Œå®šä½è¡¨å¤´ï¼›æ—¥æœŸå…¼å®¹ Excel æ—¥æœŸæ ¼å¼ä¸Ž `yyyy-M-d`/`yyyy/M/d`/`yyyyMMdd`/中文年月日;同一日期**重复导入即整体覆盖**(先删该日期再插入),幂等。
- **接口**:`POST /api/observation/import`、`GET /api/observation/dates`(按日期汇总站数、中小客流量合计、最近导入时间)、`GET /api/observation/rows?date=&limit=`、`POST /api/observation/delete?date=`。
- **前端**:`traffic-audit-web/src/views/HolidayStat.vue`(专项统计 - èŠ‚å‡æ—¥ï¼‰æ–°å¢žã€Œè§‚æµ‹ç«™åˆ†æ—¥è°ƒæŸ¥æ•°æ®ã€å¡ç‰‡ï¼šé€‰æ‹©æ–‡ä»¶ â†’ å¯¼å…¥ â†’ æŒ‰æ—¥æœŸæ±‡æ€»è¡¨ â†’ æŸ¥çœ‹æ˜Žç»†ï¼ˆå¼¹çª—)→ åˆ é™¤ã€‚
- **实测(真实模板,本机 3305)**:导入 692 è¡Œ / 692 ç«™ / 0 è·³è¿‡ / 0 å¤±è´¥ï¼›ä¸‰é¡¹åˆè®¡ä¸Žæºæ–‡ä»¶é€é¡¹ä¸€è‡´ï¼ˆä¸­å°å®¢æµé‡ 3,523,873、机动车流量 5,487,965、机动车当量 7,140,575);**692 è¡Œ Ã— 25 å­—段全量逐行比对 0 å¤„不一致**;中文(站名/地市/区县)落库无乱码;重复导入仍为 692 è¡Œï¼ˆè¦†ç›–而非累加);上传非 Excel è¿”回明确错误。旁证:692 ç«™ä¸­å°å®¢æµé‡åˆè®¡ 3,523,873 è¾† = 352.3873 ä¸‡è¾†ï¼Œä¸Ž 2026-10-01 èŠ‚å‡æ—¥ã€Œæ™®é€šå›½çœé“ã€å…¥åº“å€¼å®Œå…¨ä¸€è‡´ï¼Œå°è¯äº†æ•°æ®æ¥æºå…³ç³»ã€‚
- **顺带修复**:`pack-deploy.ps1` æŠŠ db è„šæœ¬å¤åˆ¶è¿›åŒ…å†… docs çš„æ¸…单里漏了 `sql_holiday_previous_day_migration.sql`,本次一并补上 `sql_observation_station.sql`;`deploy-templates/README-部署说明.txt` çš„ db æ¸…单同步更新。
- **10-03 æ·±å¤œè¿½åŠ ï¼ˆè§‚æµ‹ç«™ç•Œé¢å¾®è°ƒï¼‰**:①「查看」明细弹窗按模板表头展示原始数据全部字段(观测日期/站号/站名/里程/地市/区县/方向 + å…¨éƒ¨è½¦åž‹æµé‡ã€å®¢è´§æ±½è½¦æœºåŠ¨è½¦æµé‡ã€å„ç±»å½“é‡ã€é€Ÿåº¦ã€æ‹¥æŒ¤åº¦ï¼Œå¦é™„æ¥æºæ–‡ä»¶åï¼Œå…± 27 åˆ—,横向滚动);②汇总表「中小客流量合计」单位由辆次改为**万辆次**、保留 4 ä½å°æ•°ï¼ˆ`formatWanVehicles`,如 3,523,873 â†’ 352.3873)。前端 dev server å·²çƒ­æ›´æ–°ç¼–译通过。
- **10-03 æ·±å¤œè¿½åŠ ï¼ˆè§‚æµ‹ç«™åˆ—è¡¨/明细修正)**:①**明细行序**原来按 `stationCode` æŽ’序,与 Excel è¡Œåºå¯¹ä¸ä¸Šï¼Œæ”¹ä¸ºæŒ‰å¯¼å…¥é¡ºåºï¼ˆ`id ASC`,即 Excel è¡Œåºï¼‰ï¼Œä¾¿äºŽé€è¡Œæ¯”对;②明细**列顺序**与模板 26 åˆ—完全一致(末尾附来源文件名);③**观测里程/机动车速度/拥挤度**改为按原始小数显示(`formatPlain`,去尾零,如 50.92 / 43.882 / 0.777 / 58),不再固定 2 ä½ï¼›â‘£**17 ä¸ªæµé‡ä¸Žå½“量列**改为整数显示(`formatInteger`,如 6413),与 Excel ä¸€è‡´ï¼›â‘¤æ±‡æ€»åˆ—表「观测日期」改为**正序**(mapper `ORDER BY obs_date ASC`)。已用 10-01/10-02 çœŸå®žæ•°æ®éªŒè¯è¡Œåºä¸Ž Excel å‰ 5 è¡Œä¸€è‡´ï¼ˆä¸­å¸®/万店/沼山站/邾城站/白云)。
- **状态**:待用户「验收通过」后提交/推送。
---
### è¡¥å……(10-03 ä¸‹åˆï¼‰ï¼šæœåŠ¡å™¨éƒ¨ç½²æŠ¥ â€œAccess denied ... (using password: NO)” â€”— æ ¹å› ä¸Žä¿®å¤
@@ -501,6 +513,11 @@
- **2026-08-28 å¤æ ¸ã€Œç‚¹æ•°æ®å¯¼å…¥æµè§ˆå™¨å´©æºƒã€ï¼ˆæ‰“包测试环境,部分用户)**:重点排查 `GET /api/import/batches`——后端 limit ä¸Šé™ 200;全表 81 æ¡ã€error_detail æœ€é•¿ 2.6KB、全量响应约 17KB(前端只取 50 æ¡ï¼‰ï¼›å‰ç«¯è¡¨æ ¼ä»… 8 åˆ—短字段(errorDetail ä¸æ¸²æŸ“)、无轮询/定时器。**结论:接口不是崩溃根因**(若接口有问题,所有用户都会崩,而非"部分用户")。现象符合客户端环境差异(第三方扩展注入/旧显卡驱动/低配内存,与 08-27 è¿…雷扩展+Intel æ—§é©±åŠ¨ç»“è®ºä¸€è‡´ï¼‰ã€‚å»ºè®®å¯¹å´©æºƒç”¨æˆ·ä¾æ¬¡éªŒè¯ï¼šæ— ç—•æ¨¡å¼â†’æ¢ Edge→禁扩展,并看 chrome://crashes。
- ã€æ–°éœ€æ±‚·仅记录未实现·2026-08-28】增加**经济指标试算功能**:领导非常关注经济指标,**货运是大头**;常驻现场的所有人都要会计算货运相关数据(货运量/周转量等)。领导经常要求把某个数据「压一压」,今后考虑在软件里增加试算功能——可按需要调整/测算指标数值(如模拟下调某企业/某口径的货运量后看汇总与趋势影响),辅助现场人员快速试算与应对。
- ã€å¾…办·2026-10-03】**数值精度统一改造**:把既有业务表的数值列由 `double` æ”¹ä¸º `DECIMAL(30,15)`,Java å®žä½“字段由 `Double/Float` æ”¹ä¸º `BigDecimal`,与节假日表、观测站表统一口径(用户 2026-10-03 æ˜Žç¡®è¦æ±‚,择机实施)。
  - çŽ°çŠ¶ç›˜ç‚¹ï¼ˆtraffic_audit,2026-10-03 æŸ¥è¯¢ï¼‰ï¼š**16 å¼ è¡¨ã€å…± 228 ä¸ª double åˆ—**——`freight_turnover_import` 48、`city_bus_monthly` 34、`h2032_enterprise_monthly` 33、`city_taxi_monthly` 29、`wyc_order_monthly` 22、`h2031_enterprise_monthly` 18、`h204_vehicle_quarterly` 12、`scale_split_transport` 9、`investment_monthly` 5、`investment_system` 5、`wyc_total_monthly` 4、`h204_auth_vehicle` 2、`investment_project` 2、`passenger_individual_monthly` 2、`transport_auth_vehicle` 2、`vehicle_track_mileage` 1。
  - å·²æ˜¯ decimal å£å¾„、无需改动:`holiday_calendar` / `holiday_passenger_coefficient` / `holiday_passenger_flow` / `holiday_vehicle_flow` / `observation_station_flow`。
  - æ”¹é€ èŒƒå›´ä¸åªæ˜¯æ”¹åˆ—类型:实体类字段类型、导入解析(不要经 double ä¸­è½¬ï¼‰ã€è®¡ç®—与取整逻辑、报表导出格式化,以及存量数据的迁移脚本,都要一起改;改完要做逐表数值抽查。
## å…«ã€æ¯æ—¥äº¤æŽ¥çº¦å®š
1. æ–°å¯¹è¯ç¬¬ä¸€æ­¥ï¼šè¯» `AGENTS.md` + `HANDOFF.md`;涉及 AI åˆ†æž/审核时参考 `docs/` ä¸Žæ•°æ®åº“表结构。
2. æ¯å¤©ç»“束时:更新 `HANDOFF.md`(进展、坑、新需求、待办),再关对话。
deploy-templates/README-²¿Êð˵Ã÷.txt
@@ -40,6 +40,7 @@
    sql_holiday_decimal_migration.sql        èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_observation_station.sql               è§‚测站分日调查数据表(新功能,旧库缺表时执行)
  README-部署说明.txt
【部署机环境要求】
deploy-templates/db/sql_observation_station.sql
New file
@@ -0,0 +1,39 @@
-- Observation station daily survey data (raw retention for future analysis).
-- Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS observation_station_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  obs_date DATE NOT NULL COMMENT 'Observation date',
  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
  KEY idx_observation_station_date (obs_date),
  KEY idx_observation_station_city (city_name),
  KEY idx_observation_station_code (station_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
docs/sql_observation_station.sql
New file
@@ -0,0 +1,39 @@
-- Observation station daily survey data (raw retention for future analysis).
-- Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS observation_station_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  obs_date DATE NOT NULL COMMENT 'Observation date',
  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
  KEY idx_observation_station_date (obs_date),
  KEY idx_observation_station_city (city_name),
  KEY idx_observation_station_code (station_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
pack-deploy.ps1
@@ -188,7 +188,7 @@
cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache å·¥ä½œæ—¥ç»“ åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š é—®é¢˜æ±‡æ€» /XF ç”Ÿæˆ_*.xlsx _bak_* >nul"
if ($LASTEXITCODE -ge 8) { throw "docs å¤åˆ¶å¤±è´¥ï¼ˆrobocopy è¿”回 $LASTEXITCODE)" }
# db è„šæœ¬ä¹Ÿæ”¾ä¸€ä»½åˆ° docs(与旧包一致:docs/init.sql ç­‰ï¼‰
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql') $docsDest -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
New file
@@ -0,0 +1,65 @@
package com.trafficaudit.observation.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import com.trafficaudit.observation.service.ObservationStationImportService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
 * Observation station raw data. Collected and stored only; not linked to holiday flow data.
 */
@Slf4j
@RestController
@RequestMapping("/api/observation")
public class ObservationStationController {
    @Resource
    private ObservationStationImportService importService;
    @PostMapping("/import")
    public Result<Map<String, Object>> importWorkbook(@RequestParam("file") MultipartFile file) {
        try {
            return Result.ok(importService.importWorkbook(file));
        } catch (Exception e) {
            log.error("observation station import error", e);
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/dates")
    public Result<List<ObservationDateSummary>> dates() {
        return Result.ok(importService.listDateSummaries());
    }
    @GetMapping("/rows")
    public Result<List<ObservationStationFlow>> rows(@RequestParam(value = "date", required = false) String date,
                                                      @RequestParam(value = "limit", defaultValue = "2000") int limit) {
        try {
            LocalDate target = (date == null || date.trim().isEmpty()) ? null : LocalDate.parse(date);
            return Result.ok(importService.listRows(target, limit));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/delete")
    public Result<Integer> delete(@RequestParam("date") String date) {
        try {
            return Result.ok(importService.deleteDate(LocalDate.parse(date)));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
New file
@@ -0,0 +1,18 @@
package com.trafficaudit.observation.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Per-date rollup shown on the import page. */
@Data
public class ObservationDateSummary {
    private LocalDate obsDate;
    private Integer stationCount;
    private BigDecimal smallMediumPassengerTotal;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime importedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
New file
@@ -0,0 +1,51 @@
package com.trafficaudit.observation.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * Observation station daily survey data.
 * Stored raw for later analysis; it is deliberately NOT linked to the holiday flow tables,
 * even though ä¸­å°å®¢æµé‡ is one of the sources of the ordinary national/provincial road flow.
 */
@Data
@TableName("observation_station_flow")
public class ObservationStationFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private LocalDate obsDate;
    private String stationCode;
    private String stationName;
    private BigDecimal mileage;
    private String cityName;
    private String countyName;
    private String direction;
    private BigDecimal smallMediumPassenger;
    private BigDecimal largePassenger;
    private BigDecimal smallTruck;
    private BigDecimal mediumTruck;
    private BigDecimal largeTruck;
    private BigDecimal extraLargeTruck;
    private BigDecimal containerTruck;
    private BigDecimal motorcycle;
    private BigDecimal tractor;
    private BigDecimal passengerTotal;
    private BigDecimal truckTotal;
    private BigDecimal carTotal;
    private BigDecimal motorTotal;
    private BigDecimal passengerEquivalent;
    private BigDecimal truckEquivalent;
    private BigDecimal carEquivalent;
    private BigDecimal motorEquivalent;
    private BigDecimal vehicleSpeed;
    private BigDecimal congestionDegree;
    private String sourceFile;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
New file
@@ -0,0 +1,19 @@
package com.trafficaudit.observation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ObservationStationFlowMapper extends BaseMapper<ObservationStationFlow> {
    @Select("SELECT obs_date, COUNT(*) AS station_count, "
            + "SUM(small_medium_passenger) AS small_medium_passenger_total, "
            + "MAX(updated_at) AS imported_at "
            + "FROM observation_station_flow GROUP BY obs_date ORDER BY obs_date ASC")
    List<ObservationDateSummary> selectDateSummary();
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
New file
@@ -0,0 +1,350 @@
package com.trafficaudit.observation.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import com.trafficaudit.observation.mapper.ObservationStationFlowMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * Imports the "observation station daily survey" workbook (.xls/.xlsx).
 * Header names are matched by text, so column order may change between releases.
 * Re-importing the same date replaces that date's rows.
 */
@Slf4j
@Service
public class ObservationStationImportService {
    private static final String H_DATE = "观测日期";
    private static final String H_CODE = "观测站编号";
    private static final String H_NAME = "观测站名称";
    private static final String H_MILEAGE = "观测里程";
    private static final String H_CITY = "地市名称";
    private static final String H_COUNTY = "区县名称";
    private static final String H_DIRECTION = "行驶方向";
    private static final String H_SMALL_MEDIUM_PASSENGER = "中小客流量";
    private static final String H_LARGE_PASSENGER = "大客车流量";
    private static final String H_SMALL_TRUCK = "小货车流量";
    private static final String H_MEDIUM_TRUCK = "中货车流量";
    private static final String H_LARGE_TRUCK = "大货车流量";
    private static final String H_EXTRA_LARGE_TRUCK = "特大货流量";
    private static final String H_CONTAINER = "集装箱流量";
    private static final String H_MOTORCYCLE = "摩托车流量";
    private static final String H_TRACTOR = "拖拉机流量";
    private static final String H_PASSENGER_TOTAL = "客车流量";
    private static final String H_TRUCK_TOTAL = "货车流量";
    private static final String H_CAR_TOTAL = "汽车流量";
    private static final String H_MOTOR_TOTAL = "机动车流量";
    private static final String H_PASSENGER_EQUIVALENT = "客车当量";
    private static final String H_TRUCK_EQUIVALENT = "货车当量";
    private static final String H_CAR_EQUIVALENT = "汽车当量";
    private static final String H_MOTOR_EQUIVALENT = "机动车当量";
    private static final String H_SPEED = "机动车速度";
    private static final String H_CONGESTION = "拥挤度";
    private static final int HEADER_SCAN_ROWS = 10;
    private static final int MAX_REPORTED_ERRORS = 50;
    private static final DateTimeFormatter[] DATE_FORMATS = {
            DateTimeFormatter.ofPattern("yyyy-M-d"),
            DateTimeFormatter.ofPattern("yyyy/M/d"),
            DateTimeFormatter.ofPattern("yyyy.M.d"),
            DateTimeFormatter.ofPattern("yyyyMMdd")
    };
    @Resource
    private ObservationStationFlowMapper stationFlowMapper;
    @Transactional
    public Map<String, Object> importWorkbook(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("请选择要导入的 Excel æ–‡ä»¶");
        }
        String sourceFile = file.getOriginalFilename();
        List<ObservationStationFlow> rows = new ArrayList<>();
        Set<LocalDate> dates = new LinkedHashSet<>();
        Set<String> stationCodes = new LinkedHashSet<>();
        List<String> errors = new ArrayList<>();
        int skipped = 0;
        DataFormatter formatter = new DataFormatter();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            Sheet sheet = workbook.getNumberOfSheets() > 0 ? workbook.getSheetAt(0) : null;
            if (sheet == null) {
                throw new IllegalArgumentException("工作簿中没有可读取的工作表");
            }
            int headerRowIndex = findHeaderRow(sheet, formatter);
            if (headerRowIndex < 0) {
                throw new IllegalArgumentException("未找到表头,请确认文件包含\"观测日期\"和\"观测站编号\"两列");
            }
            Map<String, Integer> headerIndex = buildHeaderIndex(sheet.getRow(headerRowIndex), formatter);
            for (int r = headerRowIndex + 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) {
                    skipped++;
                    continue;
                }
                String code = stringCell(row, headerIndex, H_CODE, formatter);
                LocalDate obsDate = dateCell(row, headerIndex, H_DATE, formatter);
                if (isBlank(code) && obsDate == null) {
                    skipped++;
                    continue;
                }
                int rowNo = r + 1;
                if (obsDate == null) {
                    addError(errors, "第" + rowNo + "行:观测日期无法识别");
                    continue;
                }
                if (isBlank(code)) {
                    addError(errors, "第" + rowNo + "行:观测站编号为空");
                    continue;
                }
                ObservationStationFlow entity = new ObservationStationFlow();
                entity.setObsDate(obsDate);
                entity.setStationCode(code);
                entity.setStationName(stringCell(row, headerIndex, H_NAME, formatter));
                entity.setMileage(decimalCell(row, headerIndex, H_MILEAGE, formatter));
                entity.setCityName(stringCell(row, headerIndex, H_CITY, formatter));
                entity.setCountyName(stringCell(row, headerIndex, H_COUNTY, formatter));
                String direction = stringCell(row, headerIndex, H_DIRECTION, formatter);
                entity.setDirection(direction == null ? "" : direction);
                entity.setSmallMediumPassenger(decimalCell(row, headerIndex, H_SMALL_MEDIUM_PASSENGER, formatter));
                entity.setLargePassenger(decimalCell(row, headerIndex, H_LARGE_PASSENGER, formatter));
                entity.setSmallTruck(decimalCell(row, headerIndex, H_SMALL_TRUCK, formatter));
                entity.setMediumTruck(decimalCell(row, headerIndex, H_MEDIUM_TRUCK, formatter));
                entity.setLargeTruck(decimalCell(row, headerIndex, H_LARGE_TRUCK, formatter));
                entity.setExtraLargeTruck(decimalCell(row, headerIndex, H_EXTRA_LARGE_TRUCK, formatter));
                entity.setContainerTruck(decimalCell(row, headerIndex, H_CONTAINER, formatter));
                entity.setMotorcycle(decimalCell(row, headerIndex, H_MOTORCYCLE, formatter));
                entity.setTractor(decimalCell(row, headerIndex, H_TRACTOR, formatter));
                entity.setPassengerTotal(decimalCell(row, headerIndex, H_PASSENGER_TOTAL, formatter));
                entity.setTruckTotal(decimalCell(row, headerIndex, H_TRUCK_TOTAL, formatter));
                entity.setCarTotal(decimalCell(row, headerIndex, H_CAR_TOTAL, formatter));
                entity.setMotorTotal(decimalCell(row, headerIndex, H_MOTOR_TOTAL, formatter));
                entity.setPassengerEquivalent(decimalCell(row, headerIndex, H_PASSENGER_EQUIVALENT, formatter));
                entity.setTruckEquivalent(decimalCell(row, headerIndex, H_TRUCK_EQUIVALENT, formatter));
                entity.setCarEquivalent(decimalCell(row, headerIndex, H_CAR_EQUIVALENT, formatter));
                entity.setMotorEquivalent(decimalCell(row, headerIndex, H_MOTOR_EQUIVALENT, formatter));
                entity.setVehicleSpeed(decimalCell(row, headerIndex, H_SPEED, formatter));
                entity.setCongestionDegree(decimalCell(row, headerIndex, H_CONGESTION, formatter));
                entity.setSourceFile(sourceFile);
                rows.add(entity);
                dates.add(obsDate);
                stationCodes.add(code);
            }
        } catch (IllegalArgumentException e) {
            throw e;
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        if (!rows.isEmpty()) {
            stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
                    .in(ObservationStationFlow::getObsDate, dates));
            for (ObservationStationFlow row : rows) {
                stationFlowMapper.insert(row);
            }
        }
        List<String> dateTexts = new ArrayList<>();
        for (LocalDate date : dates) {
            dateTexts.add(date.toString());
        }
        BigDecimal smallMediumTotal = BigDecimal.ZERO;
        for (ObservationStationFlow row : rows) {
            if (row.getSmallMediumPassenger() != null) {
                smallMediumTotal = smallMediumTotal.add(row.getSmallMediumPassenger());
            }
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("totalRows", rows.size());
        summary.put("stationCount", stationCodes.size());
        summary.put("dates", dateTexts);
        summary.put("skippedRows", skipped);
        summary.put("smallMediumPassengerTotal", smallMediumTotal);
        summary.put("errors", errors);
        log.info("Observation station import done: file={} rows={} dates={} skipped={} failed={}",
                sourceFile, rows.size(), dateTexts, skipped, errors.size());
        return summary;
    }
    public List<ObservationDateSummary> listDateSummaries() {
        return stationFlowMapper.selectDateSummary();
    }
    public List<ObservationStationFlow> listRows(LocalDate date, int limit) {
        int effectiveLimit = limit <= 0 ? 2000 : Math.min(limit, 5000);
        return stationFlowMapper.selectList(new LambdaQueryWrapper<ObservationStationFlow>()
                .eq(date != null, ObservationStationFlow::getObsDate, date)
                .orderByAsc(ObservationStationFlow::getId)
                .last("LIMIT " + effectiveLimit));
    }
    public int deleteDate(LocalDate date) {
        if (date == null) {
            throw new IllegalArgumentException("请选择要删除的观测日期");
        }
        return stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
                .eq(ObservationStationFlow::getObsDate, date));
    }
    private void addError(List<String> errors, String message) {
        if (errors.size() < MAX_REPORTED_ERRORS) {
            errors.add(message);
        }
    }
    private int findHeaderRow(Sheet sheet, DataFormatter formatter) {
        int lastRow = Math.min(sheet.getLastRowNum(), HEADER_SCAN_ROWS);
        for (int r = 0; r <= lastRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) {
                continue;
            }
            Map<String, Integer> index = buildHeaderIndex(row, formatter);
            if (index.containsKey(normalize(H_DATE)) && index.containsKey(normalize(H_CODE))) {
                return r;
            }
        }
        return -1;
    }
    private Map<String, Integer> buildHeaderIndex(Row row, DataFormatter formatter) {
        Map<String, Integer> index = new LinkedHashMap<>();
        if (row == null) {
            return index;
        }
        for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) {
            Cell cell = row.getCell(c);
            String header = normalize(formatter.formatCellValue(cell));
            if (!header.isEmpty() && !index.containsKey(header)) {
                index.put(header, c);
            }
        }
        return index;
    }
    private String normalize(String value) {
        if (value == null) {
            return "";
        }
        StringBuilder builder = new StringBuilder(value.length());
        for (int i = 0; i < value.length(); i++) {
            char ch = value.charAt(i);
            if (ch == 0xFEFF || Character.isWhitespace(ch)) {
                continue;
            }
            builder.append(ch);
        }
        return builder.toString();
    }
    private String stringCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        Cell cell = row.getCell(column);
        if (cell == null) {
            return null;
        }
        String value = formatter.formatCellValue(cell).trim();
        return value.isEmpty() ? null : value;
    }
    private LocalDate dateCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        return readDate(row.getCell(column), formatter);
    }
    private BigDecimal decimalCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        return readDecimal(row.getCell(column), formatter);
    }
    private LocalDate readDate(Cell cell, DataFormatter formatter) {
        if (cell == null) {
            return null;
        }
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = cell.getCachedFormulaResultType();
        }
        if (type == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
            return cell.getLocalDateTimeCellValue().toLocalDate();
        }
        String text = formatter.formatCellValue(cell).trim();
        if (text.isEmpty()) {
            return null;
        }
        text = text.replace("å¹´", "-").replace("月", "-").replace("日", "").trim();
        for (DateTimeFormatter format : DATE_FORMATS) {
            try {
                return LocalDate.parse(text, format);
            } catch (Exception ignored) {
                // Try the next supported pattern.
            }
        }
        return null;
    }
    private BigDecimal readDecimal(Cell cell, DataFormatter formatter) {
        if (cell == null) {
            return null;
        }
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = cell.getCachedFormulaResultType();
        }
        if (type == CellType.NUMERIC) {
            return BigDecimal.valueOf(cell.getNumericCellValue());
        }
        if (type == CellType.BLANK) {
            return null;
        }
        String text = formatter.formatCellValue(cell).trim().replace(",", "").replace(" ", "");
        if (text.isEmpty() || "-".equals(text) || "—".equals(text) || "/".equals(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException e) {
            return null;
        }
    }
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}
traffic-audit-web/src/views/HolidayStat.vue
@@ -77,6 +77,74 @@
              <el-table-column label="总人员流动量(万人次)" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
            </el-table>
          </el-card>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">
              <span>观测站分日调查数据</span>
              <span class="hint">用于原始数据留存,不与上方节假日统计数据关联</span>
            </div>
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">导入数据</span>
                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
                           accept=".xls,.xlsx" :on-change="onObservationFileChange" :on-remove="onObservationFileRemove"
                           :file-list="observationFileList">
                  <el-button type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                </el-upload>
                <el-button type="primary" icon="el-icon-upload2" :loading="observationImporting"
                           :disabled="!observationFile" @click="importObservation">导入</el-button>
              </div>
            </div>
            <div v-if="observationMessage" class="import-message">
              <pre>{{ observationMessage }}</pre>
            </div>
            <el-table :data="observationDates" border stripe size="small" style="margin-top:12px" v-loading="observationLoading">
              <el-table-column prop="obsDate" label="观测日期" width="130" align="center"></el-table-column>
              <el-table-column prop="stationCount" label="观测站数" width="110" align="right"></el-table-column>
              <el-table-column label="中小客流量合计(万辆次)" width="210" align="right">
                <template slot-scope="s">{{ formatWanVehicles(s.row.smallMediumPassengerTotal) }}</template>
              </el-table-column>
              <el-table-column prop="importedAt" label="最近导入时间" min-width="180"></el-table-column>
              <el-table-column label="操作" width="150" align="center">
                <template slot-scope="s">
                  <el-button type="text" @click="viewObservation(s.row.obsDate)">查看</el-button>
                  <el-button type="text" class="danger-text" @click="deleteObservation(s.row.obsDate)">删除</el-button>
                </template>
              </el-table-column>
            </el-table>
          </el-card>
          <el-dialog :title="'观测站明细 - ' + observationDialogDate" :visible.sync="observationDialogVisible" width="92%" top="5vh">
            <el-table :data="observationRows" border stripe size="mini" height="540" v-loading="observationRowsLoading">
              <el-table-column prop="obsDate" label="观测日期" width="110" fixed="left" align="center"></el-table-column>
              <el-table-column prop="stationCode" label="观测站编号" width="130" fixed="left"></el-table-column>
              <el-table-column prop="stationName" label="观测站名称" width="120"></el-table-column>
              <el-table-column label="观测里程" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.mileage) }}</template></el-table-column>
              <el-table-column prop="cityName" label="地市名称" width="140"></el-table-column>
              <el-table-column prop="countyName" label="区县名称" width="110"></el-table-column>
              <el-table-column prop="direction" label="行驶方向" width="90"></el-table-column>
              <el-table-column label="中小客流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallMediumPassenger) }}</template></el-table-column>
              <el-table-column label="大客车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largePassenger) }}</template></el-table-column>
              <el-table-column label="小货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallTruck) }}</template></el-table-column>
              <el-table-column label="中货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.mediumTruck) }}</template></el-table-column>
              <el-table-column label="大货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largeTruck) }}</template></el-table-column>
              <el-table-column label="特大货流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.extraLargeTruck) }}</template></el-table-column>
              <el-table-column label="集装箱流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.containerTruck) }}</template></el-table-column>
              <el-table-column label="摩托车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorcycle) }}</template></el-table-column>
              <el-table-column label="拖拉机流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.tractor) }}</template></el-table-column>
              <el-table-column label="客车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerTotal) }}</template></el-table-column>
              <el-table-column label="货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckTotal) }}</template></el-table-column>
              <el-table-column label="汽车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carTotal) }}</template></el-table-column>
              <el-table-column label="机动车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorTotal) }}</template></el-table-column>
              <el-table-column label="客车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerEquivalent) }}</template></el-table-column>
              <el-table-column label="货车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckEquivalent) }}</template></el-table-column>
              <el-table-column label="汽车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carEquivalent) }}</template></el-table-column>
              <el-table-column label="机动车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorEquivalent) }}</template></el-table-column>
              <el-table-column label="机动车速度" width="110" align="right"><template slot-scope="s">{{ formatPlain(s.row.vehicleSpeed) }}</template></el-table-column>
              <el-table-column label="拥挤度" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.congestionDegree) }}</template></el-table-column>
              <el-table-column prop="sourceFile" label="来源文件名" min-width="220" show-overflow-tooltip></el-table-column>
            </el-table>
          </el-dialog>
        </div>
      </el-main>
    </el-container>
@@ -105,7 +173,17 @@
      fileList: [],
      importMessage: '',
      allowHistoricalOverwrite: false,
      bulletinDate: this.previousDateString()
      bulletinDate: this.previousDateString(),
      observationFile: null,
      observationFileList: [],
      observationImporting: false,
      observationLoading: false,
      observationMessage: '',
      observationDates: [],
      observationDialogVisible: false,
      observationDialogDate: '',
      observationRows: [],
      observationRowsLoading: false
    }
  },
  computed: {
@@ -117,6 +195,7 @@
  mounted() {
    this.loadOptions()
    this.loadOverview()
    this.loadObservationDates()
  },
  methods: {
    previousDateString() {
@@ -271,9 +350,95 @@
      const data = err && err.response && err.response.data
      this.$message.error((data && data.message) || (err && err.message) || fallback)
    },
    onObservationFileChange(file) {
      this.observationFile = file.raw
      this.observationFileList = [file]
      this.observationMessage = ''
    },
    onObservationFileRemove() {
      this.observationFile = null
      this.observationFileList = []
    },
    loadObservationDates() {
      this.observationLoading = true
      api.get('/observation/dates')
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.observationDates = res.data || []
        })
        .catch(err => this.showError(err, '观测站数据加载失败'))
        .finally(() => { this.observationLoading = false })
    },
    importObservation() {
      if (!this.observationFile) return
      const form = new FormData()
      form.append('file', this.observationFile)
      this.observationImporting = true
      this.observationMessage = ''
      api.post('/observation/import', form, { timeout: 300000, headers: { 'Content-Type': 'multipart/form-data' } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '导入失败')
          const data = res.data || {}
          const dates = (data.dates && data.dates.length) ? data.dates.join('、') : '—'
          const lines = ['导入完成:' + (data.totalRows || 0) + ' æ¡ï¼Œè§‚测站 ' + (data.stationCount || 0)
            + ' ä¸ªï¼Œæ—¥æœŸ ' + dates + ',跳过空白 ' + (data.skippedRows || 0) + ' æ¡ã€‚']
          if (data.errors && data.errors.length) {
            lines.push('失败明细:')
            data.errors.forEach(item => lines.push(item))
          }
          this.observationMessage = lines.join('\n')
          this.$message.success('观测站数据导入完成')
          this.observationFile = null
          this.observationFileList = []
          this.loadObservationDates()
        })
        .catch(err => this.showError(err, '观测站数据导入失败'))
        .finally(() => { this.observationImporting = false })
    },
    viewObservation(date) {
      this.observationDialogDate = date
      this.observationDialogVisible = true
      this.observationRowsLoading = true
      this.observationRows = []
      api.get('/observation/rows', { params: { date: date, limit: 2000 } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.observationRows = res.data || []
        })
        .catch(err => this.showError(err, '观测站明细加载失败'))
        .finally(() => { this.observationRowsLoading = false })
    },
    deleteObservation(date) {
      this.$confirm('确定删除 ' + date + ' çš„观测站数据吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/observation/delete', null, { params: { date: date } }))
        .then(res => {
          if (res && res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadObservationDates()
        })
        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '删除失败') })
    },
    formatNumber(value) {
      if (value === null || value === undefined || value === '') return '—'
      return Number(value).toFixed(2)
    },
    formatWanVehicles(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return (number / 10000).toFixed(4)
    },
    formatInteger(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return String(Math.round(number))
    },
    formatPlain(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return String(number)
    },
    formatCoefficient(value) {
      if (value === null || value === undefined || value === '') return '—'
@@ -299,4 +464,6 @@
.operation-item .label { color: #606266; font-size: 14px; }
.import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
.import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
.hint { margin-left: 10px; color: #909399; font-size: 12px; }
.danger-text { color: #f56c6c; }
</style>