2、调整了板卡管理的页面,以及校准文件

This commit is contained in:
徐星 2026-04-16 17:34:50 +08:00
parent 168772d4a5
commit 95259a701f
24 changed files with 2108 additions and 786 deletions

View File

@ -0,0 +1,885 @@
# 设计文档:前后端分离 — Java API 后端与前端集成
## 概述
本设计文档描述将现有 Next.js 16 前端生产管理子系统从硬编码模拟数据迁移到真实 RESTful API 的完整技术方案。涵盖 Java Spring Boot 3.3.6 后端多模块项目架构、PostgreSQL 数据库设计、DDD 分层实现,以及前端 API 集成层的构建。
系统为地球物理仪器(高密度电法仪等)的全生命周期管理平台,包含设备管理、板卡管理、固件库、校准管理、校准文件管理、授权管理、配置文件管理、维修工单、报废回收等核心业务模块。
### 设计目标
- 搭建符合 DDD 分层架构的 Spring Boot 多模块 Maven 项目
- 设计 PostgreSQL `dev` schema 下的完整数据库表结构
- 为所有前端页面提供对应的 RESTful API 端点
- 在前端建立统一的 API 调用层,替换所有硬编码模拟数据
- 前后端采用统一的分页、筛选、错误响应规范
## 架构
### 整体架构
```mermaid
graph TB
subgraph Frontend["前端 Next.js 16"]
Pages["页面组件<br/>devices / boards / firmware / ..."]
ApiLayer["API 集成层<br/>src/lib/api/"]
Pages --> ApiLayer
end
subgraph Backend["后端 Spring Boot 3.3.6"]
subgraph ApiAdmin["api-admin 模块"]
Controllers["REST Controllers<br/>/api/admin/*"]
end
subgraph ApiPortal["api-portal 模块"]
PortalControllers["REST Controllers<br/>/api/portal/*<br/>(无需鉴权)"]
end
subgraph Business["business 模块"]
subgraph Device["device 子模块"]
Interfaces["interfaces 层<br/>Controller / VO / Assembler"]
Application["application 层<br/>AppService / Command / Query"]
Domain["domain 层<br/>Entity / Repository接口"]
Infrastructure["infrastructure 层<br/>Mapper / DO / Repository实现"]
end
end
subgraph Common["common 模块"]
Response["统一响应体 R<T>"]
Exception["全局异常处理"]
Audit["审计字段自动填充"]
end
end
subgraph DB["PostgreSQL 12.14"]
DevSchema["dev schema<br/>devices / device_models / board_types / board_cards / calibration_files / ..."]
end
ApiLayer -->|HTTP REST| Controllers
ApiLayer -->|HTTP REST| PortalControllers
Controllers --> Interfaces
PortalControllers --> Interfaces
Interfaces --> Application
Application --> Domain
Domain --> Infrastructure
Infrastructure -->|MyBatis-Plus| DevSchema
```
### 后端项目结构
```
apps/geo-bps-api/
├── pom.xml # 父 POM
├── api-admin/
│ ├── pom.xml
│ └── src/main/java/com/geomative/bps/admin/
│ ├── ApiAdminApplication.java
│ └── config/
│ ├── CorsConfig.java # CORS 配置
│ └── WebMvcConfig.java
├── api-portal/
│ ├── pom.xml
│ └── src/main/java/com/geomative/bps/portal/
│ ├── ApiPortalApplication.java
│ └── config/
│ ├── CorsConfig.java # CORS 配置
│ └── WebMvcConfig.java # 文件上传大小限制等
├── business/
│ ├── pom.xml # pom packaging
│ └── device/
│ ├── pom.xml
│ └── src/main/java/com/geomative/bps/device/
│ ├── interfaces/ # Controller + VO
│ ├── application/ # AppService + Command + Query
│ ├── domain/ # Entity + Repository接口
│ └── infrastructure/ # Mapper + DO + Repository实现
└── common/
├── pom.xml
└── src/main/java/com/geomative/bps/common/
├── response/
│ └── R.java # 统一响应体
├── exception/
│ ├── BizException.java
│ └── GlobalExceptionHandler.java
├── mybatis/
│ └── AuditMetaObjectHandler.java
└── page/
└── PageResult.java # 分页响应
```
### 前端 API 集成层结构
```
src/
├── lib/
│ └── api/
│ ├── client.ts # Axios 实例,统一配置
│ ├── types.ts # 通用响应/分页类型
│ ├── deviceApi.ts # 设备管理 API
│ ├── modelApi.ts # 设备型号 API
│ ├── boardApi.ts # 板卡管理 API
│ ├── firmwareApi.ts # 固件库 API
│ ├── calibrationApi.ts # 校准管理 API
│ ├── calibrationFileApi.ts # 校准文件 API
│ ├── configFileApi.ts # 配置文件 API
│ ├── licenseApi.ts # 授权管理 API
│ ├── repairApi.ts # 维修工单 API
│ ├── scrapApi.ts # 报废管理 API
│ └── dashboardApi.ts # 首页统计 API
└── hooks/
└── useApi.ts # 通用数据获取 Hook
```
## 组件与接口
### 统一响应体
```java
public class R<T> {
private int code; // 业务状态码0=成功
private String message; // 提示信息
private T data; // 响应数据
public static <T> R<T> ok(T data) { ... }
public static <T> R<T> fail(int code, String message) { ... }
}
```
### 分页响应体
```java
public class PageResult<T> {
private long total; // 总记录数
private int page; // 当前页码
private int pageSize; // 每页条数
private List<T> records; // 数据列表
}
```
### 统一分页请求参数
所有列表接口统一使用:
- `page`:页码,从 1 开始,默认 1
- `pageSize`:每页条数,默认 10
### API 端点清单
| 模块 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 设备 | GET | `/api/admin/devices` | 分页设备列表(筛选:型号/状态/日期/SN/批次) |
| 设备 | GET | `/api/admin/devices/{sn}` | 设备详情(含授权、子设备、固件) |
| 设备 | POST | `/api/admin/devices` | 创建设备(设备登记) |
| 设备 | GET | `/api/admin/devices/batches` | 生产批次列表 |
| 型号 | GET | `/api/admin/device-models` | 设备型号列表 |
| 型号 | POST | `/api/admin/device-models` | 创建设备型号 |
| 型号 | GET | `/api/admin/checklist-templates` | Checklist 模板列表 |
| 型号 | POST | `/api/admin/checklist-templates` | 创建 Checklist 模板 |
| 板卡 | GET | `/api/admin/board-types` | 板卡型号列表(筛选:类型) |
| 板卡 | GET | `/api/admin/board-types/{id}` | 板卡详情 |
| 板卡 | POST | `/api/admin/board-types` | 创建板卡型号 |
| 固件 | GET | `/api/admin/firmware` | 固件版本列表(筛选:类型/板卡型号) |
| 固件 | POST | `/api/admin/firmware` | 创建固件版本 |
| 固件 | GET | `/api/admin/firmware/{id}/download` | 下载固件文件 |
| 校准 | GET | `/api/admin/calibrations` | 校准记录列表筛选SN/状态/人员) |
| 校准 | GET | `/api/admin/calibrations/{id}` | 校准详情 |
| 校准 | POST | `/api/admin/calibrations/import` | 批量导入校准记录 |
| 校准文件 | POST | `/api/portal/calibration-files/upload` | 上传校准文件(无需鉴权,上位机调用) |
| 校准文件 | GET | `/api/admin/board-cards/{id}/calibration-files` | 指定采集板的校准文件列表(按上传时间倒序) |
| 校准文件 | GET | `/api/admin/calibration-files/{id}/download` | 下载校准文件 |
| 配置 | GET | `/api/admin/config-files` | 配置文件列表(筛选:型号/版本/关键字) |
| 配置 | GET | `/api/admin/config-files/{id}` | 配置文件详情 |
| 配置 | POST | `/api/admin/config-files` | 创建配置文件 |
| 配置 | PUT | `/api/admin/config-files/{id}` | 更新配置文件 |
| 配置 | DELETE | `/api/admin/config-files/{id}` | 逻辑删除配置文件 |
| 授权 | GET | `/api/admin/licenses` | 授权列表(筛选:型号/状态) |
| 授权 | POST | `/api/admin/licenses` | 创建授权记录 |
| 授权 | PUT | `/api/admin/licenses/{id}` | 更新授权记录 |
| 授权 | PUT | `/api/admin/licenses/{id}/disable` | 停用授权 |
| 维修 | GET | `/api/admin/repair-orders` | 工单列表(筛选:状态/优先级/人员/日期/SN |
| 维修 | GET | `/api/admin/repair-orders/{id}` | 工单详情 |
| 维修 | POST | `/api/admin/repair-orders` | 创建工单 |
| 维修 | PUT | `/api/admin/repair-orders/{id}/process` | 处理工单 |
| 维修 | PUT | `/api/admin/repair-orders/{id}/close` | 关闭工单 |
| 报废 | GET | `/api/admin/scrap-records` | 报废记录列表筛选SN/状态/日期) |
| 报废 | GET | `/api/admin/scrap-records/{id}` | 报废详情 |
| 报废 | PUT | `/api/admin/scrap-records/{id}/approve` | 审批通过 |
| 报废 | PUT | `/api/admin/scrap-records/{id}/reject` | 驳回 |
| 报废 | PUT | `/api/admin/scrap-records/{id}/recover` | 物料回收入库 |
| 报废 | GET | `/api/admin/scrap-records/stats` | 报废统计 |
| 首页 | GET | `/api/admin/dashboard/metrics` | 统计指标 |
| 首页 | GET | `/api/admin/dashboard/device-status` | 设备状态分布 |
| 首页 | GET | `/api/admin/dashboard/tasks` | 待处理任务 |
### 前端 API 客户端
```typescript
// src/lib/api/client.ts
import axios from 'axios';
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
timeout: 5000,
headers: { 'Content-Type': 'application/json' },
});
// 响应拦截器:统一错误处理
apiClient.interceptors.response.use(
(res) => res.data,
(error) => {
const message = error.response?.data?.message || '请求失败,请稍后重试';
return Promise.reject(new Error(message));
}
);
export default apiClient;
```
### 前端通用类型
```typescript
// src/lib/api/types.ts
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export interface PageResult<T> {
total: number;
page: number;
pageSize: number;
records: T[];
}
export interface PageParams {
page?: number;
pageSize?: number;
}
```
## 数据模型
### ER 关系图
```mermaid
erDiagram
device_models ||--o{ devices : "型号关联"
device_models ||--o{ checklist_templates : "模板关联"
device_models ||--o{ config_files : "配置关联"
device_models ||--o{ licenses : "授权关联"
checklist_templates ||--o{ checklist_items : "包含检查项"
devices ||--o{ device_boards : "装配板卡"
devices ||--o{ repair_orders : "维修工单"
devices ||--o{ scrap_records : "报废记录"
board_types ||--o{ device_boards : "板卡实例"
board_types ||--o{ firmware_versions : "固件版本"
board_types ||--o{ calibration_records : "校准记录"
board_cards ||--o{ calibration_files : "校准文件"
device_models {
varchar(64) id PK
varchar(100) name
varchar(50) code UK
varchar(20) status
varchar(500) description
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
devices {
varchar(64) id PK
varchar(100) sn UK
varchar(64) model_id FK
varchar(100) model_name
varchar(20) status
varchar(20) firmware_version
timestamp production_date
varchar(200) customer
varchar(20) batch
varchar(64) operator
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
board_types {
varchar(64) id PK
varchar(50) type
varchar(50) version UK
varchar(20) latest_firmware
date production_date
varchar(20) status
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
device_boards {
varchar(64) id PK
varchar(64) device_id FK
varchar(100) device_sn
varchar(64) board_type_id FK
varchar(100) board_sn
varchar(50) board_name
varchar(50) board_model
varchar(20) calibration_status
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
firmware_versions {
varchar(64) id PK
varchar(20) version
varchar(64) board_type_id FK
varchar(50) board_version
varchar(50) firmware_type
date release_date
varchar(20) status
varchar(20) file_size
int download_count
varchar(200) hw_range
varchar(20) upgrade_type
boolean signed
varchar(64) md5
varchar(128) sha256
text release_notes
varchar(500) file_path
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
calibration_records {
varchar(64) id PK
varchar(100) board_sn
varchar(64) board_type_id FK
varchar(50) board_version
date calibration_date
date expiry_date
varchar(100) calibrator
varchar(20) status
int channel_count
decimal overall_deviation
jsonb channel_results
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
config_files {
varchar(64) id PK
varchar(100) name
varchar(64) model_id FK
varchar(100) model_name
varchar(20) version
varchar(20) status
jsonb transmission_params
jsonb acquisition_params
jsonb protection_params
jsonb network_params
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
licenses {
varchar(64) id PK
varchar(64) model_id FK
varchar(50) model_code
jsonb modules
varchar(20) validity_type
date expiry_date
varchar(20) status
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
repair_orders {
varchar(64) id PK
varchar(50) order_no UK
varchar(100) device_sn
varchar(50) fault_type
varchar(20) status
varchar(10) priority
varchar(100) assignee
varchar(500) description
varchar(500) phenomenon
date expected_fix_date
text note
jsonb process_records
jsonb board_replacements
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
scrap_records {
varchar(64) id PK
varchar(100) device_sn
varchar(100) model_name
varchar(500) reason
varchar(100) applicant
varchar(20) status
varchar(50) order_id
decimal residual_value
jsonb materials
jsonb approval_timeline
text approval_note
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
checklist_templates {
varchar(64) id PK
varchar(64) model_id FK
varchar(50) model_code
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
checklist_items {
varchar(64) id PK
varchar(64) template_id FK
varchar(200) name
boolean required
int sort_order
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
board_cards {
varchar(64) id PK
varchar(100) sn UK
varchar(50) type
varchar(50) version
varchar(20) firmware_version
varchar(20) status
varchar(100) device_sn
date production_date
varchar(20) calib_status
date calib_date
varchar(500) remark
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
calibration_files {
varchar(64) id PK
varchar(100) board_sn
varchar(200) original_name
varchar(500) file_path
bigint file_size
varchar(64) md5
timestamp upload_time
timestamp created_at
varchar(64) created_by
timestamp updated_at
varchar(64) updated_by
smallint deleted
}
```
### 表结构详细说明
#### 公共审计字段(所有表必须包含)
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | VARCHAR(64) | PK | 应用层生成(雪花算法/UUID |
| created_at | TIMESTAMP | NOT NULL | 创建时间INSERT 自动填充 |
| created_by | VARCHAR(64) | NULL | 创建人 |
| updated_at | TIMESTAMP | NOT NULL | 修改时间INSERT/UPDATE 自动填充 |
| updated_by | VARCHAR(64) | NULL | 修改人 |
| deleted | SMALLINT | NOT NULL DEFAULT 0 | 逻辑删除标记 |
#### dev.devices — 设备表
| 字段 | 类型 | 说明 |
|------|------|------|
| sn | VARCHAR(100) | 设备SN号唯一索引 |
| model_id | VARCHAR(64) | 关联设备型号 |
| model_name | VARCHAR(100) | 型号名称(冗余) |
| status | VARCHAR(20) | 装配中/已出厂/已激活/报废 |
| firmware_version | VARCHAR(20) | 当前固件版本 |
| production_date | TIMESTAMP | 生产日期 |
| customer | VARCHAR(200) | 客户名称 |
| batch | VARCHAR(20) | 生产批次YYYY-WXX |
| operator | VARCHAR(64) | 登记人 |
#### dev.device_models — 设备型号表
| 字段 | 类型 | 说明 |
|------|------|------|
| name | VARCHAR(100) | 型号名称(如 GD-30 Supreme |
| code | VARCHAR(50) | 型号代码(如 GD30唯一索引 |
| status | VARCHAR(20) | 在产/停产 |
| description | VARCHAR(500) | 描述 |
#### dev.board_types — 板卡型号表
| 字段 | 类型 | 说明 |
|------|------|------|
| type | VARCHAR(50) | 板卡类型(主协板/采集板/发射板/升压板) |
| version | VARCHAR(50) | 版本号(如 MB-V1.8),唯一索引 |
| latest_firmware | VARCHAR(20) | 最新固件版本 |
| production_date | DATE | 生产日期 |
| status | VARCHAR(20) | 在产/停产 |
#### dev.device_boards — 设备板卡关联表
| 字段 | 类型 | 说明 |
|------|------|------|
| device_id | VARCHAR(64) | 关联设备 |
| device_sn | VARCHAR(100) | 设备SN冗余 |
| board_type_id | VARCHAR(64) | 关联板卡型号 |
| board_sn | VARCHAR(100) | 板卡SN号 |
| board_name | VARCHAR(50) | 板卡名称(主协板/采集板等) |
| board_model | VARCHAR(50) | 板卡型号 |
| calibration_status | VARCHAR(20) | 校准状态 |
#### dev.firmware_versions — 固件版本表
| 字段 | 类型 | 说明 |
|------|------|------|
| version | VARCHAR(20) | 版本号 |
| board_type_id | VARCHAR(64) | 关联板卡型号 |
| board_version | VARCHAR(50) | 板卡版本(冗余) |
| firmware_type | VARCHAR(50) | 固件类型(主协板/采集板/发射板/升压板/主机固件/计算单元固件) |
| release_date | DATE | 发布日期 |
| status | VARCHAR(20) | 已发布/草稿 |
| file_size | VARCHAR(20) | 文件大小 |
| download_count | INT | 下载次数,默认 0 |
| hw_range | VARCHAR(200) | 硬件版本范围 |
| upgrade_type | VARCHAR(20) | 可选/强制 |
| signed | BOOLEAN | 是否数字签名 |
| md5 | VARCHAR(64) | MD5 校验值 |
| sha256 | VARCHAR(128) | SHA256 校验值 |
| release_notes | TEXT | 发布说明JSON 数组) |
| file_path | VARCHAR(500) | 文件存储路径 |
#### dev.calibration_records — 校准记录表
| 字段 | 类型 | 说明 |
|------|------|------|
| board_sn | VARCHAR(100) | 采集板SN号 |
| board_type_id | VARCHAR(64) | 关联板卡型号 |
| board_version | VARCHAR(50) | 板卡版本(冗余) |
| calibration_date | DATE | 校准日期 |
| expiry_date | DATE | 到期日期 |
| calibrator | VARCHAR(100) | 校准人员 |
| status | VARCHAR(20) | 合格/不合格/待校准 |
| channel_count | INT | 通道数 |
| overall_deviation | DECIMAL(10,4) | 综合偏差 |
| channel_results | JSONB | 各通道校准结果 |
#### dev.config_files — 配置文件表
| 字段 | 类型 | 说明 |
|------|------|------|
| name | VARCHAR(100) | 配置文件名(如 CFG-GD30-v1.3.0 |
| model_id | VARCHAR(64) | 关联设备型号 |
| model_name | VARCHAR(100) | 型号名称(冗余) |
| version | VARCHAR(20) | 配置版本 |
| status | VARCHAR(20) | 生效/已停用 |
| transmission_params | JSONB | 发射参数 |
| acquisition_params | JSONB | 采集参数 |
| protection_params | JSONB | 保护参数 |
| network_params | JSONB | 网络参数 |
#### dev.licenses — 授权表
| 字段 | 类型 | 说明 |
|------|------|------|
| model_id | VARCHAR(64) | 关联设备型号 |
| model_code | VARCHAR(50) | 型号代码(冗余) |
| modules | JSONB | 授权模块列表 |
| validity_type | VARCHAR(20) | 永久/1年/2年/自定义 |
| expiry_date | DATE | 到期日期 |
| status | VARCHAR(20) | 生效/草稿/已停用 |
#### dev.repair_orders — 维修工单表
| 字段 | 类型 | 说明 |
|------|------|------|
| order_no | VARCHAR(50) | 工单号(如 WO-2024-0001唯一索引 |
| device_sn | VARCHAR(100) | 设备SN号 |
| fault_type | VARCHAR(50) | 故障类型 |
| status | VARCHAR(20) | 待处理/处理中/已处理 |
| priority | VARCHAR(10) | 高/中/低 |
| assignee | VARCHAR(100) | 负责人 |
| description | VARCHAR(500) | 故障描述 |
| phenomenon | VARCHAR(500) | 故障现象 |
| expected_fix_date | DATE | 预计修复日期 |
| note | TEXT | 备注 |
| process_records | JSONB | 处理记录时间线 |
| board_replacements | JSONB | 板卡更换记录 |
#### dev.scrap_records — 报废记录表
| 字段 | 类型 | 说明 |
|------|------|------|
| device_sn | VARCHAR(100) | 设备SN号 |
| model_name | VARCHAR(100) | 设备型号 |
| reason | VARCHAR(500) | 报废原因 |
| applicant | VARCHAR(100) | 申请人 |
| status | VARCHAR(20) | 待审批/审批中/已审批/已驳回/回收中/已回收 |
| order_id | VARCHAR(50) | 来源工单号 |
| residual_value | DECIMAL(12,2) | 残值评估 |
| materials | JSONB | 可回收物料列表 |
| approval_timeline | JSONB | 审批记录时间线 |
| approval_note | TEXT | 审批意见 |
#### dev.checklist_templates — Checklist 模板表
| 字段 | 类型 | 说明 |
|------|------|------|
| model_id | VARCHAR(64) | 关联设备型号 |
| model_code | VARCHAR(50) | 型号代码(冗余) |
#### dev.checklist_items — Checklist 检查项表
| 字段 | 类型 | 说明 |
|------|------|------|
| template_id | VARCHAR(64) | 关联模板 |
| name | VARCHAR(200) | 检查项名称 |
| required | BOOLEAN | 是否必填 |
| sort_order | INT | 排序序号 |
#### dev.calibration_files — 校准文件表
| 字段 | 类型 | 说明 |
|------|------|------|
| board_sn | VARCHAR(100) | 采集板SN号关联 board_cards.sn |
| original_name | VARCHAR(200) | 原始文件名 |
| file_path | VARCHAR(500) | 服务器存储路径 |
| file_size | BIGINT | 文件大小(字节) |
| md5 | VARCHAR(64) | 文件MD5校验值 |
| upload_time | TIMESTAMP | 上传时间 |
## 正确性属性
*正确性属性是在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
### Property 1: 统一响应格式一致性
*For any* API 请求(无论成功或失败),响应 JSON 都应包含且仅包含 `code`(整数)、`message`(字符串)、`data`(泛型)三个顶层字段。成功时 code=0业务异常和参数校验异常均应返回 code≠0 的统一格式响应。
**Validates: Requirements 1.3, 1.4**
### Property 2: 审计字段自动填充
*For any* 通过 MyBatis-Plus 插入的实体记录,`created_at` 和 `updated_at` 字段应非空且为合理的时间戳;*For any* 更新操作,`updated_at` 应大于等于更新前的值。*For any* `dev` schema 下的业务表,都应包含 `id`、`created_at`、`created_by`、`updated_at`、`updated_by`、`deleted` 六个审计字段。
**Validates: Requirements 1.5, 2.12**
### Property 3: CRUD 数据往返一致性
*For any* 有效的创建命令设备、型号、板卡、固件、配置文件、授权、工单、Checklist 模板),通过 POST 创建后再通过 GET 查询,返回的数据应与创建时提交的数据一致(在服务端生成的字段如 id、审计字段除外
**Validates: Requirements 3.2, 3.3, 4.2, 4.4, 5.3, 6.2, 8.3, 8.4, 9.2, 9.3, 10.3**
### Property 4: 唯一性约束与冲突检测
*For any* 已存在的唯一标识设备SN号、型号代码、同一板卡型号下的固件版本号尝试创建具有相同唯一标识的记录时API 应返回 HTTP 409 冲突错误码和描述性错误消息,且数据库中不应产生重复记录。
**Validates: Requirements 3.5, 4.5, 6.4**
### Property 5: 筛选条件正确性
*For any* 列表查询接口和任意有效的筛选参数组合,返回的所有记录都应满足指定的筛选条件。例如:按型号筛选时,所有返回的设备型号应与筛选值匹配;按状态筛选时,所有返回记录的状态应与筛选值匹配。
**Validates: Requirements 3.1, 5.1, 6.1, 7.1, 8.1, 9.1, 10.1, 11.1**
### Property 6: 状态转换正确性
*For any* 支持状态转换的实体(授权停用、工单关闭、报废审批/驳回/回收),执行状态转换操作后,实体的状态应更新为目标状态,且相关附加信息(如驳回意见、回收物料清单)应被正确记录。
**Validates: Requirements 9.4, 10.5, 11.3, 11.4, 11.5**
### Property 7: 逻辑删除排除性
*For any* 被逻辑删除的记录deleted=1该记录不应出现在任何列表查询的结果中。
**Validates: Requirements 8.5**
### Property 8: 分页一致性
*For any* 列表查询接口,响应应包含 `total`、`page`、`pageSize`、`records` 四个字段;`records` 的长度应不超过 `pageSize`;当不传筛选条件或筛选条件为空时,应返回不带过滤的完整数据集(受分页限制)。
**Validates: Requirements 14.1, 14.2, 14.4**
### Property 9: 聚合统计一致性
*For any* 统计接口返回的数据,各分类计数之和应等于总数。例如:设备状态分布中各状态数量之和应等于设备总数;报废统计中各状态数量之和应等于报废总数。
**Validates: Requirements 11.6, 12.2**
### Property 10: 参数校验防御性
*For any* 无效的筛选参数如非法的状态值、格式错误的日期API 应返回 HTTP 400 错误码和描述性错误消息,而非返回错误数据或抛出未处理异常。
**Validates: Requirements 14.3**
### Property 11: 校准文件上传-下载往返一致性
*For any* 有效的校准文件和已存在的采集板SN号通过 POST `/api/portal/calibration-files/upload` 上传后,再通过 GET `/api/admin/calibration-files/{id}/download` 下载,下载的文件内容应与上传的原始文件完全一致,且 `dev.calibration_files` 表中的记录应正确关联该采集板SN号文件大小和MD5校验值应与原始文件匹配。
**Validates: Requirements 17.2, 17.3, 17.7**
### Property 12: 校准文件列表完整性与排序
*For any* 采集板,上传 N 个校准文件后,通过 GET `/api/admin/board-cards/{id}/calibration-files` 查询应返回恰好 N 条记录,每条记录包含 id、fileName、fileSize、md5、uploadTime 字段,且结果按 uploadTime 倒序排列。
**Validates: Requirements 17.6, 17.10**
## 错误处理
### 后端错误处理策略
| 错误类型 | HTTP 状态码 | 业务码 | 处理方式 |
|----------|------------|--------|----------|
| 参数校验失败 | 400 | 40001 | `GlobalExceptionHandler` 捕获 `MethodArgumentNotValidException`,返回字段级错误信息 |
| 资源不存在 | 404 | 40401 | Service 层抛出 `BizException`Handler 统一处理 |
| 文件不存在 | 404 | 40402 | 校准文件在存储目录中不存在时,返回描述性错误消息 |
| 唯一性冲突 | 409 | 40901 | Service 层检测重复后抛出 `BizException` |
| 文件上传失败 | 500 | 50002 | 文件写入磁盘失败时,记录日志并返回错误消息 |
| 服务器内部错误 | 500 | 50001 | `GlobalExceptionHandler` 兜底捕获,记录完整堆栈日志 |
### 自定义异常体系
```java
public class BizException extends RuntimeException {
private final int code;
private final String message;
public BizException(int code, String message) {
super(message);
this.code = code;
this.message = message;
}
}
```
### 全局异常处理器
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public R<Void> handleBizException(BizException e) {
return R.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public R<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return R.fail(40001, msg);
}
@ExceptionHandler(Exception.class)
public R<Void> handleException(Exception e) {
log.error("Unhandled exception", e);
return R.fail(50001, "服务器内部错误");
}
}
```
### 前端错误处理策略
| 场景 | 处理方式 |
|------|----------|
| 网络超时5秒 | 显示"网络请求超时,请检查网络连接" |
| HTTP 400 | 显示后端返回的具体校验错误信息 |
| HTTP 404 | 显示"请求的资源不存在" |
| HTTP 409 | 显示后端返回的冲突描述(如"SN号已存在" |
| HTTP 500 | 显示"服务器异常,请稍后重试" |
| 请求进行中 | 页面显示 loading 骨架屏或 spinner |
## 测试策略
### 后端测试
#### 单元测试
- 框架JUnit 5 + Mockito
- 覆盖范围:
- Application Service 层的业务逻辑
- Domain Entity 的状态转换逻辑
- 参数校验逻辑
- 异常处理逻辑
#### 集成测试
- 框架Spring Boot Test + TestContainersPostgreSQL
- 覆盖范围:
- Controller 层 API 端点的请求/响应格式
- MyBatis-Plus Mapper 的 CRUD 操作
- 审计字段自动填充
- 逻辑删除行为
- 分页查询
#### 属性测试
- 框架jqwikJava 属性测试库)
- 最少 100 次迭代
- 每个属性测试需引用设计文档中的属性编号
- 标签格式:`Feature: frontend-backend-separation, Property {number}: {property_text}`
- 覆盖范围:
- Property 1: 统一响应格式(生成随机数据类型,验证序列化格式)
- Property 3: CRUD 往返一致性(生成随机实体数据,验证创建-查询一致性)
- Property 4: 唯一性约束(生成随机唯一标识,验证重复创建返回 409
- Property 5: 筛选正确性(生成随机筛选参数,验证返回结果匹配)
- Property 8: 分页一致性(生成随机 page/pageSize验证响应格式
- Property 10: 参数校验(生成随机无效参数,验证返回 400
- Property 11: 校准文件上传-下载往返生成随机文件内容和SN号验证上传后下载内容一致、元数据正确
- Property 12: 校准文件列表完整性(生成随机数量的校准文件上传,验证列表返回数量和排序正确)
### 前端测试
#### 单元测试
- 框架Vitest + React Testing Library
- 覆盖范围:
- API 客户端的请求拦截和错误处理
- 各 API 服务文件的请求参数构造
- 页面组件的加载状态和错误状态渲染
- 板卡详情抽屉中校准文件列表的渲染和下载按钮交互
#### 集成测试
- 框架Vitest + MSWMock Service Worker
- 覆盖范围:
- 页面组件与 API 的完整交互流程
- 筛选、分页操作的数据刷新
- 表单提交与 API 调用

View File

@ -2,21 +2,41 @@
## 简介 ## 简介
本项目旨在将现有的 Next.js 前端生产管理子系统进行前后端分离改造。当前前端所有页面(设备列表、板卡管理、校准管理、固件库、授权管理、配置文件管理、维修工单、报废管理、设备登记、设备型号管理)均使用硬编码的模拟数据。需要搭建 Java Spring Boot 3.3.6 后端 API 服务和 PostgreSQL 数据库,用真实的 RESTful API 接口和持久化数据替换前端模拟数据。 本项目旨在将现有的 Next.js 16 前端生产管理子系统进行前后端分离改造。当前前端所有页面均使用硬编码的模拟数据,需要搭建 Java Spring Boot 3.3.6 后端 API 服务和 PostgreSQL 数据库,用真实的 RESTful API 接口和持久化数据替换前端模拟数据。
系统为地球物理仪器(高密度电法仪等)的全生命周期管理平台,包含以下核心页面模块:
- 首页 Dashboard`/`)— 8项统计指标、设备状态分布、4组待处理任务
- 设备列表(`/devices`)— 卡片式设备列表,按批次侧边栏分组
- 设备详情(`/devices/[sn]`)— 6个Tab页概览、装机清单、装配记录、授权项信息、配置文件、操作日志
- 设备登记(`/registration`)— 装机信息表单、BOM清单、装配Checklist
- 设备型号管理(`/models`)— 型号列表、装配Checklist模板
- 板卡列表(`/calibration`)— 板卡实例管理,含统计卡片和筛选
- 板卡登记(`/calibration/register`)— 单个/批量板卡登记
- 板卡版本管理(`/boards`)— 板卡型号版本管理Tab筛选
- 固件库(`/firmware`)— 固件版本卡片列表,支持从板卡版本页跳转筛选
- 授权管理(`/licenses`)— 授权列表11项授权模块勾选
- 配置文件管理(`/config-files`)— 配置文件列表,含详细参数
- 维修工单(`/repair`)— 卡片式工单列表,新建/处理/详情抽屉
- 报废管理(`/scrap`)— 审批流程、物料回收
## 术语表 ## 术语表
- **API_Server**:基于 Spring Boot 3.3.6 的 Java 后端 API 服务,项目路径 `apps/geo-bps-api/` - **API_Server**:基于 Spring Boot 3.3.6 的 Java 后端 API 服务,项目路径 `apps/geo-bps-api/`
- **Database**PostgreSQL 12.14 数据库实例,按模块使用不同 schema 隔离 - **Database**PostgreSQL 12.14 数据库实例,设备管理模块使用 `dev` schema
- **Frontend**:现有 Next.js 前端应用,位于 `src/app/` 目录 - **Frontend**:现有 Next.js 16 前端应用,位于 `src/app/` 目录
- **Device_Module**:设备管理业务模块,包名 `com.geomative.bps.device`,数据库 schema `dev` - **Device_Module**:设备管理业务模块,包名 `com.geomative.bps.device`,数据库 schema `dev`
- **Common_Module**:公共模块,包名 `com.geomative.bps.common`,提供统一响应体、全局异常处理、工具类 - **Common_Module**:公共模块,包名 `com.geomative.bps.common`,提供统一响应体、全局异常处理、工具类
- **API_Admin**:后台管理入口模块,提供需登录鉴权的 API 端点 - **API_Admin**:后台管理入口模块,提供需登录鉴权的 API 端点
- **DDD**领域驱动设计分层架构interfaces / application / domain / infrastructure - **DDD**领域驱动设计分层架构interfaces / application / domain / infrastructure
- **Board_Card**板卡实例指具体的单个板卡物理实体拥有唯一SN号、状态在库/已装配/故障/报废)、校准状态
- **Board_Type**:板卡版本(型号),指板卡的设计版本(如 MB-V1.8、RX-V2.3),管理版本的在产/停产状态
- **DO**数据库映射对象Data Object - **DO**数据库映射对象Data Object
- **VO**视图对象View Object用于 API 响应 - **VO**视图对象View Object用于 API 响应
- **Query**:查询参数对象 - **Query**:查询参数对象
- **Command**:命令对象,用于创建/更新操作 - **Command**:命令对象,用于创建/更新操作
- **Calibration_File**校准文件采集板校准时由上位机生成并上传的文件通过采集板SN号关联
- **Upper_Computer**上位机校准设备的PC端软件负责执行采集板校准并上传校准文件
- **API_Portal**:前端门户入口模块,提供无需登录鉴权的公开 API 端点
## 需求 ## 需求
@ -41,144 +61,186 @@
#### 验收标准 #### 验收标准
1. THE Database SHALL 在 `dev` schema 下创建设备管理相关的数据表 1. THE Database SHALL 在 `dev` schema 下创建设备管理相关的数据表
2. THE Database SHALL 包含 `dev.devices`存储设备信息SN号、型号、状态、固件版本、生产日期、客户名称、批次号 2. THE Database SHALL 包含 `dev.devices`存储设备信息SN号唯一索引、型号ID、型号名称、状态[装配中/已出厂/已激活/报废]、固件版本、生产日期、客户名称、批次号[YYYY-WXX格式]、登记人)
3. THE Database SHALL 包含 `dev.device_models` 表,存储设备型号信息(型号名称、型号代码、状态、描述) 3. THE Database SHALL 包含 `dev.device_models`存储设备型号信息型号名称如GD-30 Supreme、型号代码如GD30唯一索引、状态[在产/停产]、描述、创建日期)
4. THE Database SHALL 包含 `dev.board_types` 表,存储板卡型号信息(板卡类型、型号、固件版本、生产日期、状态) 4. THE Database SHALL 包含 `dev.board_types` 表,存储板卡版本信息(板卡类型[主协板/采集板/发射板/升压板]、版本号如MB-V1.8唯一索引、生产日期、状态[在产/停产]
5. THE Database SHALL 包含 `dev.firmware_versions`存储固件版本信息版本号、板卡型号、固件类型、发布日期、状态、文件大小、下载次数、硬件版本范围、升级类型、是否签名、MD5、SHA256、发布说明 5. THE Database SHALL 包含 `dev.board_cards`存储板卡实例信息板卡SN号唯一索引、板卡类型[主协板/采集板/发射板/升压板]、版本号、固件版本、状态[在库/已装配/故障/报废]、所属设备SN、生产日期、校准状态[合格/不合格/待校准/无需校准]、校准日期、备注)
6. THE Database SHALL 包含 `dev.calibration_records`存储校准记录采集板SN号、板卡型号、校准日期、到期日期、校准人员、状态、通道数、综合偏差 6. THE Database SHALL 包含 `dev.device_boards`存储设备与板卡的装配关联关系设备ID、设备SN、板卡实例ID、板卡SN、板卡名称、板卡型号、校准状态
7. THE Database SHALL 包含 `dev.config_files` 表,存储配置文件信息(配置名称、适配型号、版本、状态、发射参数、采集参数、网络参数) 7. THE Database SHALL 包含 `dev.firmware_versions`存储固件版本信息版本号、关联板卡版本ID、板卡版本号、固件类型[主协板/采集板/发射板/升压板/主机固件/计算单元固件]、发布日期、状态[已发布/草稿]、文件大小、下载次数、硬件版本范围、升级类型[可选/强制]、是否签名、MD5、SHA256、发布说明JSON数组、文件存储路径
8. THE Database SHALL 包含 `dev.licenses` 表,存储授权信息(设备型号、授权模块列表、到期时间、状态) 8. THE Database SHALL 包含 `dev.calibration_records`存储校准记录采集板SN号、板卡版本ID、板卡版本号、校准日期、到期日期、校准人员、状态[合格/不合格/待校准]、通道数、综合偏差、各通道校准结果JSONB
9. THE Database SHALL 包含 `dev.repair_orders`存储维修工单信息工单号、设备SN、故障类型、状态、优先级、负责人、描述 9. THE Database SHALL 包含 `dev.config_files`存储配置文件信息配置名称、适配型号ID、型号名称、版本、状态[生效/已停用]、发射参数JSONB含电压/电流/占空比/脉宽/迭代次数/波形、采集参数JSONB含通道数/采样率/电压量程/全波形、保护参数JSONB含过压/过流/短路/高温、网络参数JSONB含WiFi SSID前缀
10. THE Database SHALL 包含 `dev.scrap_records`存储报废记录设备SN、型号、报废原因、申请人、状态、来源工单号、残值评估、可回收物料 10. THE Database SHALL 包含 `dev.licenses`存储授权信息设备型号ID、型号代码、授权模块列表JSONB含11项模块[1D SP/2D SP/3D SP/1D VES/2D ERT/3D ERT/1D IP/2D IP/3D IP/跨孔/水上]、有效期类型[永久/1年/2年/3年/自定义]、到期日期、状态[生效/草稿/已停用]
11. THE Database SHALL 包含 `dev.checklist_templates` 表和 `dev.checklist_items` 表,存储装配 Checklist 模板及其检查项 11. THE Database SHALL 包含 `dev.repair_orders`存储维修工单信息工单号唯一索引、设备SN、故障类型[板卡故障/固件异常/通信故障/电源故障/传感器故障/其他]、状态[待处理/处理中/已处理]、优先级[高/中/低]、负责人、故障描述、故障现象、预计修复日期、备注、处理记录JSONB时间线、板卡更换记录JSONB
12. WHEN 创建任何业务表时THE Database SHALL 包含 idVARCHAR(64) 主键、created_at、created_by、updated_at、updated_by、deleted 审计字段 12. THE Database SHALL 包含 `dev.scrap_records`存储报废记录设备SN、设备型号名称、报废原因、申请人、状态[待审批/审批中/已审批/已驳回/回收中/已回收]、来源工单号、残值评估金额、可回收物料列表JSONB、审批记录时间线JSONB、审批意见
13. THE Database SHALL 包含 `dev.checklist_templates` 表和 `dev.checklist_items` 表,存储装配 Checklist 模板及其检查项(检查项含名称、是否必填、排序序号)
14. WHEN 创建任何业务表时THE Database SHALL 包含 idVARCHAR(64) 主键、created_at、created_by、updated_at、updated_by、deleted 审计字段
### 需求 3设备管理 API ### 需求 3设备管理 API
**用户故事:** 作为前端开发者,我希望通过 API 获取和管理设备数据,以便替换设备列表页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 获取和管理设备数据,以便替换设备列表页面`/devices`)和设备详情页面(`/devices/[sn]`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/devices`THE API_Server SHALL 返回分页的设备列表支持按型号、状态、生产日期、SN号、批次号筛选 1. WHEN 前端请求 GET `/api/admin/devices`THE API_Server SHALL 返回分页的设备列表支持按型号、状态、生产日期、SN号、批次号筛选每条记录包含 sn、model、type、status、firmware、productionDate、customer、batch 字段
2. WHEN 前端请求 GET `/api/admin/devices/{sn}`THE API_Server SHALL 返回指定设备的详细信息,包含关联的授权信息、子设备列表、固件信息 2. WHEN 前端请求 GET `/api/admin/devices/{sn}`THE API_Server SHALL 返回指定设备的完整详情包含6个Tab页所需的全部数据
3. WHEN 前端请求 POST `/api/admin/devices`THE API_Server SHALL 创建新设备记录(设备登记),包含装机信息和 BOM 清单 - 概览Tab基本信息SN、型号、类型、固件版本、生产日期、登记人、状态
4. WHEN 前端请求 GET `/api/admin/devices/batches`THE API_Server SHALL 返回所有生产批次列表及每个批次的设备数量 - 装机清单TabBOM列表物料名称、板卡SN、型号、校准状态
- 装配记录TabChecklist检查项列表项目名称、通过状态、照片URL列表、装配记录备注含通过数/总数统计
- 授权项信息Tab授权模块列表、到期时间、授权状态
- 配置文件Tab配置文件名称、版本、发射参数电压/电流/占空比/脉宽/波形/全波形)、采集参数(通道数/采样率/电压量程/迭代次数)、保护参数(过压/过流/短路/高温阈值、网络参数WiFi SSID前缀
- 操作日志Tab操作记录时间线日期、操作类型、操作人、详情描述
3. WHEN 前端请求 POST `/api/admin/devices`THE API_Server SHALL 创建新设备记录设备登记包含装机信息型号、主机SN、主板SN、测试状态、生产日期、登记人、BOM清单列表和装配Checklist状态
4. WHEN 前端请求 GET `/api/admin/devices/batches`THE API_Server SHALL 返回所有生产批次列表每个批次包含批次号YYYY-WXX格式、设备数量按年份分组并按时间倒序排列
5. IF 请求参数中的 SN 号已存在THEN THE API_Server SHALL 返回 409 冲突错误码和描述性错误消息 5. IF 请求参数中的 SN 号已存在THEN THE API_Server SHALL 返回 409 冲突错误码和描述性错误消息
### 需求 4设备型号管理 API ### 需求 4设备型号管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理设备型号和装配 Checklist 模板,以便替换型号管理页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理设备型号和装配 Checklist 模板,以便替换型号管理页面`/models`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/device-models`THE API_Server SHALL 返回所有设备型号列表 1. WHEN 前端请求 GET `/api/admin/device-models`THE API_Server SHALL 返回所有设备型号列表,每条记录包含 name、code、status、description、createDate 字段
2. WHEN 前端请求 POST `/api/admin/device-models`THE API_Server SHALL 创建新设备型号记录 2. WHEN 前端请求 POST `/api/admin/device-models`THE API_Server SHALL 创建新设备型号记录,包含型号名称、型号编码、描述、状态[在产/停产]
3. WHEN 前端请求 GET `/api/admin/checklist-templates?modelCode={code}`THE API_Server SHALL 返回指定型号的装配 Checklist 模板及其检查项列表 3. WHEN 前端请求 PUT `/api/admin/device-models/{id}`THE API_Server SHALL 更新指定型号的状态(在产/停产切换)
4. WHEN 前端请求 POST `/api/admin/checklist-templates`THE API_Server SHALL 创建新的 Checklist 模板,包含检查项列表 4. WHEN 前端请求 GET `/api/admin/checklist-templates?modelCode={code}`THE API_Server SHALL 返回指定型号的装配 Checklist 模板及其检查项列表(含名称、是否必填、排序序号)
5. IF 请求的型号代码已存在THEN THE API_Server SHALL 返回 409 冲突错误码 5. WHEN 前端请求 POST `/api/admin/checklist-templates`THE API_Server SHALL 创建新的 Checklist 模板,包含关联型号和检查项列表
6. IF 请求的型号代码已存在THEN THE API_Server SHALL 返回 409 冲突错误码
### 需求 5板卡型号管理 API ### 需求 5板卡版本管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理板卡型号数据,以便替换板卡管理页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理板卡版本(型号数据,以便替换板卡版本管理页面`/boards`的模拟数据。该页面管理的是板卡的设计版本(如 MB-V1.8),而非单个板卡实例。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/board-types`THE API_Server SHALL 返回板卡型号列表,支持按板卡类型(主协板、采集板、发射板、升压板)筛选 1. WHEN 前端请求 GET `/api/admin/board-types`THE API_Server SHALL 返回板卡版本列表,支持按板卡类型(全部/主协板/采集板/发射板/升压板Tab筛选每条记录包含 type、version、productionDate、status 字段
2. WHEN 前端请求 GET `/api/admin/board-types/{id}`THE API_Server SHALL 返回板卡详情,包含升级历史、校准历史、保养历史、维修历史 2. WHEN 前端请求 GET `/api/admin/board-types/{id}`THE API_Server SHALL 返回板卡版本详情,包含基本信息(类型、版本、生产日期、状态)
3. WHEN 前端请求 POST `/api/admin/board-types`THE API_Server SHALL 创建新板卡型号记录 3. WHEN 前端请求 POST `/api/admin/board-types`THE API_Server SHALL 创建新板卡版本记录,包含板卡类型、版本号、生产日期、状态
4. WHEN 前端请求 PUT `/api/admin/board-types/{id}/toggle-status`THE API_Server SHALL 切换板卡版本的状态(在产↔停产)
### 需求 6固件库管理 API ### 需求 6板卡实例管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理固件版本数据,以便替换固件库页面的模拟数据 **用户故事:** 作为前端开发者,我希望通过 API 管理板卡实例数据,以便替换板卡列表页面(`/calibration`的模拟数据。该页面管理的是具体的单个板卡物理实体每个板卡有唯一SN号、库存状态和校准状态
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/firmware` 时THE API_Server SHALL 返回固件版本列表,支持按固件类型和板卡型号筛选 1. WHEN 前端请求 GET `/api/admin/board-cards` 时THE API_Server SHALL 返回分页的板卡实例列表,支持按板卡类型(全部/主协板/采集板/发射板/升压板)、板卡状态(全部/在库/已装配/故障/报废)、校准状态(全部/合格/不合格/待校准筛选支持按板卡SN或设备SN搜索每条记录包含 sn、type、version、firmware、status、deviceSn、productionDate、calibStatus、calibDate 字段
2. WHEN 前端请求 POST `/api/admin/firmware`THE API_Server SHALL 创建新固件版本记录,包含版本号、硬件版本范围、升级类型、固件类型、签名状态、发布说明 2. WHEN 前端请求 GET `/api/admin/board-cards/{id}`THE API_Server SHALL 返回板卡实例详情包含基本信息SN、类型、版本、固件版本、生产日期、状态、装配信息所属设备SN、校准信息校准状态、校准日期仅采集板显示
3. WHEN 前端请求 GET `/api/admin/firmware/{id}/download` 时THE API_Server SHALL 返回固件文件的下载流,并将该固件的下载次数加 1 3. WHEN 前端请求 GET `/api/admin/board-cards/stats` 时THE API_Server SHALL 返回板卡统计数据,包含板卡总数、在库数量、已装配数量、故障数量、待校准数量
4. IF 上传的固件版本号与同一板卡型号的已有版本重复THEN THE API_Server SHALL 返回 409 冲突错误码 4. IF 请求参数中的板卡SN号已存在THEN THE API_Server SHALL 返回 409 冲突错误码和描述性错误消息
### 需求 7校准管理 API ### 需求 7板卡登记 API
**用户故事:** 作为前端开发者,我希望通过 API 管理采集板校准数据,以便替换校准管理页面的模拟数据 **用户故事:** 作为前端开发者,我希望通过 API 登记新板卡,以便替换板卡登记页面(`/calibration/register`)的模拟数据。该页面支持单个和批量登记板卡
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/calibrations`THE API_Server SHALL 返回分页的校准记录列表支持按采集板SN号、校准状态、校准人员筛选 1. WHEN 前端请求 POST `/api/admin/board-cards`THE API_Server SHALL 创建单个板卡实例记录包含板卡类型、版本号、固件版本根据版本自动填充、板卡SN号、生产日期、备注
2. WHEN 前端请求 POST `/api/admin/board-cards/batch`THE API_Server SHALL 支持批量创建多个板卡实例记录,请求体为板卡信息数组
3. WHEN 创建采集板类型的板卡时THE API_Server SHALL 自动将校准状态设置为"待校准"
4. WHEN 创建非采集板类型的板卡时THE API_Server SHALL 自动将板卡状态设置为"在库",校准状态设置为无需校准
5. THE API_Server SHALL 提供 GET `/api/admin/board-types/{type}/versions` 接口,返回指定板卡类型的可选版本列表及对应固件版本,用于板卡登记表单的版本下拉选择
### 需求 8固件库管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理固件版本数据,以便替换固件库页面(`/firmware`)的模拟数据。
#### 验收标准
1. WHEN 前端请求 GET `/api/admin/firmware`THE API_Server SHALL 返回固件版本列表,支持按固件类型(全部/主协板/采集板/发射板/升压板/主机固件/计算单元固件Tab筛选
2. WHEN 前端请求 GET `/api/admin/firmware?boardVersion={version}`THE API_Server SHALL 返回指定板卡版本的固件列表,用于从板卡版本管理页面(`/boards`)通过 `?board=` 参数跳转后的筛选展示
3. WHEN 前端请求 POST `/api/admin/firmware`THE API_Server SHALL 创建新固件版本记录,包含版本号、硬件版本范围、升级类型[可选/强制]、签名状态、发布说明(多行文本,每行一条)
4. WHEN 前端请求 GET `/api/admin/firmware/{id}/download`THE API_Server SHALL 返回固件文件的下载流,并将该固件的下载次数加 1
5. IF 上传的固件版本号与同一板卡版本的已有版本重复THEN THE API_Server SHALL 返回 409 冲突错误码
### 需求 9校准记录管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理采集板校准数据。校准功能是板卡实例管理的一部分,校准状态直接关联到板卡实例(`dev.board_cards`)的 calibStatus 和 calibDate 字段。
#### 验收标准
1. WHEN 前端请求 GET `/api/admin/calibrations`THE API_Server SHALL 返回分页的校准记录列表支持按采集板SN号、校准状态[合格/不合格/待校准]、校准人员筛选
2. WHEN 前端请求 GET `/api/admin/calibrations/{id}`THE API_Server SHALL 返回校准详情,包含各通道的校准结果(参考值、测量值、偏差、结果) 2. WHEN 前端请求 GET `/api/admin/calibrations/{id}`THE API_Server SHALL 返回校准详情,包含各通道的校准结果(参考值、测量值、偏差、结果)
3. WHEN 前端请求 POST `/api/admin/calibrations/import`THE API_Server SHALL 支持批量导入校准记录数据 3. WHEN 前端请求 POST `/api/admin/calibrations/import`THE API_Server SHALL 支持批量导入校准记录数据,并同步更新对应板卡实例(`dev.board_cards`)的校准状态和校准日期
### 需求 8配置文件管理 API ### 需求 10:配置文件管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理设备配置文件,以便替换配置文件管理页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理设备配置文件,以便替换配置文件管理页面`/config-files`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/config-files`THE API_Server SHALL 返回分页的配置文件列表,支持按适配型号、版本、关键字筛选 1. WHEN 前端请求 GET `/api/admin/config-files`THE API_Server SHALL 返回分页的配置文件列表,支持按适配型号、版本、关键字筛选,每条记录包含 name、model、version、createTime、status 字段
2. WHEN 前端请求 GET `/api/admin/config-files/{id}`THE API_Server SHALL 返回配置文件详情,包含发射参数、采集参数、网络参数 2. WHEN 前端请求 GET `/api/admin/config-files/{id}`THE API_Server SHALL 返回配置文件详情,包含基本信息(名称、型号、版本、状态、创建时间)、发射参数(电压、电流、占空比、脉宽范围、迭代次数)、采集参数(通道数、采样率、电压量程、全波形采集)、网络参数WiFi SSID前缀
3. WHEN 前端请求 POST `/api/admin/config-files`THE API_Server SHALL 创建新配置文件记录 3. WHEN 前端请求 POST `/api/admin/config-files`THE API_Server SHALL 创建新配置文件记录,包含适配型号、版本、发射参数、采集参数、网络参数
4. WHEN 前端请求 PUT `/api/admin/config-files/{id}`THE API_Server SHALL 更新指定配置文件记录 4. WHEN 前端请求 PUT `/api/admin/config-files/{id}`THE API_Server SHALL 更新指定配置文件记录
5. WHEN 前端请求 DELETE `/api/admin/config-files/{id}`THE API_Server SHALL 逻辑删除指定配置文件(设置 deleted=1 5. WHEN 前端请求 DELETE `/api/admin/config-files/{id}`THE API_Server SHALL 逻辑删除指定配置文件(设置 deleted=1
### 需求 9授权管理 API ### 需求 11:授权管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理设备授权数据,以便替换授权管理页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理设备授权数据,以便替换授权管理页面`/licenses`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/licenses`THE API_Server SHALL 返回分页的授权列表,支持按设备型号和状态筛选 1. WHEN 前端请求 GET `/api/admin/licenses`THE API_Server SHALL 返回分页的授权列表,支持按设备型号GD-10/GD-20/GD-30和状态(生效/草稿/已停用)筛选,每条记录包含 model、modules、expiry、date、status 字段
2. WHEN 前端请求 POST `/api/admin/licenses`THE API_Server SHALL 创建新授权记录,包含设备型号、授权模块列表、授权期限 2. WHEN 前端请求 POST `/api/admin/licenses`THE API_Server SHALL 创建新授权记录,包含设备型号、授权模块列表从11项模块中勾选、授权期限[1年/2年/3年/永久/自定义]、自定义到期日期
3. WHEN 前端请求 PUT `/api/admin/licenses/{id}`THE API_Server SHALL 更新指定授权记录 3. WHEN 前端请求 PUT `/api/admin/licenses/{id}`THE API_Server SHALL 更新指定授权记录
4. WHEN 前端请求 PUT `/api/admin/licenses/{id}/disable`THE API_Server SHALL 将指定授权记录状态设置为已停用 4. WHEN 前端请求 PUT `/api/admin/licenses/{id}/disable`THE API_Server SHALL 将指定授权记录状态设置为已停用
5. THE API_Server SHALL 提供 GET `/api/admin/licenses/auth-items` 接口返回全部11项授权模块定义ID、名称、描述
6. THE API_Server SHALL 提供 GET `/api/admin/licenses/model-presets` 接口返回各型号的默认授权模块预选配置GD-10: 6项, GD-20: 9项, GD-30: 全部11项
### 需求 10维修工单管理 API ### 需求 12:维修工单管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理维修工单数据,以便替换维修工单页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理维修工单数据,以便替换维修工单页面`/repair`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/repair-orders`THE API_Server SHALL 返回分页的维修工单列表支持按状态、优先级、负责人、日期范围、设备SN筛选 1. WHEN 前端请求 GET `/api/admin/repair-orders`THE API_Server SHALL 返回分页的维修工单列表,支持按状态[全部/待处理/处理中/已处理]、优先级[全部/高/中/低]、负责人、日期范围(开始~结束、设备SN筛选每条记录包含 id、sn、faultType、status、priority、assignee、createDate、description 字段
2. WHEN 前端请求 GET `/api/admin/repair-orders/{id}`THE API_Server SHALL 返回工单详情,包含设备信息、故障信息、处理记录时间线、板卡更换记录 2. WHEN 前端请求 GET `/api/admin/repair-orders/{id}`THE API_Server SHALL 返回工单详情,包含:
3. WHEN 前端请求 POST `/api/admin/repair-orders`THE API_Server SHALL 创建新维修工单 - 工单信息(工单号、状态、优先级、创建时间、负责人、预计修复时间)
4. WHEN 前端请求 PUT `/api/admin/repair-orders/{id}/process`THE API_Server SHALL 更新工单处理信息(处理操作、板卡更换、授权处理、处理备注) - 设备信息设备SN、型号、类型、固件版本、位置通过 deviceInfoMap 查询)
- 故障信息(故障类型、描述、现象)
- 处理记录时间线(日期、操作人、操作类型、备注)
- 板卡更换记录原板卡SN、新板卡SN、板卡类型、板卡型号、更换日期、操作人
3. WHEN 前端请求 POST `/api/admin/repair-orders`THE API_Server SHALL 创建新维修工单包含设备SN从设备列表选择、故障类型单选6种、故障描述、故障现象、优先级低/中/高)、维修人员、预计修复时间、备注
4. WHEN 前端请求 PUT `/api/admin/repair-orders/{id}/process`THE API_Server SHALL 更新工单处理信息,包含处理操作类型[更换板卡/固件修复/参数重置/其他处理]、板卡更换信息板卡类型、板卡型号、原SN、新SN、授权处理重新生成授权文件/推送适配固件)、处理备注
5. WHEN 前端请求 PUT `/api/admin/repair-orders/{id}/close`THE API_Server SHALL 关闭工单并将状态设置为已处理 5. WHEN 前端请求 PUT `/api/admin/repair-orders/{id}/close`THE API_Server SHALL 关闭工单并将状态设置为已处理
6. THE API_Server SHALL 提供 GET `/api/admin/repair-orders/device-info/{sn}` 接口返回指定设备SN的设备信息型号、类型、固件版本、位置用于新建工单时选择设备后展示设备信息
### 需求 11报废管理 API ### 需求 13:报废管理 API
**用户故事:** 作为前端开发者,我希望通过 API 管理报废审批和物料回收数据,以便替换报废管理页面的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 管理报废审批和物料回收数据,以便替换报废管理页面`/scrap`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/scrap-records`THE API_Server SHALL 返回分页的报废记录列表支持按设备SN、状态、日期筛选 1. WHEN 前端请求 GET `/api/admin/scrap-records`THE API_Server SHALL 返回分页的报废记录列表支持按设备SN、状态[全部/待审批/审批中/已审批/已驳回/回收中/已回收]、日期筛选,每条记录包含 sn、model、reason、applicant、status、orderId、date、value、materials 字段
2. WHEN 前端请求 GET `/api/admin/scrap-records/{id}`THE API_Server SHALL 返回报废详情,包含设备信息、审批信息、可回收物料列表、审批记录时间线 2. WHEN 前端请求 GET `/api/admin/scrap-records/{id}`THE API_Server SHALL 返回报废详情,包含设备信息SN、型号、报废日期、报废原因、残值评估、审批信息(申请人、状态、来源工单号)、可回收物料标签列表、审批记录时间线(日期、操作类型、操作人、备注)
3. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/approve`THE API_Server SHALL 审批通过报废申请,更新状态为已审批 3. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/approve`THE API_Server SHALL 审批通过报废申请,更新状态为已审批,记录审批意见
4. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/reject`THE API_Server SHALL 驳回报废申请,更新状态为已驳回,记录驳回意见 4. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/reject`THE API_Server SHALL 驳回报废申请,更新状态为已驳回,记录驳回意见
5. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/recover`THE API_Server SHALL 完成物料回收入库,更新状态为已回收,记录回收物料清单 5. WHEN 前端请求 PUT `/api/admin/scrap-records/{id}/recover`THE API_Server SHALL 完成物料回收入库,更新状态为已回收,记录已检测的回收物料清单和回收备注
6. THE API_Server SHALL 提供 GET `/api/admin/scrap-records/stats` 接口,返回报废统计数据(报废总数、待审批数、已审批待回收数、已回收数) 6. THE API_Server SHALL 提供 GET `/api/admin/scrap-records/stats` 接口,返回报废统计数据(报废总数、待审批数、已审批待回收数、已回收数)
### 需求 12:首页 Dashboard 统计 API ### 需求 14:首页 Dashboard 统计 API
**用户故事:** 作为前端开发者,我希望通过 API 获取首页仪表盘的统计数据,以便替换首页的模拟数据。 **用户故事:** 作为前端开发者,我希望通过 API 获取首页仪表盘的统计数据,以便替换首页`/`的模拟数据。
#### 验收标准 #### 验收标准
1. WHEN 前端请求 GET `/api/admin/dashboard/metrics`THE API_Server SHALL 返回设备总数、装配中数量、已激活数量、维修中数量、报废数量、授权即将到期数量等统计指标 1. WHEN 前端请求 GET `/api/admin/dashboard/metrics`THE API_Server SHALL 返回8项统计指标设备总数、装配中数量、已激活数量、有新版本数量、维修中数量、报废数量、授权即将到期数量、可升级数量,每项指标包含当前值和趋势变化百分比
2. WHEN 前端请求 GET `/api/admin/dashboard/device-status`THE API_Server SHALL 返回设备状态分布数据(已装配、已出厂、已激活、报废各状态的数量) 2. WHEN 前端请求 GET `/api/admin/dashboard/device-status`THE API_Server SHALL 返回设备状态分布数据,包含已装配、已出厂、已激活、报废四种状态的数量,用于横向条形图展示
3. WHEN 前端请求 GET `/api/admin/dashboard/tasks`THE API_Server SHALL 返回待处理任务列表,包含校准即将到期、维修工单、固件升级通知、授权即将到期四个分组 3. WHEN 前端请求 GET `/api/admin/dashboard/tasks`THE API_Server SHALL 返回待处理任务列表,包含4个分组校准即将到期、维修工单、固件升级通知、授权即将到期每组包含总数和最近2条任务详情设备SN/名称、描述、时间、跳转链接)
### 需求 13:前端 API 集成层 ### 需求 15:前端 API 集成层
**用户故事:** 作为前端开发者,我希望在前端建立统一的 API 调用层,以便各页面组件可以方便地调用后端接口替换模拟数据。 **用户故事:** 作为前端开发者,我希望在前端建立统一的 API 调用层,以便各页面组件可以方便地调用后端接口替换模拟数据。
#### 验收标准 #### 验收标准
1. THE Frontend SHALL 创建统一的 API 客户端模块,配置后端 API 基础 URL、请求超时时间5秒、统一错误处理 1. THE Frontend SHALL 创建统一的 API 客户端模块`src/lib/api/client.ts`,配置后端 API 基础 URL(通过 NEXT_PUBLIC_API_URL 环境变量)、请求超时时间5秒、统一错误处理
2. THE Frontend SHALL 为每个业务模块创建独立的 API 服务文件(如 deviceApi.ts、boardApi.ts、firmwareApi.ts 等) 2. THE Frontend SHALL 为每个业务模块创建独立的 API 服务文件deviceApi.ts、modelApi.ts、boardTypeApi.ts、boardCardApi.ts、firmwareApi.ts、calibrationApi.ts、configFileApi.ts、licenseApi.ts、repairApi.ts、scrapApi.ts、dashboardApi.ts
3. WHEN API 请求失败时THE Frontend SHALL 在页面上展示友好的错误提示信息 3. WHEN API 请求失败时THE Frontend SHALL 在页面上展示友好的错误提示信息
4. WHEN API 请求正在进行时THE Frontend SHALL 展示加载状态指示器 4. WHEN API 请求正在进行时THE Frontend SHALL 展示加载状态指示器
5. THE Frontend SHALL 将各页面组件中的硬编码模拟数据替换为 API 调用,使用 React 的 useState 和 useEffect 管理数据获取状态 5. THE Frontend SHALL 将各页面组件中的硬编码模拟数据替换为 API 调用,使用 React 的 useState 和 useEffect 管理数据获取状态
### 需求 14:分页与筛选标准化 ### 需求 16:分页与筛选标准化
**用户故事:** 作为前端开发者,我希望前后端采用统一的分页和筛选参数规范,以便各列表页面的数据交互保持一致。 **用户故事:** 作为前端开发者,我希望前后端采用统一的分页和筛选参数规范,以便各列表页面的数据交互保持一致。
@ -188,3 +250,20 @@
2. THE API_Server SHALL 对所有列表接口返回统一的分页响应格式,包含 total总记录数、page当前页码、pageSize每页条数、records数据列表 2. THE API_Server SHALL 对所有列表接口返回统一的分页响应格式,包含 total总记录数、page当前页码、pageSize每页条数、records数据列表
3. THE API_Server SHALL 对所有筛选参数进行服务端校验,无效参数返回 400 错误码和描述性错误消息 3. THE API_Server SHALL 对所有筛选参数进行服务端校验,无效参数返回 400 错误码和描述性错误消息
4. WHEN 筛选条件为空或为"全部"时THE API_Server SHALL 返回不带该条件过滤的完整数据集 4. WHEN 筛选条件为空或为"全部"时THE API_Server SHALL 返回不带该条件过滤的完整数据集
### 需求 17采集板校准文件管理
**用户故事:** 作为上位机校准设备PC端软件操作员我希望在完成采集板校准后将校准文件上传到设备管理平台并通过采集板SN号自动匹配关联以便前端板卡详情中可以查看和下载校准文件。
#### 验收标准
1. THE Database SHALL 包含 `dev.calibration_files`存储校准文件信息采集板SN号、原始文件名、存储路径、文件大小字节、文件MD5校验值、上传时间并包含标准审计字段
2. WHEN 上位机请求 POST `/api/portal/calibration-files/upload` 并携带采集板SN号和校准文件时THE API_Server SHALL 保存校准文件到服务器存储目录,并在 `dev.calibration_files` 表中创建关联记录
3. WHEN 上传校准文件时THE API_Server SHALL 通过采集板SN号匹配 `dev.board_cards` 表中的板卡实例记录,将校准文件与对应的采集板关联
4. IF 上传请求中的采集板SN号在 `dev.board_cards` 表中不存在THEN THE API_Server SHALL 返回 404 错误码和描述性错误消息
5. THE API_Server SHALL 将校准文件上传接口注册在 `api-portal` 模块中,该接口无需登录鉴权
6. WHEN 前端请求 GET `/api/admin/board-cards/{id}/calibration-files`THE API_Server SHALL 返回指定采集板关联的校准文件列表,每条记录包含 id、fileName原始文件名、fileSize文件大小、md5校验值、uploadTime上传时间字段按上传时间倒序排列
7. WHEN 前端请求 GET `/api/admin/calibration-files/{id}/download`THE API_Server SHALL 返回校准文件的下载流,响应头包含正确的文件名和 Content-Type
8. IF 请求下载的校准文件在存储目录中不存在THEN THE API_Server SHALL 返回 404 错误码和描述性错误消息
9. THE Frontend SHALL 在板卡详情抽屉的校准信息区域中展示关联的校准文件列表,每条记录显示文件名、文件大小、上传时间,并提供下载按钮
10. WHEN 同一采集板SN号多次上传校准文件时THE API_Server SHALL 保留所有历史校准文件记录,支持查看完整的校准文件历史

View File

@ -1,30 +1,6 @@
{ {
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
"static/chunks/node_modules_next_app_0jt-zj..js",
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__0c0okpg._.js",
"static/chunks/pages__app_07xvfw~._.js",
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
],
"/_error": [
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__01mw43t._.js",
"static/chunks/pages__error_07xvfw~._.js",
"static/chunks/turbopack-pages__error_016chbq._.js"
]
}, },
"devFiles": [], "devFiles": [],
"polyfillFiles": [ "polyfillFiles": [

View File

@ -1,30 +1,6 @@
{ {
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
"static/chunks/node_modules_next_app_0jt-zj..js",
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__0c0okpg._.js",
"static/chunks/pages__app_07xvfw~._.js",
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
],
"/_error": [
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__01mw43t._.js",
"static/chunks/pages__error_07xvfw~._.js",
"static/chunks/turbopack-pages__error_016chbq._.js"
]
}, },
"devFiles": [], "devFiles": [],
"polyfillFiles": [], "polyfillFiles": [],

View File

@ -1,233 +1,47 @@
{"timestamp":"00:00:01.061","source":"Server","level":"LOG","message":""} {"timestamp":"00:00:01.927","source":"Server","level":"LOG","message":""}
{"timestamp":"00:00:03.305","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:00:05.166","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"19:08:22.325","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:02:55.137","source":"Server","level":"LOG","message":"✓ Compiled in 505ms"}
{"timestamp":"19:08:25.916","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:02:55.338","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: taskGroups is not defined"}
{"timestamp":"117:32:37.131","source":"Server","level":"LOG","message":"✓ Compiled in 153ms"} {"timestamp":"00:02:55.342","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: taskGroups is not defined\\u001b[39m\\n\\u001b[31m at DashboardPage (src/app/page.tsx:77:12)\\u001b[39m\\n \\u001b[90m75 |\\u001b[0m <h3 className=\\u001b[32m\\\"text-lg font-semibold mb-6\\\"\\u001b[0m>待处理任务</h3>\\n \\u001b[90m76 |\\u001b[0m <div className=\\u001b[32m\\\"grid grid-cols-2 gap-6\\\"\\u001b[0m>\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m77 |\\u001b[0m {taskGroups.map((group, gi) => (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m78 |\\u001b[0m <div key={gi}>\\n \\u001b[90m79 |\\u001b[0m <div className=\\u001b[32m\\\"flex items-center justify-between mb-4\\\"\\u001b[0m>\\n \\u001b[90m80 |\\u001b[0m <h4 className=\\u001b[32m\\\"text-base font-medium\\\"\\u001b[0m>{group.title}</h4>\""}
{"timestamp":"117:32:38.097","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:02:55.343","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: taskGroups is not defined\u001b[39m\n\u001b[31m at DashboardPage (src/app/page.tsx:77:12)\u001b[39m\n \u001b[90m75 |\u001b[0m <h3 className=\u001b[32m\"text-lg font-semibold mb-6\"\u001b[0m>待处理任务</h3>\n \u001b[90m76 |\u001b[0m <div className=\u001b[32m\"grid grid-cols-2 gap-6\"\u001b[0m>\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m77 |\u001b[0m {taskGroups.map((group, gi) => (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m78 |\u001b[0m <div key={gi}>\n \u001b[90m79 |\u001b[0m <div className=\u001b[32m\"flex items-center justify-between mb-4\"\u001b[0m>\n \u001b[90m80 |\u001b[0m <h4 className=\u001b[32m\"text-base font-medium\"\u001b[0m>{group.title}</h4>"}
{"timestamp":"117:32:38.544","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:03:27.339","source":"Server","level":"LOG","message":"✓ Compiled in 43ms"}
{"timestamp":"117:32:38.547","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload when ./src/app/devices/page.tsx changed. Read more: https://nextjs.org/docs/messages/fast-refresh-reload"} {"timestamp":"00:03:27.342","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
{"timestamp":"117:32:38.940","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:03:27.675","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"117:32:39.220","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:04:00.436","source":"Server","level":"LOG","message":"✓ Compiled in 31ms"}
{"timestamp":"117:32:40.255","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:15:02.952","source":"Server","level":"LOG","message":"✓ Compiled in 50ms"}
{"timestamp":"117:32:40.535","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:15:10.621","source":"Server","level":"LOG","message":"✓ Compiled in 34ms"}
{"timestamp":"117:32:40.837","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:15:19.794","source":"Server","level":"LOG","message":"✓ Compiled in 36ms"}
{"timestamp":"117:32:41.104","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:15:34.242","source":"Server","level":"LOG","message":"✓ Compiled in 59ms"}
{"timestamp":"117:32:41.590","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:15:52.132","source":"Server","level":"LOG","message":"✓ Compiled in 71ms"}
{"timestamp":"117:32:41.994","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:16:01.417","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
{"timestamp":"117:32:42.214","source":"Browser","level":"ERROR","message":"uncaughtError: Error: The default export is not a React Component in \"/devices/page\""} {"timestamp":"00:16:09.653","source":"Server","level":"LOG","message":"✓ Compiled in 34ms"}
{"timestamp":"117:32:42.258","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: The default export is not a React Component in \\\"/devices/page\\\"\\u001b[39m\""} {"timestamp":"00:16:18.455","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
{"timestamp":"117:32:42.259","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: The default export is not a React Component in \"/devices/page\"\u001b[39m"} {"timestamp":"00:23:51.418","source":"Server","level":"LOG","message":"✓ Compiled in 54ms"}
{"timestamp":"117:33:29.348","source":"Server","level":"LOG","message":"✓ Compiled in 274ms"} {"timestamp":"00:24:00.421","source":"Server","level":"LOG","message":"✓ Compiled in 32ms"}
{"timestamp":"117:33:30.697","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:24:08.371","source":"Server","level":"LOG","message":"✓ Compiled in 39ms"}
{"timestamp":"118:56:04.251","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:24:12.614","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"118:56:06.098","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:24:36.581","source":"Server","level":"LOG","message":"✓ Compiled in 32ms"}
{"timestamp":"118:56:21.517","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:33:32.945","source":"Server","level":"LOG","message":"✓ Compiled in 104ms"}
{"timestamp":"119:13:03.183","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:43:37.634","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"119:13:04.798","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:44:30.678","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"119:14:07.937","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:46:40.902","source":"Server","level":"LOG","message":"✓ Compiled in 106ms"}
{"timestamp":"119:18:22.932","source":"Server","level":"LOG","message":"✓ Compiled in 65ms"} {"timestamp":"00:46:57.334","source":"Server","level":"LOG","message":"✓ Compiled in 46ms"}
{"timestamp":"119:25:27.462","source":"Server","level":"LOG","message":"✓ Compiled in 29ms"} {"timestamp":"00:46:57.600","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: actionMenuId is not defined"}
{"timestamp":"119:28:26.107","source":"Server","level":"LOG","message":"✓ Compiled in 58ms"} {"timestamp":"00:46:57.634","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: actionMenuId is not defined\\u001b[39m\\n\\u001b[31m at ModelsPage (src/app/models/page.tsx:249:8)\\u001b[39m\\n \\u001b[90m247 |\\u001b[0m\\n \\u001b[90m248 |\\u001b[0m {\\u001b[90m/* Click outside to close action menu */\\u001b[0m}\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m249 |\\u001b[0m {actionMenuId !== \\u001b[36mnull\\u001b[0m && (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m250 |\\u001b[0m <div onClick={() => setActionMenuId(\\u001b[36mnull\\u001b[0m)} style={{ position: \\u001b[32m'fixed'\\u001b[0m, inset: \\u001b[35m0\\u001b[0m, zIndex: \\u001b[35m5\\u001b[0m }} />\\n \\u001b[90m251 |\\u001b[0m )}\\n \\u001b[90m252 |\\u001b[0m\""}
{"timestamp":"119:28:32.906","source":"Server","level":"LOG","message":"✓ Compiled in 30ms"} {"timestamp":"00:46:57.635","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: actionMenuId is not defined\u001b[39m\n\u001b[31m at ModelsPage (src/app/models/page.tsx:249:8)\u001b[39m\n \u001b[90m247 |\u001b[0m\n \u001b[90m248 |\u001b[0m {\u001b[90m/* Click outside to close action menu */\u001b[0m}\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m249 |\u001b[0m {actionMenuId !== \u001b[36mnull\u001b[0m && (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m250 |\u001b[0m <div onClick={() => setActionMenuId(\u001b[36mnull\u001b[0m)} style={{ position: \u001b[32m'fixed'\u001b[0m, inset: \u001b[35m0\u001b[0m, zIndex: \u001b[35m5\u001b[0m }} />\n \u001b[90m251 |\u001b[0m )}\n \u001b[90m252 |\u001b[0m"}
{"timestamp":"119:29:36.058","source":"Server","level":"LOG","message":"✓ Compiled in 49ms"} {"timestamp":"00:47:05.547","source":"Server","level":"LOG","message":"✓ Compiled in 35ms"}
{"timestamp":"119:30:08.040","source":"Server","level":"LOG","message":"✓ Compiled in 48ms"} {"timestamp":"00:47:05.554","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
{"timestamp":"119:36:42.082","source":"Server","level":"LOG","message":"✓ Compiled in 67ms"} {"timestamp":"00:47:05.961","source":"Server","level":"ERROR","message":" ReferenceError: actionMenuId is not defined"}
{"timestamp":"119:36:42.377","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: modelsData is not defined"} {"timestamp":"00:47:05.989","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: actionMenuId is not defined"}
{"timestamp":"119:36:42.484","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: modelsData is not defined\\u001b[39m\\n\\u001b[31m at ModelsPage (src/app/models/page.tsx:107:14)\\u001b[39m\\n \\u001b[90m105 |\\u001b[0m </thead>\\n \\u001b[90m106 |\\u001b[0m <tbody>\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m107 |\\u001b[0m {modelsData.map(model => (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m108 |\\u001b[0m <tr key={model.id} style={{ borderBottom: \\u001b[32m'1px solid #F0F0F0'\\u001b[0m }}>\\n \\u001b[90m109 |\\u001b[0m <td style={{ padding: \\u001b[32m'12px 16px'\\u001b[0m, fontSize: \\u001b[35m14\\u001b[0m, fontWeight: \\u001b[35m500\\u001b[0m }}>{model.name}</td>\\n \\u001b[90m110 |\\u001b[0m <td style={{ padding: \\u001b[32m'12px 16px'\\u001b[0m, fontSize: \\u001b[35m14\\u001b[0m, color: \\u001b[32m'rgba(0,0,0,0.65)'\\u001b[0m }}>{model.code}</td>\""} {"timestamp":"00:47:06.088","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: actionMenuId is not defined\\u001b[39m\\n\\u001b[31m at ModelsPage (src/app/models/page.tsx:248:8)\\n at Set.forEach (<anonymous>)\\u001b[39m\\n \\u001b[90m246 |\\u001b[0m\\n \\u001b[90m247 |\\u001b[0m {\\u001b[90m/* Click outside to close action menu */\\u001b[0m}\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m248 |\\u001b[0m {actionMenuId !== \\u001b[36mnull\\u001b[0m && (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m249 |\\u001b[0m <div onClick={() => setActionMenuId(\\u001b[36mnull\\u001b[0m)} style={{ position: \\u001b[32m'fixed'\\u001b[0m, inset: \\u001b[35m0\\u001b[0m, zIndex: \\u001b[35m5\\u001b[0m }} />\\n \\u001b[90m250 |\\u001b[0m )}\\n \\u001b[90m251 |\\u001b[0m\""}
{"timestamp":"119:36:42.484","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: modelsData is not defined\u001b[39m\n\u001b[31m at ModelsPage (src/app/models/page.tsx:107:14)\u001b[39m\n \u001b[90m105 |\u001b[0m </thead>\n \u001b[90m106 |\u001b[0m <tbody>\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m107 |\u001b[0m {modelsData.map(model => (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m108 |\u001b[0m <tr key={model.id} style={{ borderBottom: \u001b[32m'1px solid #F0F0F0'\u001b[0m }}>\n \u001b[90m109 |\u001b[0m <td style={{ padding: \u001b[32m'12px 16px'\u001b[0m, fontSize: \u001b[35m14\u001b[0m, fontWeight: \u001b[35m500\u001b[0m }}>{model.name}</td>\n \u001b[90m110 |\u001b[0m <td style={{ padding: \u001b[32m'12px 16px'\u001b[0m, fontSize: \u001b[35m14\u001b[0m, color: \u001b[32m'rgba(0,0,0,0.65)'\u001b[0m }}>{model.code}</td>"} {"timestamp":"00:47:06.091","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: actionMenuId is not defined\u001b[39m\n\u001b[31m at ModelsPage (src/app/models/page.tsx:248:8)\n at Set.forEach (<anonymous>)\u001b[39m\n \u001b[90m246 |\u001b[0m\n \u001b[90m247 |\u001b[0m {\u001b[90m/* Click outside to close action menu */\u001b[0m}\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m248 |\u001b[0m {actionMenuId !== \u001b[36mnull\u001b[0m && (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m249 |\u001b[0m <div onClick={() => setActionMenuId(\u001b[36mnull\u001b[0m)} style={{ position: \u001b[32m'fixed'\u001b[0m, inset: \u001b[35m0\u001b[0m, zIndex: \u001b[35m5\u001b[0m }} />\n \u001b[90m250 |\u001b[0m )}\n \u001b[90m251 |\u001b[0m"}
{"timestamp":"119:36:56.088","source":"Server","level":"LOG","message":"✓ Compiled in 100ms"} {"timestamp":"00:47:06.199","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"119:36:56.099","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."} {"timestamp":"00:47:06.347","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: actionMenuId is not defined"}
{"timestamp":"119:36:57.229","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"00:47:06.349","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: actionMenuId is not defined\\u001b[39m\\n\\u001b[31m at ModelsPage (src/app/models/page.tsx:248:8)\\u001b[39m\\n \\u001b[90m246 |\\u001b[0m\\n \\u001b[90m247 |\\u001b[0m {\\u001b[90m/* Click outside to close action menu */\\u001b[0m}\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m248 |\\u001b[0m {actionMenuId !== \\u001b[36mnull\\u001b[0m && (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m249 |\\u001b[0m <div onClick={() => setActionMenuId(\\u001b[36mnull\\u001b[0m)} style={{ position: \\u001b[32m'fixed'\\u001b[0m, inset: \\u001b[35m0\\u001b[0m, zIndex: \\u001b[35m5\\u001b[0m }} />\\n \\u001b[90m250 |\\u001b[0m )}\\n \\u001b[90m251 |\\u001b[0m\""}
{"timestamp":"119:37:03.520","source":"Server","level":"LOG","message":"✓ Compiled in 36ms"} {"timestamp":"00:47:06.349","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: actionMenuId is not defined\u001b[39m\n\u001b[31m at ModelsPage (src/app/models/page.tsx:248:8)\u001b[39m\n \u001b[90m246 |\u001b[0m\n \u001b[90m247 |\u001b[0m {\u001b[90m/* Click outside to close action menu */\u001b[0m}\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m248 |\u001b[0m {actionMenuId !== \u001b[36mnull\u001b[0m && (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m249 |\u001b[0m <div onClick={() => setActionMenuId(\u001b[36mnull\u001b[0m)} style={{ position: \u001b[32m'fixed'\u001b[0m, inset: \u001b[35m0\u001b[0m, zIndex: \u001b[35m5\u001b[0m }} />\n \u001b[90m250 |\u001b[0m )}\n \u001b[90m251 |\u001b[0m"}
{"timestamp":"119:37:24.669","source":"Server","level":"LOG","message":"✓ Compiled in 86ms"} {"timestamp":"00:47:33.817","source":"Server","level":"LOG","message":"✓ Compiled in 44ms"}
{"timestamp":"119:37:43.557","source":"Server","level":"LOG","message":"✓ Compiled in 47ms"} {"timestamp":"00:47:33.825","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
{"timestamp":"119:38:59.568","source":"Server","level":"LOG","message":"✓ Compiled in 194ms"} {"timestamp":"00:47:34.427","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"119:39:00.886","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"} {"timestamp":"01:03:11.671","source":"Server","level":"LOG","message":"✓ Compiled in 35ms"}
{"timestamp":"119:41:47.398","source":"Server","level":"LOG","message":"✓ Compiled in 30ms"} {"timestamp":"01:03:41.505","source":"Server","level":"LOG","message":"✓ Compiled in 50ms"}
{"timestamp":"119:42:31.585","source":"Server","level":"LOG","message":"✓ Compiled in 32ms"} {"timestamp":"01:03:51.542","source":"Server","level":"LOG","message":"✓ Compiled in 66ms"}
{"timestamp":"119:45:00.791","source":"Server","level":"LOG","message":"✓ Compiled in 52ms"}
{"timestamp":"119:45:16.637","source":"Server","level":"LOG","message":"✓ Compiled in 86ms"}
{"timestamp":"119:45:16.991","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: batchList is not defined"}
{"timestamp":"119:45:17.051","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: batchList is not defined\\u001b[39m\\n\\u001b[31m at DevicesPage (src/app/devices/page.tsx:198:14)\\u001b[39m\\n \\u001b[90m196 |\\u001b[0m }}>{devicesData.length}</span>\\n \\u001b[90m197 |\\u001b[0m </button>\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m198 |\\u001b[0m {batchList.map(({ batch, count }) => (\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m199 |\\u001b[0m <button\\n \\u001b[90m200 |\\u001b[0m key={batch}\\n \\u001b[90m201 |\\u001b[0m onClick={() => handleBatchSelect(batch)}\""}
{"timestamp":"119:45:17.053","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: batchList is not defined\u001b[39m\n\u001b[31m at DevicesPage (src/app/devices/page.tsx:198:14)\u001b[39m\n \u001b[90m196 |\u001b[0m }}>{devicesData.length}</span>\n \u001b[90m197 |\u001b[0m </button>\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m198 |\u001b[0m {batchList.map(({ batch, count }) => (\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m199 |\u001b[0m <button\n \u001b[90m200 |\u001b[0m key={batch}\n \u001b[90m201 |\u001b[0m onClick={() => handleBatchSelect(batch)}"}
{"timestamp":"119:45:41.032","source":"Server","level":"LOG","message":"✓ Compiled in 52ms"}
{"timestamp":"119:45:41.037","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
{"timestamp":"119:45:42.083","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"119:49:42.815","source":"Server","level":"LOG","message":"✓ Compiled in 89ms"}
{"timestamp":"120:11:46.034","source":"Server","level":"LOG","message":"✓ Compiled in 46ms"}
{"timestamp":"120:15:49.161","source":"Server","level":"LOG","message":"✓ Compiled in 38ms"}
{"timestamp":"120:15:54.549","source":"Server","level":"ERROR","message":"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."}
{"timestamp":"120:15:54.759","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:15:54.834","source":"Browser","level":"ERROR","message":"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."}
{"timestamp":"120:15:54.853","source":"Server","level":"ERROR","message":"[browser] \"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.\" \"\""}
{"timestamp":"120:15:54.854","source":"Browser","level":"ERROR","message":"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. \"\""}
{"timestamp":"120:16:05.201","source":"Server","level":"LOG","message":"✓ Compiled in 67ms"}
{"timestamp":"120:21:20.618","source":"Server","level":"LOG","message":"✓ Compiled in 71ms"}
{"timestamp":"120:21:52.821","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:53.298","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:53.333","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload when ./src/app/calibration/page.tsx changed. Read more: https://nextjs.org/docs/messages/fast-refresh-reload"}
{"timestamp":"120:21:53.887","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:54.306","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:54.655","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:54.957","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:55.271","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:55.651","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:56.158","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:56.535","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:56.652","source":"Browser","level":"ERROR","message":"uncaughtError: Error: The default export is not a React Component in \"/calibration/page\""}
{"timestamp":"120:21:56.662","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: The default export is not a React Component in \\\"/calibration/page\\\"\\u001b[39m\""}
{"timestamp":"120:21:56.663","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: The default export is not a React Component in \"/calibration/page\"\u001b[39m"}
{"timestamp":"120:22:31.629","source":"Server","level":"ERROR","message":" ./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]\n\n"}
{"timestamp":"120:22:31.636","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]"}
{"timestamp":"120:22:31.816","source":"Server","level":"ERROR","message":" ./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]\n\n"}
{"timestamp":"120:22:31.818","source":"Server","level":"ERROR","message":"[browser] \"./src/app/calibration/page.tsx:138:1\\nExpected '</', got '<eof>'\\n 136 | </div>\\n 137 | </div>\\n> 138 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:22:31.819","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component] \"\""}
{"timestamp":"120:22:31.821","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]"}
{"timestamp":"120:22:31.822","source":"Server","level":"ERROR","message":"[browser] \"./src/app/calibration/page.tsx:138:1\\nExpected '</', got '<eof>'\\n 136 | </div>\\n 137 | </div>\\n> 138 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:22:31.822","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component] \"\""}
{"timestamp":"120:22:31.902","source":"Browser","level":"ERROR","message":"uncaughtError: Error: ./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]\n\n"}
{"timestamp":"120:22:31.904","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: ./src/app/calibration/page.tsx:138:1\\nExpected '</', got '<eof>'\\n 136 | </div>\\n 137 | </div>\\n> 138 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n\\u001b[39m\\n\\u001b[31m at <unknown> (Error: ./src/app/calibration/page.tsx:138:1)\\n at <unknown> (Error: (./src/app/calibration/page.tsx:138:1)\\u001b[39m\""}
{"timestamp":"120:22:31.904","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: ./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]\n\n\u001b[39m\n\u001b[31m at <unknown> (Error: ./src/app/calibration/page.tsx:138:1)\n at <unknown> (Error: (./src/app/calibration/page.tsx:138:1)\u001b[39m"}
{"timestamp":"120:22:31.923","source":"Server","level":"ERROR","message":"[browser] \"./src/app/calibration/page.tsx:138:1\\nExpected '</', got '<eof>'\\n 136 | </div>\\n 137 | </div>\\n> 138 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:22:31.923","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component] \"\""}
{"timestamp":"120:22:31.924","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]"}
{"timestamp":"120:22:31.954","source":"Server","level":"ERROR","message":"[browser] \"./src/app/calibration/page.tsx:138:1\\nExpected '</', got '<eof>'\\n 136 | </div>\\n 137 | </div>\\n> 138 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:22:31.955","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component] \"\""}
{"timestamp":"120:22:31.955","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:138:1\nExpected '</', got '<eof>'\n 136 | </div>\n 137 | </div>\n> 138 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]"}
{"timestamp":"120:22:55.601","source":"Server","level":"ERROR","message":"[browser] \"./src/app/calibration/page.tsx:192:1\\nExpected '</', got '<eof>'\\n 190 | </div>\\n 191 | </div>\\n> 192 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/calibration/page.tsx [Client Component Browser]\\n ./src/app/calibration/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/calibration/page.tsx [Client Component SSR]\\n ./src/app/calibration/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:22:55.601","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:192:1\nExpected '</', got '<eof>'\n 190 | </div>\n 191 | </div>\n> 192 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component] \"\""}
{"timestamp":"120:22:55.602","source":"Browser","level":"ERROR","message":"./src/app/calibration/page.tsx:192:1\nExpected '</', got '<eof>'\n 190 | </div>\n 191 | </div>\n> 192 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/calibration/page.tsx [Client Component Browser]\n ./src/app/calibration/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/calibration/page.tsx [Client Component SSR]\n ./src/app/calibration/page.tsx [Server Component]"}
{"timestamp":"120:23:28.321","source":"Server","level":"LOG","message":"✓ Compiled in 65ms"}
{"timestamp":"120:23:29.053","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:24:00.938","source":"Server","level":"LOG","message":"✓ Compiled in 76ms"}
{"timestamp":"120:24:01.199","source":"Browser","level":"ERROR","message":"uncaughtError: TypeError: Cannot read properties of undefined (reading 'map')"}
{"timestamp":"120:24:01.332","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught TypeError: Cannot read properties of undefined (reading 'map')\\u001b[39m\\n\\u001b[31m at eval (src/app/components/sidebar.tsx:36:24)\\n at Array.map (<anonymous>)\\n at Sidebar (src/app/components/sidebar.tsx:33:19)\\n at RootLayout (src\\\\app\\\\layout.tsx:14:11)\\u001b[39m\\n \\u001b[90m34 |\\u001b[0m <div key={group.title}>\\n \\u001b[90m35 |\\u001b[0m <div className=\\u001b[32m\\\"px-4 py-2.5 text-xs font-semibold uppercase tracking-wider\\\"\\u001b[0m style={{ color: \\u001b[32m'rgba(0,0,0,0.35..\\u001b[0m.\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m36 |\\u001b[0m {group.items.map(item => {\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m37 |\\u001b[0m \\u001b[36mconst\\u001b[0m \\u001b[33mIcon\\u001b[0m = item.icon\\n \\u001b[90m38 |\\u001b[0m \\u001b[36mreturn\\u001b[0m (\\n \\u001b[90m39 |\\u001b[0m <\\u001b[33mLink\\u001b[0m key={item.path} href={item.path} className=\\u001b[32m\\\"flex items-center gap-2 px-4 py-2 text-sm transition-c...\\u001b[0m\""}
{"timestamp":"120:24:01.334","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught TypeError: Cannot read properties of undefined (reading 'map')\u001b[39m\n\u001b[31m at eval (src/app/components/sidebar.tsx:36:24)\n at Array.map (<anonymous>)\n at Sidebar (src/app/components/sidebar.tsx:33:19)\n at RootLayout (src\\app\\layout.tsx:14:11)\u001b[39m\n \u001b[90m34 |\u001b[0m <div key={group.title}>\n \u001b[90m35 |\u001b[0m <div className=\u001b[32m\"px-4 py-2.5 text-xs font-semibold uppercase tracking-wider\"\u001b[0m style={{ color: \u001b[32m'rgba(0,0,0,0.35..\u001b[0m.\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m36 |\u001b[0m {group.items.map(item => {\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m37 |\u001b[0m \u001b[36mconst\u001b[0m \u001b[33mIcon\u001b[0m = item.icon\n \u001b[90m38 |\u001b[0m \u001b[36mreturn\u001b[0m (\n \u001b[90m39 |\u001b[0m <\u001b[33mLink\u001b[0m key={item.path} href={item.path} className=\u001b[32m\"flex items-center gap-2 px-4 py-2 text-sm transition-c...\u001b[0m"}
{"timestamp":"120:24:25.345","source":"Server","level":"LOG","message":"✓ Compiled in 49ms"}
{"timestamp":"120:24:25.347","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
{"timestamp":"120:24:25.908","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:26:49.542","source":"Server","level":"LOG","message":"✓ Compiled in 70ms"}
{"timestamp":"120:31:09.492","source":"Browser","level":"ERROR","message":"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."}
{"timestamp":"120:31:09.494","source":"Server","level":"ERROR","message":"[browser] \"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.\" \"\""}
{"timestamp":"120:31:09.494","source":"Browser","level":"ERROR","message":"You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. \"\""}
{"timestamp":"120:31:53.655","source":"Server","level":"LOG","message":"✓ Compiled in 26ms"}
{"timestamp":"120:33:07.451","source":"Server","level":"LOG","message":"✓ Compiled in 31ms"}
{"timestamp":"120:33:49.615","source":"Server","level":"LOG","message":"✓ Compiled in 35ms"}
{"timestamp":"120:33:49.785","source":"Browser","level":"ERROR","message":"A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components"}
{"timestamp":"120:33:49.807","source":"Server","level":"ERROR","message":"[browser] \"A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components\" \"\""}
{"timestamp":"120:33:49.808","source":"Browser","level":"ERROR","message":"A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components \"\""}
{"timestamp":"120:33:54.669","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:35:37.666","source":"Server","level":"LOG","message":"✓ Compiled in 37ms"}
{"timestamp":"120:37:14.496","source":"Server","level":"LOG","message":"✓ Compiled in 28ms"}
{"timestamp":"120:37:47.169","source":"Server","level":"LOG","message":"✓ Compiled in 30ms"}
{"timestamp":"120:37:53.739","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:38:48.262","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:39:20.372","source":"Server","level":"LOG","message":"✓ Compiled in 44ms"}
{"timestamp":"120:40:15.243","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:41:21.738","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:22.130","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:22.971","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:23.735","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:24.485","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:24.972","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:25.720","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:26.046","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:26.173","source":"Browser","level":"ERROR","message":"uncaughtError: Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:26.185","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: The default export is not a React Component in \\\"/devices/[sn]/page\\\"\\u001b[39m\""}
{"timestamp":"120:41:26.186","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: The default export is not a React Component in \"/devices/[sn]/page\"\u001b[39m"}
{"timestamp":"120:41:58.004","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:58.319","source":"Server","level":"ERROR","message":" Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:58.527","source":"Browser","level":"ERROR","message":"uncaughtError: Error: The default export is not a React Component in \"/devices/[sn]/page\""}
{"timestamp":"120:41:58.528","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: The default export is not a React Component in \\\"/devices/[sn]/page\\\"\\u001b[39m\""}
{"timestamp":"120:41:58.529","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: The default export is not a React Component in \"/devices/[sn]/page\"\u001b[39m"}
{"timestamp":"120:42:20.220","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:42:20.228","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:42:20.230","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:20.233","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:20.368","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:42:20.368","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:20.368","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:20.370","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:42:20.371","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:20.371","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:20.381","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:42:20.499","source":"Browser","level":"ERROR","message":"uncaughtError: Error: ./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:42:20.504","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: ./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n\\u001b[39m\\n\\u001b[31m at <unknown> (Error: ./src/app/devices/[sn]/page.tsx:154:1)\\n at <unknown> (Error: (./src/app/devices/[sn]/page.tsx:154:1)\\u001b[39m\""}
{"timestamp":"120:42:20.505","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: ./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n\u001b[39m\n\u001b[31m at <unknown> (Error: ./src/app/devices/[sn]/page.tsx:154:1)\n at <unknown> (Error: (./src/app/devices/[sn]/page.tsx:154:1)\u001b[39m"}
{"timestamp":"120:42:20.519","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:20.519","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:20.520","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:42:20.587","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:154:1\\nExpected '</', got '<eof>'\\n 152 | </div>\\n 153 | </div>\\n> 154 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:20.588","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:20.590","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:154:1\nExpected '</', got '<eof>'\n 152 | </div>\n 153 | </div>\n> 154 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:42:40.514","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:199:1\\nExpected '</', got '<eof>'\\n 197 | ))}\\n 198 | </div>\\n> 199 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:42:40.515","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:199:1\nExpected '</', got '<eof>'\n 197 | ))}\n 198 | </div>\n> 199 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:42:40.515","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:199:1\nExpected '</', got '<eof>'\n 197 | ))}\n 198 | </div>\n> 199 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:43:14.065","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:269:1\\nExpected '</', got '<eof>'\\n 267 | </div>\\n 268 | )}\\n> 269 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:43:14.065","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:269:1\nExpected '</', got '<eof>'\n 267 | </div>\n 268 | )}\n> 269 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:43:14.066","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:269:1\nExpected '</', got '<eof>'\n 267 | </div>\n 268 | )}\n> 269 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:43:45.661","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:328:1\\nExpected '</', got '<eof>'\\n 326 | </div>\\n 327 | )}\\n> 328 |\\n | ^\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:43:45.663","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:328:1\nExpected '</', got '<eof>'\n 326 | </div>\n 327 | )}\n> 328 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:43:45.665","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:328:1\nExpected '</', got '<eof>'\n 326 | </div>\n 327 | )}\n> 328 |\n | ^\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:44:03.932","source":"Server","level":"LOG","message":"✓ Compiled in 145ms"}
{"timestamp":"120:44:04.986","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:45:42.964","source":"Server","level":"LOG","message":"✓ Compiled in 38ms"}
{"timestamp":"120:48:15.982","source":"Server","level":"LOG","message":"✓ Compiled in 40ms"}
{"timestamp":"120:52:11.129","source":"Server","level":"LOG","message":"✓ Compiled in 36ms"}
{"timestamp":"120:53:27.719","source":"Server","level":"LOG","message":"✓ Compiled in 43ms"}
{"timestamp":"120:53:57.768","source":"Server","level":"LOG","message":"✓ Compiled in 57ms"}
{"timestamp":"120:54:02.970","source":"Server","level":"LOG","message":"✓ Compiled in 36ms"}
{"timestamp":"120:54:14.218","source":"Server","level":"LOG","message":"✓ Compiled in 30ms"}
{"timestamp":"120:54:30.156","source":"Server","level":"LOG","message":"✓ Compiled in 33ms"}
{"timestamp":"120:54:51.314","source":"Server","level":"LOG","message":"✓ Compiled in 28ms"}
{"timestamp":"120:54:59.763","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
{"timestamp":"120:55:14.825","source":"Server","level":"LOG","message":"✓ Compiled in 44ms"}
{"timestamp":"120:58:05.296","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
{"timestamp":"120:58:05.328","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:58:05.369","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.369","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.381","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.393","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:58:05.418","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.419","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:05.422","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.423","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:05.425","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.426","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:05.447","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.449","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.450","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:05.456","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:58:05.470","source":"Server","level":"ERROR","message":" ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:58:05.773","source":"Browser","level":"ERROR","message":"uncaughtError: Error: ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n"}
{"timestamp":"120:58:05.785","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: ./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n\\u001b[39m\\n\\u001b[31m at <unknown> (Error: ./src/app/devices/[sn]/page.tsx:179:7)\\n at <unknown> (Error: (./src/app/devices/[sn]/page.tsx:179:7)\\u001b[39m\""}
{"timestamp":"120:58:05.787","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: ./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n\u001b[39m\n\u001b[31m at <unknown> (Error: ./src/app/devices/[sn]/page.tsx:179:7)\n at <unknown> (Error: (./src/app/devices/[sn]/page.tsx:179:7)\u001b[39m"}
{"timestamp":"120:58:05.802","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.804","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:05.805","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.911","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component]"}
{"timestamp":"120:58:05.912","source":"Server","level":"ERROR","message":"[browser] \"./src/app/devices/[sn]/page.tsx:179:7\\nExpected ',', got '{'\\n 177 | </div>\\n 178 |\\n> 179 | {/* Tabs */}\\n | ^\\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\\n 181 | {tabs.map(tab => (\\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\\n ./src/app/devices/[sn]/page.tsx [Server Component]\" \"\""}
{"timestamp":"120:58:05.912","source":"Browser","level":"ERROR","message":"./src/app/devices/[sn]/page.tsx:179:7\nExpected ',', got '{'\n 177 | </div>\n 178 |\n> 179 | {/* Tabs */}\n | ^\n 180 | <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #F0F0F0', marginBottom: 24 }}>\n 181 | {tabs.map(tab => (\n 182 | <button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/devices/[sn]/page.tsx [Client Component Browser]\n ./src/app/devices/[sn]/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/devices/[sn]/page.tsx [Client Component SSR]\n ./src/app/devices/[sn]/page.tsx [Server Component] \"\""}
{"timestamp":"120:58:20.378","source":"Server","level":"LOG","message":"✓ Compiled in 112ms"}
{"timestamp":"120:58:21.227","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"120:58:30.947","source":"Server","level":"LOG","message":"✓ Compiled in 33ms"}
{"timestamp":"121:35:19.134","source":"Server","level":"LOG","message":"✓ Compiled in 54ms"}
{"timestamp":"121:35:26.186","source":"Server","level":"LOG","message":"✓ Compiled in 92ms"}
{"timestamp":"136:35:03.263","source":"Server","level":"LOG","message":"✓ Compiled in 34ms"}
{"timestamp":"136:38:00.817","source":"Server","level":"LOG","message":"✓ Compiled in 52ms"}
{"timestamp":"136:40:21.076","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"136:43:58.112","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"136:47:36.538","source":"Server","level":"LOG","message":"✓ Compiled in 39ms"}
{"timestamp":"136:49:42.435","source":"Server","level":"LOG","message":"✓ Compiled in 58ms"}
{"timestamp":"136:50:09.938","source":"Server","level":"LOG","message":"✓ Compiled in 45ms"}
{"timestamp":"136:50:19.856","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"136:51:19.379","source":"Server","level":"LOG","message":"✓ Compiled in 32ms"}
{"timestamp":"136:51:32.486","source":"Server","level":"LOG","message":"✓ Compiled in 37ms"}
{"timestamp":"136:56:56.629","source":"Server","level":"LOG","message":"✓ Compiled in 29ms"}
{"timestamp":"136:57:06.457","source":"Server","level":"LOG","message":"✓ Compiled in 56ms"}
{"timestamp":"136:57:17.332","source":"Server","level":"LOG","message":"✓ Compiled in 28ms"}
{"timestamp":"144:03:02.593","source":"Server","level":"LOG","message":"✓ Compiled in 33ms"}
{"timestamp":"144:03:05.593","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
{"timestamp":"144:03:35.022","source":"Server","level":"LOG","message":"✓ Compiled in 29ms"}
{"timestamp":"144:03:51.002","source":"Server","level":"LOG","message":"✓ Compiled in 28ms"}
{"timestamp":"144:04:23.708","source":"Server","level":"LOG","message":"✓ Compiled in 28ms"}

View File

@ -4,8 +4,8 @@
"dynamicRoutes": {}, "dynamicRoutes": {},
"notFoundRoutes": [], "notFoundRoutes": [],
"preview": { "preview": {
"previewModeId": "2925825860b67ed4c439317f2cac20b3", "previewModeId": "1947d233f6bd907465d44e9106e1529f",
"previewModeSigningKey": "7937a0b3e643f2be8042280f66ac7478a5b4aef1395c235ab35c1995bd204a8c", "previewModeSigningKey": "19d915bf45327c04933031dcd1bf3dbb51437d192bd72e75782313759599b542",
"previewModeEncryptionKey": "22c27792d58e7d752d9b9deffc5d8cdab0fcf88e7274087d3703af1d2f4c5eb3" "previewModeEncryptionKey": "7b1b69cc42bec06b4a05f44a9eaed9da30d6309359a920dd5e4d809ba31844c1"
} }
} }

View File

@ -1,8 +1,7 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/board-cards/page": "app/board-cards/page.js",
"/board-cards/register/page": "app/board-cards/register/page.js",
"/boards/page": "app/boards/page.js", "/boards/page": "app/boards/page.js",
"/calibration/page": "app/calibration/page.js",
"/calibration/register/page": "app/calibration/register/page.js",
"/config-files/page": "app/config-files/page.js", "/config-files/page": "app/config-files/page.js",
"/devices/[sn]/page": "app/devices/[sn]/page.js", "/devices/[sn]/page": "app/devices/[sn]/page.js",
"/devices/page": "app/devices/page.js", "/devices/page": "app/devices/page.js",
@ -11,6 +10,5 @@
"/models/page": "app/models/page.js", "/models/page": "app/models/page.js",
"/page": "app/page.js", "/page": "app/page.js",
"/registration/page": "app/registration/page.js", "/registration/page": "app/registration/page.js",
"/repair/page": "app/repair/page.js", "/repair/page": "app/repair/page.js"
"/scrap/page": "app/scrap/page.js"
} }

File diff suppressed because one or more lines are too long

View File

@ -1,30 +1,6 @@
globalThis.__BUILD_MANIFEST = { globalThis.__BUILD_MANIFEST = {
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
"static/chunks/node_modules_next_app_0jt-zj..js",
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__0c0okpg._.js",
"static/chunks/pages__app_07xvfw~._.js",
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
],
"/_error": [
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
"static/chunks/node_modules_react-dom_0bruynb._.js",
"static/chunks/node_modules_0lx093h._.js",
"static/chunks/[root-of-the-server]__01mw43t._.js",
"static/chunks/pages__error_07xvfw~._.js",
"static/chunks/turbopack-pages__error_016chbq._.js"
]
}, },
"devFiles": [], "devFiles": [],
"polyfillFiles": [ "polyfillFiles": [

View File

@ -1,5 +1 @@
{ {}
"/_app": "pages/_app.js",
"/_document": "pages/_document.js",
"/_error": "pages/_error.js"
}

View File

@ -1,7 +1,4 @@
self.__BUILD_MANIFEST = { self.__BUILD_MANIFEST = {
"/_error": [
"static/chunks/pages/_error.js"
],
"__rewrites": { "__rewrites": {
"afterFiles": [], "afterFiles": [],
"beforeFiles": [], "beforeFiles": [],

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
// This file is generated automatically by Next.js // This file is generated automatically by Next.js
// Do not edit this file manually // Do not edit this file manually
type AppRoutes = "/" | "/boards" | "/calibration" | "/calibration/register" | "/config-files" | "/devices" | "/devices/[sn]" | "/firmware" | "/licenses" | "/models" | "/registration" | "/repair" | "/scrap" type AppRoutes = "/" | "/board-cards" | "/board-cards/register" | "/boards" | "/config-files" | "/devices" | "/devices/[sn]" | "/firmware" | "/licenses" | "/models" | "/registration" | "/repair" | "/scrap"
type PageRoutes = never type PageRoutes = never
type LayoutRoutes = "/" type LayoutRoutes = "/"
type RedirectRoutes = never type RedirectRoutes = never
@ -11,9 +11,9 @@ type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRo
interface ParamMap { interface ParamMap {
"/": {} "/": {}
"/board-cards": {}
"/board-cards/register": {}
"/boards": {} "/boards": {}
"/calibration": {}
"/calibration/register": {}
"/config-files": {} "/config-files": {}
"/devices": {} "/devices": {}
"/devices/[sn]": { "sn": string; } "/devices/[sn]": { "sn": string; }

View File

@ -36,6 +36,24 @@ type LayoutConfig<Route extends LayoutRoutes = LayoutRoutes> = {
} }
// Validate ../../../src/app/board-cards/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/board-cards">> = Specific
const handler = {} as typeof import("../../../src/app/board-cards/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../src/app/board-cards/register/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/board-cards/register">> = Specific
const handler = {} as typeof import("../../../src/app/board-cards/register/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../src/app/boards/page.tsx // Validate ../../../src/app/boards/page.tsx
{ {
type __IsExpected<Specific extends AppPageConfig<"/boards">> = Specific type __IsExpected<Specific extends AppPageConfig<"/boards">> = Specific
@ -45,24 +63,6 @@ type LayoutConfig<Route extends LayoutRoutes = LayoutRoutes> = {
type __Unused = __Check type __Unused = __Check
} }
// Validate ../../../src/app/calibration/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/calibration">> = Specific
const handler = {} as typeof import("../../../src/app/calibration/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../src/app/calibration/register/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/calibration/register">> = Specific
const handler = {} as typeof import("../../../src/app/calibration/register/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../src/app/config-files/page.tsx // Validate ../../../src/app/config-files/page.tsx
{ {
type __IsExpected<Specific extends AppPageConfig<"/config-files">> = Specific type __IsExpected<Specific extends AppPageConfig<"/config-files">> = Specific

View File

@ -1,29 +1,66 @@
'use client' 'use client'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Search, ChevronLeft, ChevronRight, Plus, Download, Info, Eye, X, Tag } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus, Download, Eye, X, FileText, Download as DownloadIcon } from 'lucide-react'
const boardCardsData = [ const boardCardsData = [
{ id: 1, sn: 'MB25011500', type: '主协板', model: 'MB-V2.1', firmware: 'v2.1', status: '在库', deviceSn: '-', productionDate: '2025-01-15', calibStatus: '-', calibDate: '-'}, { id: 1, sn: 'MB25011500', type: '主协板', version: 'MB-V2.1', firmware: 'v2.1', status: '在库', deviceSn: '-', productionDate: '2025-01-15', calibStatus: '-', calibDate: '-'},
{ id: 2, sn: 'MCB-3000-20250118002', type: '主协板', model: 'MCB-3000', firmware: 'v2.1.0', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-18', calibStatus: '-', calibDate: '-', }, { id: 2, sn: 'MCB-3000-20250118002', type: '主协板', version: 'MCB-3000', firmware: 'v2.1.0', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-18', calibStatus: '-', calibDate: '-', },
{ id: 3, sn: 'ACB-6000-20250110001', type: '采集板', model: 'ACB-6000', firmware: 'v3.0.2', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-10', calibStatus: '合格', calibDate: '2025-01-12'}, { id: 3, sn: 'ACB-6000-20250110001', type: '采集板', version: 'ACB-6000', firmware: 'v3.0.2', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-10', calibStatus: '合格', calibDate: '2025-01-12'},
{ id: 4, sn: 'ACB-6000-20250110002', type: '采集板', model: 'ACB-6000', firmware: 'v3.0.2', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-10', calibStatus: '合格', calibDate: '2025-01-12'}, { id: 4, sn: 'ACB-6000-20250110002', type: '采集板', version: 'ACB-6000', firmware: 'v3.0.2', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-10', calibStatus: '合格', calibDate: '2025-01-12'},
{ id: 5, sn: 'ACB-6000-20250112003', type: '采集板', model: 'ACB-6000', firmware: 'v3.0.2', status: '在库', deviceSn: '-', productionDate: '2025-01-12', calibStatus: '待校准', calibDate: '-', }, { id: 5, sn: 'ACB-6000-20250112003', type: '采集板', version: 'ACB-6000', firmware: 'v3.0.2', status: '在库', deviceSn: '-', productionDate: '2025-01-12', calibStatus: '待校准', calibDate: '-', },
{ id: 6, sn: 'ACB-5000-20241205001', type: '采集板', model: 'ACB-5000', firmware: 'v2.5.1', status: '已装配', deviceSn: 'GD20-2024-000045', productionDate: '2024-12-05', calibStatus: '合格', calibDate: '2024-12-08'}, { id: 6, sn: 'ACB-5000-20241205001', type: '采集板', version: 'ACB-5000', firmware: 'v2.5.1', status: '已装配', deviceSn: 'GD20-2024-000045', productionDate: '2024-12-05', calibStatus: '合格', calibDate: '2024-12-08'},
{ id: 7, sn: 'TXB-1000-20250120001', type: '发射板', model: 'TXB-1000', firmware: 'v1.2.0', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-20', calibStatus: '-', calibDate: '-'}, { id: 7, sn: 'TXB-1000-20250120001', type: '发射板', version: 'TXB-1000', firmware: 'v1.2.0', status: '已装配', deviceSn: 'GD30-2025-000001', productionDate: '2025-01-20', calibStatus: '-', calibDate: '-'},
{ id: 8, sn: 'TXB-1000-20250122002', type: '发射板', model: 'TXB-1000', firmware: 'v1.2.0', status: '在库', deviceSn: '-', productionDate: '2025-01-22', calibStatus: '-', calibDate: '-'}, { id: 8, sn: 'TXB-1000-20250122002', type: '发射板', version: 'TXB-1000', firmware: 'v1.2.0', status: '在库', deviceSn: '-', productionDate: '2025-01-22', calibStatus: '-', calibDate: '-'},
{ id: 9, sn: 'BST-500-20250201001', type: '升压板', model: 'BST-500', firmware: 'v1.1.0', status: '已装配', deviceSn: 'GD30-2025-000002', productionDate: '2025-02-01', calibStatus: '-', calibDate: '-'}, { id: 9, sn: 'BST-500-20250201001', type: '升压板', version: 'BST-500', firmware: 'v1.1.0', status: '已装配', deviceSn: 'GD30-2025-000002', productionDate: '2025-02-01', calibStatus: '-', calibDate: '-'},
{ id: 10, sn: 'BST-500-20250203002', type: '升压板', model: 'BST-500', firmware: 'v1.1.0', status: '在库', deviceSn: '-', productionDate: '2025-02-03', calibStatus: '-', calibDate: '-'}, { id: 10, sn: 'BST-500-20250203002', type: '升压板', version: 'BST-500', firmware: 'v1.1.0', status: '在库', deviceSn: '-', productionDate: '2025-02-03', calibStatus: '-', calibDate: '-'},
{ id: 11, sn: 'ACB-6000-20241120001', type: '采集板', model: 'ACB-6000', firmware: 'v3.0.2', status: '故障', deviceSn: '-', productionDate: '2024-11-20', calibStatus: '不合格', calibDate: '2025-02-10'}, { id: 11, sn: 'ACB-6000-20241120001', type: '采集板', version: 'ACB-6000', firmware: 'v3.0.2', status: '故障', deviceSn: '-', productionDate: '2024-11-20', calibStatus: '不合格', calibDate: '2025-02-10'},
{ id: 12, sn: 'MCB-2000-20240915001', type: '主协板', model: 'MCB-2000', firmware: 'v1.8.5', status: '已装配', deviceSn: 'GD20-2024-000046', productionDate: '2024-09-15', calibStatus: '-', calibDate: '-'}, { id: 12, sn: 'MCB-2000-20240915001', type: '主协板', version: 'MCB-2000', firmware: 'v1.8.5', status: '已装配', deviceSn: 'GD20-2024-000046', productionDate: '2024-09-15', calibStatus: '-', calibDate: '-'},
{ id: 13, sn: 'ACB-6000-20250305001', type: '采集板', model: 'ACB-6000', firmware: 'v3.0.2', status: '在库', deviceSn: '-', productionDate: '2025-03-05', calibStatus: '待校准', calibDate: '-'}, { id: 13, sn: 'ACB-6000-20250305001', type: '采集板', version: 'ACB-6000', firmware: 'v3.0.2', status: '在库', deviceSn: '-', productionDate: '2025-03-05', calibStatus: '待校准', calibDate: '-'},
{ id: 14, sn: 'TXB-800-20240610001', type: '发射板', model: 'TXB-800', firmware: 'v1.0.3', status: '报废', deviceSn: '-', productionDate: '2024-06-10', calibStatus: '-', calibDate: '-'}, { id: 14, sn: 'TXB-800-20240610001', type: '发射板', version: 'TXB-800', firmware: 'v1.0.3', status: '报废', deviceSn: '-', productionDate: '2024-06-10', calibStatus: '-', calibDate: '-'},
] ]
const typeOptions = ['全部', '主协板', '采集板', '发射板', '升压板'] const typeOptions = ['全部', '主协板', '采集板', '发射板', '升压板']
const statusOptions = ['全部', '在库', '已装配', '故障', '报废'] const statusOptions = ['全部', '在库', '已装配', '故障', '报废']
const calibStatusOptions = ['全部', '合格', '不合格', '待校准'] const calibStatusOptions = ['全部', '合格', '不合格', '待校准']
/** 模拟校准文件数据key 为板卡 SN */
const calibrationFilesMap: Record<string, { id: string; fileName: string; fileSize: number; md5: string; uploadTime: string; channels: { channel: string; refValue: number; measuredValue: number; deviation: number; result: string }[] }[]> = {
'ACB-6000-20250110001': [
{ id: 'cf-1', fileName: 'ACB-6000-20250110001_calib_20250112.csv', fileSize: 24576, md5: 'a1b2c3d4e5f6', uploadTime: '2025-01-12 14:30:00', channels: [
{ channel: 'CH1', refValue: 100.0, measuredValue: 99.8, deviation: -0.2, result: '合格' },
{ channel: 'CH2', refValue: 200.0, measuredValue: 200.3, deviation: 0.15, result: '合格' },
{ channel: 'CH3', refValue: 500.0, measuredValue: 499.5, deviation: -0.1, result: '合格' },
{ channel: 'CH4', refValue: 1000.0, measuredValue: 999.2, deviation: -0.08, result: '合格' },
]},
],
'ACB-6000-20250110002': [
{ id: 'cf-2', fileName: 'ACB-6000-20250110002_calib_20250112.csv', fileSize: 23040, md5: 'b2c3d4e5f6a1', uploadTime: '2025-01-12 15:10:00', channels: [
{ channel: 'CH1', refValue: 100.0, measuredValue: 100.1, deviation: 0.1, result: '合格' },
{ channel: 'CH2', refValue: 200.0, measuredValue: 199.7, deviation: -0.15, result: '合格' },
{ channel: 'CH3', refValue: 500.0, measuredValue: 500.8, deviation: 0.16, result: '合格' },
]},
],
'ACB-5000-20241205001': [
{ id: 'cf-3', fileName: 'ACB-5000-20241205001_calib_20241208.csv', fileSize: 18432, md5: 'c3d4e5f6a1b2', uploadTime: '2024-12-08 09:45:00', channels: [
{ channel: 'CH1', refValue: 100.0, measuredValue: 99.9, deviation: -0.1, result: '合格' },
{ channel: 'CH2', refValue: 200.0, measuredValue: 200.5, deviation: 0.25, result: '合格' },
]},
],
'ACB-6000-20241120001': [
{ id: 'cf-4', fileName: 'ACB-6000-20241120001_calib_20250210.csv', fileSize: 25600, md5: 'd4e5f6a1b2c3', uploadTime: '2025-02-10 11:20:00', channels: [
{ channel: 'CH1', refValue: 100.0, measuredValue: 98.2, deviation: -1.8, result: '不合格' },
{ channel: 'CH2', refValue: 200.0, measuredValue: 203.6, deviation: 1.8, result: '不合格' },
{ channel: 'CH3', refValue: 500.0, measuredValue: 500.1, deviation: 0.02, result: '合格' },
]},
],
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
return (bytes / 1024).toFixed(1) + ' KB'
}
function getStatusStyle(status: string) { function getStatusStyle(status: string) {
switch (status) { switch (status) {
case '在库': return { backgroundColor: '#E6F7FF', color: '#1890FF', border: '1px solid #91D5FF' } case '在库': return { backgroundColor: '#E6F7FF', color: '#1890FF', border: '1px solid #91D5FF' }
@ -50,6 +87,7 @@ export default function BoardCardsPage() {
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [detailDrawer, setDetailDrawer] = useState<typeof boardCardsData[0] | null>(null) const [detailDrawer, setDetailDrawer] = useState<typeof boardCardsData[0] | null>(null)
const [calibFileDrawer, setCalibFileDrawer] = useState<typeof boardCardsData[0] | null>(null)
const pageSize = 8 const pageSize = 8
const filtered = useMemo(() => boardCardsData.filter(b => { const filtered = useMemo(() => boardCardsData.filter(b => {
@ -84,7 +122,7 @@ export default function BoardCardsPage() {
<button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}> <button style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>
<Download size={16} /> <Download size={16} />
</button> </button>
<Link href="/calibration/register" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14, textDecoration: 'none' }}> <Link href="/board-cards/register" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14, textDecoration: 'none' }}>
<Plus size={16} /> <Plus size={16} />
</Link> </Link>
</div> </div>
@ -139,7 +177,7 @@ export default function BoardCardsPage() {
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ backgroundColor: '#FAFAFA' }}> <tr style={{ backgroundColor: '#FAFAFA' }}>
{['板卡SN', '类型', '型号', '固件', '状态', '所属设备', '校准状态', '操作'].map(h => ( {['板卡SN', '类型', '版本', '固件', '状态', '所属设备', '校准状态', '操作'].map(h => (
<th key={h} style={{ padding: '12px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0', whiteSpace: 'nowrap' }}>{h}</th> <th key={h} style={{ padding: '12px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0', whiteSpace: 'nowrap' }}>{h}</th>
))} ))}
</tr> </tr>
@ -149,7 +187,7 @@ export default function BoardCardsPage() {
<tr key={row.id} style={{ borderBottom: '1px solid #F0F0F0' }}> <tr key={row.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '12px 14px', fontSize: 13, fontWeight: 500 }}>{row.sn}</td> <td style={{ padding: '12px 14px', fontSize: 13, fontWeight: 500 }}>{row.sn}</td>
<td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.type}</td> <td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.type}</td>
<td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.model}</td> <td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.version}</td>
<td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.firmware}</td> <td style={{ padding: '12px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{row.firmware}</td>
<td style={{ padding: '12px 14px' }}> <td style={{ padding: '12px 14px' }}>
<span style={{ ...getStatusStyle(row.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{row.status}</span> <span style={{ ...getStatusStyle(row.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{row.status}</span>
@ -163,9 +201,16 @@ export default function BoardCardsPage() {
)} )}
</td> </td>
<td style={{ padding: '12px 14px' }}> <td style={{ padding: '12px 14px' }}>
<button onClick={() => setDetailDrawer(row)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', gap: 12 }}>
<Eye size={14} /> <button onClick={() => setDetailDrawer(row)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}>
</button> <Eye size={14} />
</button>
{row.type === '采集板' && (
<button onClick={() => setCalibFileDrawer(row)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}>
<FileText size={14} />
</button>
)}
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -203,7 +248,7 @@ export default function BoardCardsPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13 }}>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>SN</span>{detailDrawer.sn}</div> <div><span style={{ color: 'rgba(0,0,0,0.45)' }}>SN</span>{detailDrawer.sn}</div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.type}</div> <div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.type}</div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.model}</div> <div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.version}</div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.firmware}</div> <div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.firmware}</div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.productionDate}</div> <div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{detailDrawer.productionDate}</div>
<div> <div>
@ -250,6 +295,82 @@ export default function BoardCardsPage() {
</div> </div>
</div> </div>
)} )}
{/* Calibration File Drawer */}
{calibFileDrawer && (() => {
const files = calibrationFilesMap[calibFileDrawer.sn] || []
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50 }}>
<div onClick={() => setCalibFileDrawer(null)} style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.45)' }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 640, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}> - {calibFileDrawer.sn}</h3>
<button onClick={() => setCalibFileDrawer(null)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
{files.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0', color: 'rgba(0,0,0,0.25)' }}>
<FileText size={40} style={{ marginBottom: 12, opacity: 0.3 }} />
<div style={{ fontSize: 14 }}></div>
</div>
) : (
files.map(file => (
<div key={file.id} style={{ marginBottom: 20 }}>
{/* 文件信息卡片 */}
<div style={{ padding: 16, backgroundColor: '#FAFAFA', borderRadius: 8, border: '1px solid #F0F0F0', marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={16} style={{ color: '#4a7c59' }} />
<span style={{ fontSize: 14, fontWeight: 500 }}>{file.fileName}</span>
</div>
<button style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px', border: '1px solid #D9D9D9', borderRadius: 4, backgroundColor: '#fff', cursor: 'pointer', fontSize: 12, color: '#4a7c59' }}>
<Download size={12} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{formatFileSize(file.fileSize)}</div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>MD5</span><span style={{ fontFamily: 'monospace' }}>{file.md5}</span></div>
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}></span>{file.uploadTime}</div>
</div>
</div>
{/* 校准数据内容 */}
<div style={{ borderRadius: 8, border: '1px solid #F0F0F0', overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', backgroundColor: '#eef5f0', fontSize: 13, fontWeight: 600, color: '#4a7c59' }}></div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#FAFAFA' }}>
{['通道', '参考值', '测量值', '偏差(%)', '结果'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontSize: 12, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{file.channels.map(ch => (
<tr key={ch.channel} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '8px 12px', fontSize: 12, fontWeight: 500 }}>{ch.channel}</td>
<td style={{ padding: '8px 12px', fontSize: 12, fontFamily: 'monospace' }}>{ch.refValue.toFixed(1)}</td>
<td style={{ padding: '8px 12px', fontSize: 12, fontFamily: 'monospace' }}>{ch.measuredValue.toFixed(1)}</td>
<td style={{ padding: '8px 12px', fontSize: 12, fontFamily: 'monospace', color: Math.abs(ch.deviation) > 1 ? '#FF4D4F' : 'rgba(0,0,0,0.65)' }}>{ch.deviation > 0 ? '+' : ''}{ch.deviation.toFixed(2)}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ padding: '1px 6px', borderRadius: 4, fontSize: 11, ...(ch.result === '合格' ? { backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' } : { backgroundColor: '#FFF1F0', color: '#FF4D4F', border: '1px solid #FFCCC7' }) }}>{ch.result}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
)}
</div>
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => setCalibFileDrawer(null)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}></button>
</div>
</div>
</div>
)
})()}
</div> </div>
) )
} }

View File

@ -1,38 +1,40 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { ArrowLeft, Plus, Trash2, Upload, Info, CheckCircle } from 'lucide-react' import { useRef } from 'react'
import { ArrowLeft, Plus, Trash2, Upload, Info, CheckCircle, FileText, X } from 'lucide-react'
/** 板卡类型 -> 可选型号 */ /** 板卡类型 -> 可选版本 */
const modelsByType: Record<string, { model: string; firmware: string }[]> = { const versionsByType: Record<string, { version: string; firmware: string }[]> = {
'主协板': [ '主协板': [
{ model: 'MB-V1.2', firmware: 'v2.1' }, { version: 'MB-V1.2', firmware: 'v2.1' },
{ model: 'MB-V2.1', firmware: 'v1.8' }, { version: 'MB-V2.1', firmware: 'v1.8' },
], ],
'采集板': [ '采集板': [
{ model: 'RX-V1.3', firmware: 'v3.0' }, { version: 'RX-V1.3', firmware: 'v3.0' },
{ model: 'RX-V2.1', firmware: 'v2.5' }, { version: 'RX-V2.1', firmware: 'v2.5' },
], ],
'发射板': [ '发射板': [
{ model: 'TX-V1.5', firmware: 'v1.2' }, { version: 'TX-V1.5', firmware: 'v1.2' },
{ model: 'TX-V2.1', firmware: 'v1.0' }, { version: 'TX-V2.1', firmware: 'v1.0' },
], ],
'升压板': [ '升压板': [
{ model: 'BO-V2.1', firmware: 'v1.1' }, { version: 'BO-V2.1', firmware: 'v1.1' },
{ model: 'BO-V2.2', firmware: 'v0.9' }, { version: 'BO-V2.2', firmware: 'v0.9' },
], ],
} }
const typeOptions = Object.keys(modelsByType) const typeOptions = Object.keys(versionsByType)
interface BoardEntry { interface BoardEntry {
id: number id: number
type: string type: string
model: string version: string
firmware: string firmware: string
sn: string sn: string
productionDate: string productionDate: string
remark: string remark: string
calibFile: File | null
} }
let nextId = 1 let nextId = 1
@ -43,7 +45,7 @@ export default function BoardRegisterPage() {
const [batchMode, setBatchMode] = useState(false) const [batchMode, setBatchMode] = useState(false)
function createEntry(): BoardEntry { function createEntry(): BoardEntry {
return { id: nextId++, type: '采集板', model: 'RX-V2.1', firmware: 'v2.1', sn: '', productionDate: '', remark: '' } return { id: nextId++, type: '采集板', version: 'RX-V2.1', firmware: 'v2.1', sn: '', productionDate: '', remark: '', calibFile: null }
} }
const addEntry = () => { const addEntry = () => {
@ -59,24 +61,38 @@ export default function BoardRegisterPage() {
setEntries(prev => prev.map(e => { setEntries(prev => prev.map(e => {
if (e.id !== id) return e if (e.id !== id) return e
const updated = { ...e, [field]: value } const updated = { ...e, [field]: value }
// 切换类型时自动选第一个型号和固件 // 切换类型时自动选第一个版本和固件
if (field === 'type') { if (field === 'type') {
const models = modelsByType[value] const versions = versionsByType[value]
if (models && models.length > 0) { if (versions && versions.length > 0) {
updated.model = models[0].model updated.version = versions[0].version
updated.firmware = models[0].firmware updated.firmware = versions[0].firmware
} }
// 切换到非采集板时清除校准文件
if (value !== '采集板') updated.calibFile = null
} }
// 切换型号时自动填充固件 // 切换版本时自动填充固件
if (field === 'model') { if (field === 'version') {
const models = modelsByType[updated.type] const versions = versionsByType[updated.type]
const match = models?.find(m => m.model === value) const match = versions?.find(m => m.version === value)
if (match) updated.firmware = match.firmware if (match) updated.firmware = match.firmware
} }
return updated return updated
})) }))
} }
const fileInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
const handleCalibFileChange = (entryId: number, file: File | null) => {
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, calibFile: file } : e))
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const isValid = entries.every(e => e.sn.trim() && e.productionDate) const isValid = entries.every(e => e.sn.trim() && e.productionDate)
return ( return (
@ -84,7 +100,7 @@ export default function BoardRegisterPage() {
<div style={{ flex: 1, padding: 24, paddingBottom: 80 }}> <div style={{ flex: 1, padding: 24, paddingBottom: 80 }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => router.push('/calibration')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}> <button onClick={() => router.push('/board-cards')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<div> <div>
@ -97,7 +113,7 @@ export default function BoardRegisterPage() {
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, backgroundColor: '#eef5f0', borderRadius: 8, marginBottom: 24, border: '1px solid #a3c4ad' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, backgroundColor: '#eef5f0', borderRadius: 8, marginBottom: 24, border: '1px solid #a3c4ad' }}>
<Info size={18} style={{ color: '#4a7c59', flexShrink: 0, marginTop: 2 }} /> <Info size={18} style={{ color: '#4a7c59', flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: 14, color: '#4a7c59', lineHeight: 1.6 }}> <div style={{ fontSize: 14, color: '#4a7c59', lineHeight: 1.6 }}>
SN号为唯一标识 SN号为唯一标识
</div> </div>
</div> </div>
@ -136,11 +152,11 @@ export default function BoardRegisterPage() {
</select> </select>
</div> </div>
{/* 板卡型号 */} {/* 板卡版本 */}
<div> <div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={entry.model} onChange={e => updateEntry(entry.id, 'model', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}> <select value={entry.version} onChange={e => updateEntry(entry.id, 'version', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{(modelsByType[entry.type] || []).map(m => <option key={m.model} value={m.model}>{m.model}</option>)} {(versionsByType[entry.type] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}
</select> </select>
</div> </div>
@ -153,7 +169,7 @@ export default function BoardRegisterPage() {
{/* 板卡SN */} {/* 板卡SN */}
<div> <div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> SN号</label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> SN号</label>
<input value={entry.sn} onChange={e => updateEntry(entry.id, 'sn', e.target.value)} placeholder={`${entry.model}-20250401001`} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} /> <input value={entry.sn} onChange={e => updateEntry(entry.id, 'sn', e.target.value)} placeholder={`${entry.version}-20250401001`} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
</div> </div>
{/* 生产日期 */} {/* 生产日期 */}
@ -176,6 +192,38 @@ export default function BoardRegisterPage() {
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>"待校准"</span> <span style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>"待校准"</span>
</div> </div>
)} )}
{/* 采集板校准文件导入 */}
{entry.type === '采集板' && (
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 8 }}></label>
<input
type="file"
accept=".csv,.xlsx,.xls,.dat"
ref={el => { fileInputRefs.current[entry.id] = el }}
onChange={e => handleCalibFileChange(entry.id, e.target.files?.[0] || null)}
style={{ display: 'none' }}
/>
{entry.calibFile ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', backgroundColor: '#eef5f0', borderRadius: 6, border: '1px solid #a3c4ad' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={16} style={{ color: '#4a7c59', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.85)' }}>{entry.calibFile.name}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{formatFileSize(entry.calibFile.size)}</div>
</div>
</div>
<button onClick={() => { handleCalibFileChange(entry.id, null); if (fileInputRefs.current[entry.id]) fileInputRefs.current[entry.id]!.value = '' }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, border: 'none', background: 'none', cursor: 'pointer', color: 'rgba(0,0,0,0.45)', borderRadius: 4 }}>
<X size={14} />
</button>
</div>
) : (
<button onClick={() => fileInputRefs.current[entry.id]?.click()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%', padding: '14px 0', border: '2px dashed #D9D9D9', borderRadius: 6, backgroundColor: '#FAFAFA', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
<Upload size={16} /> .csv / .xlsx / .dat
</button>
)}
</div>
)}
</div> </div>
))} ))}
@ -193,7 +241,7 @@ export default function BoardRegisterPage() {
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ backgroundColor: '#FAFAFA' }}> <tr style={{ backgroundColor: '#FAFAFA' }}>
{['序号', '类型', '型号', '固件', 'SN号', '生产日期', '状态'].map(h => ( {['序号', '类型', '版本', '固件', 'SN号', '生产日期', '校准文件', '状态'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th> <th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
))} ))}
</tr> </tr>
@ -203,10 +251,23 @@ export default function BoardRegisterPage() {
<tr key={entry.id} style={{ borderBottom: '1px solid #F0F0F0' }}> <tr key={entry.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{i + 1}</td> <td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{i + 1}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.type}</td> <td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.type}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.model}</td> <td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.version}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{entry.firmware}</td> <td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{entry.firmware}</td>
<td style={{ padding: '10px 14px', fontSize: 13, fontWeight: 500, color: entry.sn ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>{entry.sn || '未填写'}</td> <td style={{ padding: '10px 14px', fontSize: 13, fontWeight: 500, color: entry.sn ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>{entry.sn || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: entry.productionDate ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.25)' }}>{entry.productionDate || '未填写'}</td> <td style={{ padding: '10px 14px', fontSize: 13, color: entry.productionDate ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.25)' }}>{entry.productionDate || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>
{entry.type === '采集板' ? (
entry.calibFile ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: '#4a7c59', fontSize: 12 }}>
<FileText size={12} />{entry.calibFile.name}
</span>
) : (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}></span>
)
) : (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}>-</span>
)}
</td>
<td style={{ padding: '10px 14px' }}> <td style={{ padding: '10px 14px' }}>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 12, ...(entry.type === '采集板' ? { backgroundColor: '#FFFBE6', color: '#FAAD14', border: '1px solid #FFE58F' } : { backgroundColor: '#E6F7FF', color: '#1890FF', border: '1px solid #91D5FF' }) }}> <span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 12, ...(entry.type === '采集板' ? { backgroundColor: '#FFFBE6', color: '#FAAD14', border: '1px solid #FFE58F' } : { backgroundColor: '#E6F7FF', color: '#1890FF', border: '1px solid #91D5FF' }) }}>
{entry.type === '采集板' ? '待校准' : '在库'} {entry.type === '采集板' ? '待校准' : '在库'}
@ -225,11 +286,12 @@ export default function BoardRegisterPage() {
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}> <span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
{entries.length} {entries.length}
{entries.some(e => e.type === '采集板') && <span style={{ color: '#FAAD14' }}> · </span>} {entries.some(e => e.type === '采集板') && <span style={{ color: '#FAAD14' }}> · </span>}
{entries.some(e => e.type === '采集板' && e.calibFile) && <span style={{ color: '#4a7c59' }}> · {entries.filter(e => e.calibFile).length} </span>}
</span> </span>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<button onClick={() => router.push('/calibration')} style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}></button> <button onClick={() => router.push('/board-cards')} style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}></button>
<button <button
onClick={() => router.push('/calibration')} onClick={() => router.push('/board-cards')}
disabled={!isValid} disabled={!isValid}
style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: isValid ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 14 }} style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: isValid ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 14 }}
> >

View File

@ -1,41 +1,17 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Download, Plus, X, Eye, Upload, Clock, Wrench, CheckCircle, ChevronRight, GripVertical } from 'lucide-react' import { Download, Plus, X, Eye, Upload, Wrench, CheckCircle, RefreshCw } from 'lucide-react'
const tabs = ['全部', '主协板', '采集板', '发射板', '升压板'] const tabs = ['全部', '主协板', '采集板', '发射板', '升压板']
const boardsData = [ const initialBoardsData = [
{ id: 1, type: '主协板', model: 'MB25130025', firmwareVersion: 'v2.1.0', productionDate: '2024-03-15', status: '在产' }, { id: 1, type: '主协板', version: 'MB-V1.8', status: '在产' },
{ id: 2, type: '主协板', model: 'MB25130024', firmwareVersion: 'v1.8.5', productionDate: '2023-11-20', status: '在产' }, { id: 3, type: '采集板', version: 'RX-V2.3', status: '在产' },
{ id: 3, type: '采集板', model: 'RX25130024', firmwareVersion: 'v3.0.2', productionDate: '2024-01-10', status: '在产' }, { id: 6, type: '发射板', version: 'TX-V2.1', status: '停产' },
{ id: 4, type: '采集板', model: 'RX25130012', firmwareVersion: 'v2.5.1', productionDate: '2023-09-05', status: '停产' }, { id: 8, type: '升压板', version: 'BP600-V1.2', status: '停产' },
{ id: 5, type: '发射板', model: 'TXB-1000', firmwareVersion: 'v1.2.0', productionDate: '2024-02-28', status: '在产' },
{ id: 6, type: '发射板', model: 'TXB-800', firmwareVersion: 'v1.0.3', productionDate: '2023-06-15', status: '停产' },
{ id: 7, type: '升压板', model: 'BST-500', firmwareVersion: 'v1.1.0', productionDate: '2024-04-01', status: '在产' },
{ id: 8, type: '升压板', model: 'BST-300', firmwareVersion: 'v0.9.8', productionDate: '2023-08-22', status: '停产' },
] ]
const upgradeHistory = [
{ version: 'v2.1.0', date: '2024-03-10', operator: '张工', note: '修复通信协议兼容性问题' },
{ version: 'v2.0.0', date: '2024-01-15', operator: '李工', note: '新增多通道采集支持' },
{ version: 'v1.8.5', date: '2023-11-01', operator: '张工', note: '优化功耗管理' },
]
const calibrationHistory = [
{ date: '2024-03-01', result: '合格', operator: '王工', nextDate: '2025-03-01' },
{ date: '2023-03-05', result: '合格', operator: '王工', nextDate: '2024-03-05' },
]
const serviceHistory = [
{ date: '2024-02-15', type: '例行保养', operator: '赵工', description: '清洁接口,检查焊点' },
{ date: '2023-08-20', type: '例行保养', operator: '赵工', description: '更换散热硅脂' },
]
const repairHistory = [
{ date: '2024-01-20', issue: '通信异常', operator: '李工', result: '更换通信芯片,已修复' },
]
function getStatusStyle(status: string) { function getStatusStyle(status: string) {
switch (status) { switch (status) {
@ -48,20 +24,27 @@ function getStatusStyle(status: string) {
export default function BoardsPage() { export default function BoardsPage() {
const router = useRouter() const router = useRouter()
const [activeTab, setActiveTab] = useState('全部') const [activeTab, setActiveTab] = useState('全部')
const [detailDrawer, setDetailDrawer] = useState<typeof boardsData[0] | null>(null) const [boardsData, setBoardsData] = useState(initialBoardsData)
const [detailDrawer, setDetailDrawer] = useState<typeof initialBoardsData[0] | null>(null)
const [addDrawer, setAddDrawer] = useState(false) const [addDrawer, setAddDrawer] = useState(false)
const [detailTab, setDetailTab] = useState('basic') const [detailTab, setDetailTab] = useState('basic')
const [formData, setFormData] = useState({ type: '主协板', model: '', firmwareVersion: '', productionDate: '' }) const [formData, setFormData] = useState({ type: '主协板', version: '', firmwareVersion: '', productionDate: '', status: '在产' })
const filteredBoards = activeTab === '全部' ? boardsData : boardsData.filter(b => b.type === activeTab) const filteredBoards = activeTab === '全部' ? boardsData : boardsData.filter(b => b.type === activeTab)
const handleToggleStatus = (id: number) => {
setBoardsData(prev => prev.map(b =>
b.id === id ? { ...b, status: b.status === '在产' ? '停产' : '在产' } : b
))
}
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div> <div>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}></h2> <h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}></h2>
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p> <p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p>
</div> </div>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<button onClick={() => {}} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}> <button onClick={() => {}} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>
@ -90,7 +73,7 @@ export default function BoardsPage() {
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ backgroundColor: '#FAFAFA' }}> <tr style={{ backgroundColor: '#FAFAFA' }}>
{['板卡类型', '型号', '固件版本', '生产日期', '状态', '操作'].map(h => ( {['板卡类型', '版本', '状态', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: 14, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th> <th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: 14, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
))} ))}
</tr> </tr>
@ -99,9 +82,7 @@ export default function BoardsPage() {
{filteredBoards.map(board => ( {filteredBoards.map(board => (
<tr key={board.id} style={{ borderBottom: '1px solid #F0F0F0' }}> <tr key={board.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '12px 16px', fontSize: 14 }}>{board.type}</td> <td style={{ padding: '12px 16px', fontSize: 14 }}>{board.type}</td>
<td style={{ padding: '12px 16px', fontSize: 14, fontWeight: 500 }}>{board.model}</td> <td style={{ padding: '12px 16px', fontSize: 14, fontWeight: 500 }}>{board.version}</td>
<td style={{ padding: '12px 16px', fontSize: 14 }}>{board.firmwareVersion}</td>
<td style={{ padding: '12px 16px', fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>{board.productionDate}</td>
<td style={{ padding: '12px 16px' }}> <td style={{ padding: '12px 16px' }}>
<span style={{ ...getStatusStyle(board.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{board.status}</span> <span style={{ ...getStatusStyle(board.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{board.status}</span>
</td> </td>
@ -110,9 +91,12 @@ export default function BoardsPage() {
<button onClick={() => { setDetailDrawer(board); setDetailTab('basic') }} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}> <button onClick={() => { setDetailDrawer(board); setDetailTab('basic') }} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}>
<Eye size={14} /> <Eye size={14} />
</button> </button>
<button onClick={() => router.push(`/firmware?board=${board.model}`)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}> <button onClick={() => router.push(`/firmware?board=${board.version}`)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}>
<Upload size={14} /> <Upload size={14} />
</button> </button>
<button onClick={() => handleToggleStatus(board.id)} style={{ color: board.status === '在产' ? '#FAAD14' : '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}>
<RefreshCw size={14} />{board.status === '在产' ? '停产' : '恢复在产'}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -127,17 +111,13 @@ export default function BoardsPage() {
<div onClick={() => setDetailDrawer(null)} style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.45)' }} /> <div onClick={() => setDetailDrawer(null)} style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.45)' }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 560, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}> <div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 560, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}> - {detailDrawer.model}</h3> <h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}> - {detailDrawer.version}</h3>
<button onClick={() => setDetailDrawer(null)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button> <button onClick={() => setDetailDrawer(null)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
</div> </div>
{/* Detail Tabs */} {/* Detail Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid #F0F0F0' }}> <div style={{ display: 'flex', borderBottom: '1px solid #F0F0F0' }}>
{[ {[
{ key: 'basic', label: '基本信息' }, { key: 'basic', label: '基本信息' },
{ key: 'upgrade', label: '升级历史' },
{ key: 'calibration', label: '校准历史' },
{ key: 'service', label: '保养历史' },
{ key: 'repair', label: '维修历史' },
].map(t => ( ].map(t => (
<button key={t.key} onClick={() => setDetailTab(t.key)} style={{ <button key={t.key} onClick={() => setDetailTab(t.key)} style={{
padding: '10px 16px', fontSize: 13, cursor: 'pointer', border: 'none', backgroundColor: 'transparent', padding: '10px 16px', fontSize: 13, cursor: 'pointer', border: 'none', backgroundColor: 'transparent',
@ -151,9 +131,7 @@ export default function BoardsPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{[ {[
{ label: '板卡类型', value: detailDrawer.type }, { label: '板卡类型', value: detailDrawer.type },
{ label: '型号', value: detailDrawer.model }, { label: '版本', value: detailDrawer.version },
{ label: '固件版本', value: detailDrawer.firmwareVersion },
{ label: '生产日期', value: detailDrawer.productionDate },
{ label: '状态', value: detailDrawer.status }, { label: '状态', value: detailDrawer.status },
].map(item => ( ].map(item => (
<div key={item.label}> <div key={item.label}>
@ -167,70 +145,6 @@ export default function BoardsPage() {
))} ))}
</div> </div>
)} )}
{detailTab === 'upgrade' && (
<div>
{upgradeHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: '1px solid #F0F0F0' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#eef5f0', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Upload size={14} style={{ color: '#4a7c59' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{h.version}</div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{h.note}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 4 }}>{h.date} · {h.operator}</div>
</div>
</div>
))}
</div>
)}
{detailTab === 'calibration' && (
<div>
{calibrationHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: '1px solid #F0F0F0' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#F6FFED', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<CheckCircle size={14} style={{ color: '#52C41A' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>: <span style={{ color: '#52C41A' }}>{h.result}</span></div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>: {h.nextDate}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 4 }}>{h.date} · {h.operator}</div>
</div>
</div>
))}
</div>
)}
{detailTab === 'service' && (
<div>
{serviceHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: '1px solid #F0F0F0' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#eef5f0', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Wrench size={14} style={{ color: '#4a7c59' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{h.type}</div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{h.description}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 4 }}>{h.date} · {h.operator}</div>
</div>
</div>
))}
</div>
)}
{detailTab === 'repair' && (
<div>
{repairHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: '1px solid #F0F0F0' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#FFF2F0', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Wrench size={14} style={{ color: '#FF4D4F' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>: {h.issue}</div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{h.result}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 4 }}>{h.date} · {h.operator}</div>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -253,16 +167,14 @@ export default function BoardsPage() {
</select> </select>
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}></label> <label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}></label>
<input value={formData.model} onChange={e => setFormData({ ...formData, model: e.target.value })} placeholder="请输入型号" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, outline: 'none', boxSizing: 'border-box' }} /> <input value={formData.version} onChange={e => setFormData({ ...formData, version: e.target.value })} placeholder="请输入版本" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}></label> <label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}></label>
<input value={formData.firmwareVersion} onChange={e => setFormData({ ...formData, firmwareVersion: e.target.value })} placeholder="请输入固件版本" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, outline: 'none', boxSizing: 'border-box' }} /> <select value={formData.status} onChange={e => setFormData({ ...formData, status: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, outline: 'none' }}>
</div> {['在产', '停产'].map(s => <option key={s} value={s}>{s}</option>)}
<div style={{ marginBottom: 20 }}> </select>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}></label>
<input type="date" value={formData.productionDate} onChange={e => setFormData({ ...formData, productionDate: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
</div> </div>
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}> <div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>

View File

@ -8,8 +8,8 @@ const menuGroups = [
{ path: '/devices', label: '设备列表', icon: Monitor }, { path: '/devices', label: '设备列表', icon: Monitor },
{ path: '/models', label: '设备型号管理', icon: Settings2 }, { path: '/models', label: '设备型号管理', icon: Settings2 },
]}, ]},
{ title: '板卡', items: [{ path: '/calibration', label: '板卡列表', icon: Gauge }, { title: '板卡', items: [{ path: '/board-cards', label: '板卡列表', icon: Gauge },
{ path: '/boards', label: '板卡型号管理', icon: Cpu },] }, { path: '/boards', label: '板卡版本管理', icon: Cpu },] },
{ title: '维修', items: [ { title: '维修', items: [
{ path: '/repair', label: '维修工单', icon: Wrench }, { path: '/repair', label: '维修工单', icon: Wrench },
{ path: '/scrap', label: '报废回收', icon: Recycle }, { path: '/scrap', label: '报废回收', icon: Recycle },

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, Suspense } from 'react'
import { Plus, Search, Info, ChevronLeft, ChevronRight, X, Download, Trash2, Eye, Edit } from 'lucide-react' import { useRouter, useSearchParams } from 'next/navigation'
import { Plus, Search, Info, ChevronLeft, ChevronRight, X, Download, Trash2, Eye, Edit, ArrowLeft } from 'lucide-react'
const configData = [ const configData = [
{ id: 1, name: 'CFG-GD30-v1.2.0', model: 'GD-30 Supreme', version: 'v1.2.0', createTime: '2025-01-15 10:30', status: '生效', voltage: '1500V', current: '10A', dutyCycle: '50%、100%', pulseWidth: '0.25s~64s', iterations: '1~256', channels: 12, sampleRate: '50Hz/60Hz/100Hz/1000Hz', voltageRange: '±2.5V/±80V', waveform: '支持', ssid: 'GD30' }, { id: 1, name: 'CFG-GD30-v1.2.0', model: 'GD-30 Supreme', version: 'v1.2.0', createTime: '2025-01-15 10:30', status: '生效', voltage: '1500V', current: '10A', dutyCycle: '50%、100%', pulseWidth: '0.25s~64s', iterations: '1~256', channels: 12, sampleRate: '50Hz/60Hz/100Hz/1000Hz', voltageRange: '±2.5V/±80V', waveform: '支持', ssid: 'GD30' },
@ -23,7 +24,28 @@ function getStatusStyle(status: string) {
} }
export default function ConfigFilesPage() { export default function ConfigFilesPage() {
const [filterModel, setFilterModel] = useState('全部') return (
<Suspense fallback={<div style={{ padding: 24 }}>...</div>}>
<ConfigFilesContent />
</Suspense>
)
}
function ConfigFilesContent() {
const router = useRouter()
const searchParams = useSearchParams()
const modelParam = searchParams.get('model') || ''
// Map model name to filter value
const modelNameMap: Record<string, string> = {
'GD-30 Supreme': 'GD-30 Supreme',
'GD-20 Supreme': 'GD-20 Supreme',
'GD-10 Supreme': 'GD-10 Supreme',
}
const initialModelFilter = modelParam ? (modelNameMap[modelParam] || modelParam) : '全部'
const isFromModels = !!modelParam
const [filterModel, setFilterModel] = useState(initialModelFilter)
const [filterVersion, setFilterVersion] = useState('全部') const [filterVersion, setFilterVersion] = useState('全部')
const [filterKeyword, setFilterKeyword] = useState('') const [filterKeyword, setFilterKeyword] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@ -50,9 +72,16 @@ export default function ConfigFilesPage() {
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}></h2> {isFromModels && (
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p> <button onClick={() => router.back()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} />
</button>
)}
<div>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>{isFromModels ? `配置文件管理 — ${modelParam}` : '配置文件管理'}</h2>
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p>
</div>
</div> </div>
<button onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}> <button onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
<Plus size={16} /> <Plus size={16} />

View File

@ -2,22 +2,22 @@
import { use } from 'react' import { use } from 'react'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { ArrowLeft, Cpu, Wifi, Monitor, Key, FileCode, Upload, Clock, Wrench, CheckCircle, AlertTriangle, Package, FileDown } from 'lucide-react' import { ArrowLeft, Cpu, Wifi, Monitor, Key, FileCode, Camera, Clock, X, CheckCircle, AlertTriangle, Package, ChevronLeft, ChevronRight, ZoomIn, Download } from 'lucide-react'
/** Mock: 所有设备数据 */ /** Mock: 所有设备数据 */
const allDevices = [ const allDevices = [
{ id: 1, sn: 'GD30-2025-000001', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-15 14:30', customer: '北京地质研究院', operator: '张工程师' }, { id: 1, sn: 'GD30-2025-000001', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-15 14:30', operator: '张工程师' },
{ id: 2, sn: 'GD30-2025-000002', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-18 09:15', customer: '中国地质大学', operator: '张工程师' }, { id: 2, sn: 'GD30-2025-000002', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-18 09:15', operator: '张工程师' },
{ id: 3, sn: 'GD30-2024-000056', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-12-20 16:00', customer: '成都理工大学', operator: '李工程师' }, { id: 3, sn: 'GD30-2024-000056', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-12-20 16:00', operator: '李工程师' },
{ id: 4, sn: 'GT20-2025-000045', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-02-10 11:20', customer: '武汉地质调查中心', operator: '王工程师' }, { id: 4, sn: 'GT20-2025-000045', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-02-10 11:20', operator: '王工程师' },
{ id: 5, sn: 'GT20-2025-000046', model: 'GD-20', type: '二维电法仪', status: '装配中', firmware: 'v1.8.5', productionDate: '2025-03-01 08:45', customer: '-', operator: '王工程师' }, { id: 5, sn: 'GT20-2025-000046', model: 'GD-20', type: '二维电法仪', status: '装配中', firmware: 'v1.8.5', productionDate: '2025-03-01 08:45', operator: '王工程师' },
{ id: 6, sn: 'GD30-2024-000078', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-11-05 13:30', customer: '长安大学', operator: '张工程师' }, { id: 6, sn: 'GD30-2024-000078', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-11-05 13:30', operator: '张工程师' },
{ id: 7, sn: 'GD10-2024-000033', model: 'GD-10 Supreme', type: '入门级电法仪', status: '已激活', firmware: 'v1.5.2', productionDate: '2024-09-12 10:00', customer: '河海大学', operator: '李工程师' }, { id: 7, sn: 'GD10-2024-000033', model: 'GD-10 Supreme', type: '入门级电法仪', status: '已激活', firmware: 'v1.5.2', productionDate: '2024-09-12 10:00', operator: '李工程师' },
{ id: 8, sn: 'GD30-2024-000089', model: 'GD-30 Supreme', type: '高密度电法仪', status: '装配中', firmware: 'v2.3.5', productionDate: '2025-03-05 15:10', customer: '-', operator: '张工程师' }, { id: 8, sn: 'GD30-2024-000089', model: 'GD-30 Supreme', type: '高密度电法仪', status: '装配中', firmware: 'v2.3.5', productionDate: '2025-03-05 15:10',operator: '张工程师' },
{ id: 9, sn: 'GT20-2025-000012', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-01-22 09:30', customer: '中南大学', operator: '王工程师' }, { id: 9, sn: 'GT20-2025-000012', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-01-22 09:30', operator: '王工程师' },
{ id: 10, sn: 'GD30-2024-000102', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-10-18 14:00', customer: '吉林大学', operator: '李工程师' }, { id: 10, sn: 'GD30-2024-000102', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-10-18 14:00', operator: '李工程师' },
{ id: 11, sn: 'GD10-2024-000034', model: 'GD-10 Supreme', type: '入门级电法仪', status: '装配中', firmware: 'v1.5.2', productionDate: '2025-03-08 11:45', customer: '-', operator: '王工程师' }, { id: 11, sn: 'GD10-2024-000034', model: 'GD-10 Supreme', type: '入门级电法仪', status: '装配中', firmware: 'v1.5.2', productionDate: '2025-03-08 11:45', operator: '王工程师' },
{ id: 12, sn: 'GD30-2024-000145', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2024-08-25 16:20', customer: '同济大学', operator: '张工程师' }, { id: 12, sn: 'GD30-2024-000145', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2024-08-25 16:20', operator: '张工程师' },
] ]
/** Mock: 装机BOM清单 */ /** Mock: 装机BOM清单 */
@ -38,11 +38,46 @@ const licenseData: Record<string, { modules: string; expiry: string; status: str
'GT20-2025-000045': { modules: '2D ERT, 3D ERT, 1D IP, 2D IP', expiry: '2025-06-30', status: '生效' }, 'GT20-2025-000045': { modules: '2D ERT, 3D ERT, 1D IP, 2D IP', expiry: '2025-06-30', status: '生效' },
} }
/** Mock: 配置文件 */ /** Mock: 配置文件(含详细参数) */
const configData: Record<string, { name: string; version: string; uploadDate: string }> = { interface ConfigDetail {
'GD30-2025-000001': { name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-15' }, name: string
'GD30-2025-000002': { name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-18' }, version: string
'GT20-2025-000045': { name: 'CFG-GD20-v1.1.0', version: 'v1.1.0', uploadDate: '2025-02-10' }, uploadDate: string
params: {
transmission: { maxVoltage: string; maxCurrent: string; dutyCycle: string; pulseWidths: string[]; waveforms: string[]; fullWaveform: boolean }
acquisition: { channels: number; sampleRates: string[]; voltageRanges: string[]; iterationRange: string }
protection: { overVoltage: { enabled: boolean; threshold: string }; overCurrent: { enabled: boolean; threshold: string }; shortCircuit: boolean; overTemp: { enabled: boolean; threshold: string } }
network: { wifiSsidPrefix: string }
}
}
const configData: Record<string, ConfigDetail> = {
'GD30-2025-000001': {
name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-15',
params: {
transmission: { maxVoltage: '1500V', maxCurrent: '10A', dutyCycle: '50%、100%', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s', '16s', '32s', '64s'], waveforms: ['0+0-', '+0-0', '+-'], fullWaveform: true },
acquisition: { channels: 12, sampleRates: ['50Hz', '60Hz', '100Hz', '1000Hz'], voltageRanges: ['±2.5V', '±80V', '±600V'], iterationRange: '1~256' },
protection: { overVoltage: { enabled: true, threshold: '1600V' }, overCurrent: { enabled: true, threshold: '12A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '75°C' } },
network: { wifiSsidPrefix: 'GD30-Supreme' },
},
},
'GD30-2025-000002': {
name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-18',
params: {
transmission: { maxVoltage: '1500V', maxCurrent: '10A', dutyCycle: '50%、100%', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s', '16s', '32s', '64s'], waveforms: ['0+0-', '+0-0', '+-'], fullWaveform: true },
acquisition: { channels: 12, sampleRates: ['50Hz', '60Hz', '100Hz', '1000Hz'], voltageRanges: ['±2.5V', '±80V', '±600V'], iterationRange: '1~256' },
protection: { overVoltage: { enabled: true, threshold: '1600V' }, overCurrent: { enabled: true, threshold: '12A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '75°C' } },
network: { wifiSsidPrefix: 'GD30-Supreme' },
},
},
'GT20-2025-000045': {
name: 'CFG-GD20-v1.1.0', version: 'v1.1.0', uploadDate: '2025-02-10',
params: {
transmission: { maxVoltage: '800V', maxCurrent: '5A', dutyCycle: '50%', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s'], waveforms: ['0+0-', '+0-0'], fullWaveform: false },
acquisition: { channels: 6, sampleRates: ['50Hz', '60Hz'], voltageRanges: ['±2.5V', '±80V'], iterationRange: '1~128' },
protection: { overVoltage: { enabled: true, threshold: '900V' }, overCurrent: { enabled: true, threshold: '6A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '70°C' } },
network: { wifiSsidPrefix: 'GD20' },
},
},
} }
/** Mock: 操作日志 */ /** Mock: 操作日志 */
@ -55,30 +90,30 @@ const operationLogs = [
{ date: '2025-01-16 09:00', action: '出厂检测', operator: '李工程师', detail: '出厂检测通过,设备状态变更为已出厂' }, { date: '2025-01-16 09:00', action: '出厂检测', operator: '李工程师', detail: '出厂检测通过,设备状态变更为已出厂' },
] ]
/** Mock: 装配 Checklist */ /** Mock: 装配 Checklist(含照片和记录) */
const checklistData = [ const checklistData = [
{ name: '主板SN扫码绑定', passed: true }, { name: '主板SN扫码绑定', passed: true, photos: ['https://picsum.photos/seed/mb1/800/600', 'https://picsum.photos/seed/mb2/800/600'], note: '主板SN MB25011501 扫码绑定成功,条码清晰可读' },
{ name: '采集板SN录入×6', passed: true }, { name: '采集板SN录入×6', passed: true, photos: ['https://picsum.photos/seed/rx1/800/600', 'https://picsum.photos/seed/rx2/800/600', 'https://picsum.photos/seed/rx3/800/600'], note: '6块采集板全部录入完成SN号已核对一致' },
{ name: '发射板安装检查', passed: true }, { name: '发射板安装检查', passed: true, photos: ['https://picsum.photos/seed/tx1/800/600'], note: '发射板安装到位,螺丝紧固力矩达标' },
{ name: '升压板安装检查', passed: true }, { name: '升压板安装检查', passed: true, photos: ['https://picsum.photos/seed/bp1/800/600'], note: '升压板安装完成,接线端子牢固' },
{ name: '线缆连接检查', passed: true }, { name: '线缆连接检查', passed: true, photos: ['https://picsum.photos/seed/cable1/800/600', 'https://picsum.photos/seed/cable2/800/600'], note: '所有线缆连接正确,无松动现象' },
{ name: '整机通电测试', passed: true }, { name: '整机通电测试', passed: true, photos: ['https://picsum.photos/seed/power1/800/600'], note: '通电正常,各指示灯状态正确' },
{ name: 'GPS/北斗模块检测', passed: true }, { name: 'GPS/北斗模块检测', passed: true, photos: ['https://picsum.photos/seed/gps1/800/600'], note: 'GPS/北斗信号接收正常,定位精度达标' },
{ name: 'WiFi通信测试', passed: true }, { name: 'WiFi通信测试', passed: true, photos: [], note: 'WiFi连接稳定信号强度 -45dBm' },
{ name: '蓝牙通信测试', passed: true }, { name: '蓝牙通信测试', passed: true, photos: [], note: '蓝牙配对成功,数据传输正常' },
{ name: '采集通道校准验证', passed: true }, { name: '采集通道校准验证', passed: true, photos: ['https://picsum.photos/seed/cal1/800/600', 'https://picsum.photos/seed/cal2/800/600'], note: '所有采集通道校准偏差 < 0.1%,符合标准' },
{ name: '发射电压测试', passed: true }, { name: '发射电压测试', passed: true, photos: ['https://picsum.photos/seed/volt1/800/600'], note: '发射电压 800V 测试通过,波形正常' },
{ name: '电池安装与充电测试', passed: true }, { name: '电池安装与充电测试', passed: true, photos: ['https://picsum.photos/seed/bat1/800/600'], note: '电池安装到位,充电电流正常' },
{ name: 'IP66防护检测', passed: true }, { name: 'IP66防护检测', passed: true, photos: ['https://picsum.photos/seed/ip1/800/600', 'https://picsum.photos/seed/ip2/800/600'], note: 'IP66防护等级测试通过密封良好' },
{ name: '固件版本校验', passed: true }, { name: '固件版本校验', passed: true, photos: [], note: '固件版本 v2.3.5 校验通过' },
{ name: '配置文件写入', passed: true }, { name: '配置文件写入', passed: true, photos: [], note: '配置文件 CFG-GD30-v1.3.0 写入成功' },
{ name: '授权文件写入', passed: true }, { name: '授权文件写入', passed: true, photos: [], note: '全模块授权项写入完成' },
{ name: '整机功能测试', passed: true }, { name: '整机功能测试', passed: true, photos: ['https://picsum.photos/seed/func1/800/600'], note: '整机功能测试全部通过' },
{ name: '数据采集验证', passed: true }, { name: '数据采集验证', passed: true, photos: ['https://picsum.photos/seed/data1/800/600', 'https://picsum.photos/seed/data2/800/600'], note: '数据采集验证通过,采集数据与标准值一致' },
{ name: '外观检查', passed: true }, { name: '外观检查', passed: true, photos: ['https://picsum.photos/seed/look1/800/600'], note: '外观无划痕、无变形,表面处理合格' },
{ name: '标签粘贴', passed: true }, { name: '标签粘贴', passed: true, photos: ['https://picsum.photos/seed/label1/800/600'], note: 'SN标签、型号标签、安全标签粘贴完成' },
{ name: '配件清点', passed: true }, { name: '配件清点', passed: true, photos: ['https://picsum.photos/seed/acc1/800/600'], note: '配件清单核对完成,数量一致' },
{ name: '包装检查', passed: true }, { name: '包装检查', passed: true, photos: ['https://picsum.photos/seed/pack1/800/600', 'https://picsum.photos/seed/pack2/800/600'], note: '包装完好,防震材料到位' },
] ]
function getStatusStyle(status: string) { function getStatusStyle(status: string) {
@ -103,6 +138,8 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
const { sn } = use(params) const { sn } = use(params)
const router = useRouter() const router = useRouter()
const [activeTab, setActiveTab] = useState('overview') const [activeTab, setActiveTab] = useState('overview')
const [expandedItem, setExpandedItem] = useState<number | null>(null)
const [lightbox, setLightbox] = useState<{ photos: string[]; index: number } | null>(null)
const device = allDevices.find(d => d.sn === sn) const device = allDevices.find(d => d.sn === sn)
const bom = bomData[sn] || [] const bom = bomData[sn] || []
@ -157,22 +194,6 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
</div> </div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#4a7c59' }}>{bom.length}</div> <div style={{ fontSize: 22, fontWeight: 600, color: '#4a7c59' }}>{bom.length}</div>
</div> </div>
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Key size={16} style={{ color: license ? '#52C41A' : '#FAAD14' }} />
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}></span>
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: license ? '#52C41A' : '#FAAD14' }}>{license ? license.status : '未授权'}</div>
{license && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 2 }}>{license.expiry}</div>}
</div>
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<FileCode size={16} style={{ color: config ? '#52C41A' : 'rgba(0,0,0,0.25)' }} />
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}></span>
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: config ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>{config ? config.version : '未配置'}</div>
{config && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 2 }}>{config.name}</div>}
</div>
</div> </div>
{/* Tabs */} {/* Tabs */}
@ -198,7 +219,6 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
{ label: '固件版本', value: device.firmware }, { label: '固件版本', value: device.firmware },
{ label: '生产日期', value: device.productionDate }, { label: '生产日期', value: device.productionDate },
{ label: '登记人', value: device.operator }, { label: '登记人', value: device.operator },
{ label: '客户', value: device.customer },
{ label: '设备状态', value: device.status, isStatus: true }, { label: '设备状态', value: device.status, isStatus: true },
].map(item => ( ].map(item => (
<div key={item.label}> <div key={item.label}>
@ -259,39 +279,164 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
{activeTab === 'checklist' && ( {activeTab === 'checklist' && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}> Checklist</h3> <div>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}> <h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}></h3>
<span style={{ color: '#52C41A', fontWeight: 600 }}>{checklistData.filter(c => c.passed).length}</span>/{checklistData.length} <p style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p>
</span> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
<span style={{ color: '#52C41A', fontWeight: 600 }}>{checklistData.filter(c => c.passed).length}</span>/{checklistData.length}
</span>
<button
onClick={() => {
const rows = checklistData.map((item, i) => [
i + 1,
item.name,
item.passed ? '通过' : '未通过',
item.note || '-',
item.photos.length > 0 ? `${item.photos.length}` : '无',
])
const header = ['序号', '检查项', '结果', '装配记录', '照片数量']
const csvContent = '\uFEFF' + [header, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `装配记录_${sn}.csv`
a.click()
URL.revokeObjectURL(url)
}}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 13 }}
>
<Download size={14} />
</button>
</div>
</div> </div>
<div style={{ height: 6, backgroundColor: '#F0F0F0', borderRadius: 3, marginBottom: 20, overflow: 'hidden' }}> <div style={{ height: 6, backgroundColor: '#F0F0F0', borderRadius: 3, marginBottom: 20, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(checklistData.filter(c => c.passed).length / checklistData.length) * 100}%`, backgroundColor: '#52C41A', borderRadius: 3 }} /> <div style={{ height: '100%', width: `${(checklistData.filter(c => c.passed).length / checklistData.length) * 100}%`, backgroundColor: '#52C41A', borderRadius: 3 }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{checklistData.map((item, i) => ( {checklistData.map((item, i) => {
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', borderRadius: 8, backgroundColor: item.passed ? '#F6FFED' : '#FFF1F0', border: item.passed ? '1px solid #B7EB8F' : '1px solid #FFCCC7' }}> const isExpanded = expandedItem === i
<div style={{ width: 20, height: 20, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: item.passed ? '#52C41A' : '#FF4D4F', flexShrink: 0 }}> return (
<CheckCircle size={14} color="#fff" /> <div key={i}>
<div
onClick={() => setExpandedItem(isExpanded ? null : i)}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', borderRadius: isExpanded ? '8px 8px 0 0' : 8,
backgroundColor: item.passed ? '#F6FFED' : '#FFF1F0', border: item.passed ? '1px solid #B7EB8F' : '1px solid #FFCCC7',
borderBottom: isExpanded ? 'none' : undefined, cursor: 'pointer', transition: 'background-color 0.15s',
}}
>
<div style={{ width: 20, height: 20, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: item.passed ? '#52C41A' : '#FF4D4F', flexShrink: 0 }}>
<CheckCircle size={14} color="#fff" />
</div>
<div style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 600, backgroundColor: item.passed ? '#eef5f0' : '#FFF2F0', color: item.passed ? '#4a7c59' : '#FF4D4F', flexShrink: 0 }}>{i + 1}</div>
<span style={{ fontSize: 14, color: item.passed ? '#4a7c59' : '#FF4D4F', flex: 1 }}>{item.name}</span>
{item.photos.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'rgba(0,0,0,0.45)', padding: '2px 8px', borderRadius: 4, backgroundColor: 'rgba(0,0,0,0.04)' }}>
<Camera size={12} />{item.photos.length}
</span>
)}
<span style={{ fontSize: 12, color: item.passed ? '#52C41A' : '#FF4D4F' }}>{item.passed ? '通过' : '未通过'}</span>
<ChevronRight size={14} style={{ color: 'rgba(0,0,0,0.25)', transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} />
</div>
{isExpanded && (
<div style={{
padding: 16, backgroundColor: item.passed ? '#FCFFF8' : '#FFFAFA',
border: item.passed ? '1px solid #B7EB8F' : '1px solid #FFCCC7', borderTop: 'none',
borderRadius: '0 0 8px 8px',
}}>
{item.note && (
<div style={{ marginBottom: item.photos.length > 0 ? 12 : 0 }}>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 4 }}></div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.85)', lineHeight: 1.6 }}>{item.note}</div>
</div>
)}
{item.photos.length > 0 && (
<div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{item.photos.map((photo, pi) => (
<div
key={pi}
onClick={(e) => { e.stopPropagation(); setLightbox({ photos: item.photos, index: pi }) }}
style={{
width: 100, height: 75, borderRadius: 6, overflow: 'hidden', cursor: 'pointer',
border: '1px solid #e8e8e8', position: 'relative',
}}
>
<img src={photo} alt={`${item.name} 照片${pi + 1}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{
position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0)', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0,0,0,0.3)'; (e.currentTarget.querySelector('svg') as SVGElement).style.opacity = '1' }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0,0,0,0)'; (e.currentTarget.querySelector('svg') as SVGElement).style.opacity = '0' }}
>
<ZoomIn size={20} color="#fff" style={{ opacity: 0, transition: 'opacity 0.15s' }} />
</div>
</div>
))}
</div>
</div>
)}
{!item.note && item.photos.length === 0 && (
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.25)' }}></div>
)}
</div>
)}
</div> </div>
<div style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 600, backgroundColor: item.passed ? '#eef5f0' : '#FFF2F0', color: item.passed ? '#4a7c59' : '#FF4D4F', flexShrink: 0 }}>{i + 1}</div> )
<span style={{ fontSize: 14, color: item.passed ? '#4a7c59' : '#FF4D4F' }}>{item.name}</span> })}
<span style={{ marginLeft: 'auto', fontSize: 12, color: item.passed ? '#52C41A' : '#FF4D4F' }}>{item.passed ? '通过' : '未通过'}</span>
</div>
))}
</div> </div>
</div> </div>
)} )}
{/* Photo Lightbox */}
{lightbox && (
<div
onClick={() => setLightbox(null)}
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.85)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<button onClick={() => setLightbox(null)} style={{ position: 'absolute', top: 16, right: 16, background: 'none', border: 'none', cursor: 'pointer', padding: 8 }}>
<X size={24} color="#fff" />
</button>
<div style={{ position: 'absolute', bottom: 20, color: '#fff', fontSize: 14 }}>
{lightbox.index + 1} / {lightbox.photos.length}
</div>
{lightbox.photos.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); setLightbox({ ...lightbox, index: (lightbox.index - 1 + lightbox.photos.length) % lightbox.photos.length }) }}
style={{ position: 'absolute', left: 16, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronLeft size={24} color="#fff" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setLightbox({ ...lightbox, index: (lightbox.index + 1) % lightbox.photos.length }) }}
style={{ position: 'absolute', right: 16, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<ChevronRight size={24} color="#fff" />
</button>
</>
)}
<img
onClick={(e) => e.stopPropagation()}
src={lightbox.photos[lightbox.index]}
alt="装配照片"
style={{ maxWidth: '85vw', maxHeight: '85vh', borderRadius: 8, objectFit: 'contain' }}
/>
</div>
)}
{activeTab === 'license' && ( {activeTab === 'license' && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}></h3> <h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}></h3>
{license ? ( {license ? (
<div> <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 24 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 24 }}>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<span style={{ padding: '2px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' }}>{license.status}</span>
</div>
<div> <div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div> <div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{license.expiry}</div> <div style={{ fontSize: 14, fontWeight: 500 }}>{license.expiry}</div>
@ -316,27 +461,154 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
)} )}
{activeTab === 'config' && ( {activeTab === 'config' && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}> <div>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}></h3>
{config ? ( {config ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div> {/* 基本信息 */}
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.name}</div> <h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24 }}>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.name}</div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.version}</div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.uploadDate}</div>
</div>
</div>
</div> </div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div> {/* 发射参数 */}
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.version}</div> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#4a7c59', display: 'inline-block' }} />
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.maxVoltage}</div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.maxCurrent}</div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.dutyCycle}</div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<span style={{
padding: '2px 10px', borderRadius: 4, fontSize: 12,
backgroundColor: config.params.transmission.fullWaveform ? '#F6FFED' : '#FAFAFA',
color: config.params.transmission.fullWaveform ? '#52C41A' : 'rgba(0,0,0,0.45)',
border: config.params.transmission.fullWaveform ? '1px solid #B7EB8F' : '1px solid #D9D9D9',
}}>{config.params.transmission.fullWaveform ? '支持' : '不支持'}</span>
</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{config.params.transmission.pulseWidths.map(pw => (
<span key={pw} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#eef5f0', color: '#4a7c59', border: '1px solid #a3c4ad' }}>{pw}</span>
))}
</div>
</div>
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{config.params.transmission.waveforms.map(wf => (
<span key={wf} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#f5f5f5', color: 'rgba(0,0,0,0.65)', border: '1px solid #e8e8e8', fontFamily: 'monospace' }}>{wf}</span>
))}
</div>
</div>
</div> </div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div> {/* 采集参数 */}
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.uploadDate}</div> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#597EF7', display: 'inline-block' }} />
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.acquisition.channels} </div>
</div>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}></div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.acquisition.iterationRange}</div>
</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{config.params.acquisition.sampleRates.map(sr => (
<span key={sr} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{sr}</span>
))}
</div>
</div>
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{config.params.acquisition.voltageRanges.map(vr => (
<span key={vr} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{vr}</span>
))}
</div>
</div>
</div>
{/* 保护参数 */}
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#FA8C16', display: 'inline-block' }} />
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
{[
{ label: '过压保护', enabled: config.params.protection.overVoltage.enabled, value: config.params.protection.overVoltage.threshold },
{ label: '过流保护', enabled: config.params.protection.overCurrent.enabled, value: config.params.protection.overCurrent.threshold },
{ label: '短路保护', enabled: config.params.protection.shortCircuit, value: null },
{ label: '高温保护', enabled: config.params.protection.overTemp.enabled, value: config.params.protection.overTemp.threshold },
].map(item => (
<div key={item.label} style={{ padding: 14, borderRadius: 8, backgroundColor: item.enabled ? '#FFFBE6' : '#FAFAFA', border: item.enabled ? '1px solid #FFE58F' : '1px solid #F0F0F0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: item.enabled ? '#52C41A' : '#D9D9D9' }} />
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{item.label}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 500, color: item.enabled ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>
{item.enabled ? (item.value ? `阈值 ${item.value}` : '已启用') : '未启用'}
</div>
</div>
))}
</div>
</div>
{/* 网络参数 */}
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#52C41A', display: 'inline-block' }} />
</h3>
<div>
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>WiFi SSID </div>
<div style={{ fontSize: 14, fontWeight: 500, fontFamily: 'monospace', padding: '4px 10px', backgroundColor: '#f5f5f5', borderRadius: 4, display: 'inline-block' }}>{config.params.network.wifiSsidPrefix}</div>
</div>
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ padding: 40, textAlign: 'center' }}> <div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<FileCode size={32} style={{ color: 'rgba(0,0,0,0.15)', marginBottom: 12 }} /> <h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}></h3>
<div style={{ fontSize: 14, color: 'rgba(0,0,0,0.25)' }}></div> <div style={{ padding: 40, textAlign: 'center' }}>
<FileCode size={32} style={{ color: 'rgba(0,0,0,0.15)', marginBottom: 12 }} />
<div style={{ fontSize: 14, color: 'rgba(0,0,0,0.25)' }}></div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -6,17 +6,48 @@ import { ArrowLeft, Upload, Download, ChevronDown, ChevronUp, X, Package, Shield
const firmwareTypes = ['全部', '主协板', '采集板', '发射板', '升压板', '主机固件', '计算单元固件'] const firmwareTypes = ['全部', '主协板', '采集板', '发射板', '升压板', '主机固件', '计算单元固件']
const firmwareData = [ const firmwareData = [
{ id: 1, version: 'v2.1.0', boardModel: 'MB25130025', type: '主协板', date: '2024-03-10', status: '已发布', size: '12.5MB', downloads: 1234, hwRange: 'MB25130025 Rev.A~C', upgradeType: '可选', signed: true, md5: 'a1b2c3d4e5f6...', sha256: '9f8e7d6c5b4a...', notes: ['修复通信协议兼容性问题', '优化低功耗模式切换', '新增看门狗超时配置'] }, { id: 1, version: 'v2.1.0', boardVersion: 'MB-V1.8', type: '主协板', date: '2024-03-10', status: '已发布', size: '12.5MB', downloads: 1234, hwRange: 'MB25130025 Rev.A~C', upgradeType: '可选', signed: true, md5: 'a1b2c3d4e5f6...', sha256: '9f8e7d6c5b4a...', notes: ['修复通信协议兼容性问题', '优化低功耗模式切换', '新增看门狗超时配置'] },
{ id: 2, version: 'v2.0.0', boardModel: 'MB25130025', type: '主协板', date: '2024-01-15', status: '已发布', size: '11.8MB', downloads: 2456, hwRange: 'MB25130025 Rev.A~C', upgradeType: '强制', signed: true, md5: 'b2c3d4e5f6a1...', sha256: '8e7d6c5b4a9f...', notes: ['新增多通道采集支持', '重构通信协议栈'] }, { id: 2, version: 'v2.0.0', boardVersion: 'MB-V1.8', type: '主协板', date: '2024-01-15', status: '已发布', size: '11.8MB', downloads: 2456, hwRange: 'MB25130025 Rev.A~C', upgradeType: '强制', signed: true, md5: 'b2c3d4e5f6a1...', sha256: '8e7d6c5b4a9f...', notes: ['新增多通道采集支持', '重构通信协议栈'] },
{ id: 3, version: 'v1.8.5', boardModel: 'MCB-2000', type: '主协板', date: '2023-11-01', status: '已发布', size: '9.2MB', downloads: 3120, hwRange: 'MCB-2000 Rev.B~D', upgradeType: '可选', signed: true, md5: 'c3d4e5f6a1b2...', sha256: '7d6c5b4a9f8e...', notes: ['优化功耗管理', '修复偶发重启问题'] }, { id: 3, version: 'v1.8.5', boardVersion: 'MB-V1.8', type: '主协板', date: '2023-11-01', status: '已发布', size: '9.2MB', downloads: 3120, hwRange: 'MCB-2000 Rev.B~D', upgradeType: '可选', signed: true, md5: 'c3d4e5f6a1b2...', sha256: '7d6c5b4a9f8e...', notes: ['优化功耗管理', '修复偶发重启问题'] },
{ id: 4, version: 'v3.0.2', boardModel: 'ACB-6000', type: '采集板', date: '2024-02-20', status: '已发布', size: '8.7MB', downloads: 890, hwRange: 'ACB-6000 Rev.A~B', upgradeType: '可选', signed: true, md5: 'd4e5f6a1b2c3...', sha256: '6c5b4a9f8e7d...', notes: ['提升采样精度', '修复通道串扰问题', '新增自校准功能'] }, { id: 4, version: 'v3.0.2', boardVersion: 'RX-V2.3', type: '采集板', date: '2024-02-20', status: '已发布', size: '8.7MB', downloads: 890, hwRange: 'ACB-6000 Rev.A~B', upgradeType: '可选', signed: true, md5: 'd4e5f6a1b2c3...', sha256: '6c5b4a9f8e7d...', notes: ['提升采样精度', '修复通道串扰问题', '新增自校准功能'] },
{ id: 5, version: 'v2.5.1', boardModel: 'ACB-5000', type: '采集板', date: '2023-09-15', status: '已发布', size: '7.1MB', downloads: 1567, hwRange: 'ACB-5000 Rev.A~C', upgradeType: '可选', signed: true, md5: 'e5f6a1b2c3d4...', sha256: '5b4a9f8e7d6c...', notes: ['优化ADC驱动', '修复温漂补偿算法'] }, { id: 5, version: 'v2.5.1', boardVersion: 'RX-V2.3', type: '采集板', date: '2023-09-15', status: '已发布', size: '7.1MB', downloads: 1567, hwRange: 'ACB-5000 Rev.A~C', upgradeType: '可选', signed: true, md5: 'e5f6a1b2c3d4...', sha256: '5b4a9f8e7d6c...', notes: ['优化ADC驱动', '修复温漂补偿算法'] },
{ id: 6, version: 'v1.2.0', boardModel: 'TXB-1000', type: '发射板', date: '2024-02-28', status: '已发布', size: '6.3MB', downloads: 456, hwRange: 'TXB-1000 Rev.A', upgradeType: '强制', signed: true, md5: 'f6a1b2c3d4e5...', sha256: '4a9f8e7d6c5b...', notes: ['新增过流保护', '优化PWM控制算法'] }, { id: 6, version: 'v1.2.0', boardVersion: 'TX-V2.1', type: '发射板', date: '2024-02-28', status: '已发布', size: '6.3MB', downloads: 456, hwRange: 'TXB-1000 Rev.A', upgradeType: '强制', signed: true, md5: 'f6a1b2c3d4e5...', sha256: '4a9f8e7d6c5b...', notes: ['新增过流保护', '优化PWM控制算法'] },
{ id: 7, version: 'v1.0.3', boardModel: 'TXB-800', type: '发射板', date: '2023-06-20', status: '已发布', size: '5.8MB', downloads: 789, hwRange: 'TXB-800 Rev.A~B', upgradeType: '可选', signed: false, md5: 'a1c3e5b2d4f6...', sha256: '3f8e6d4b2a9c...', notes: ['修复高压输出不稳定'] }, { id: 7, version: 'v1.0.3', boardVersion: 'TX-V2.1', type: '发射板', date: '2023-06-20', status: '已发布', size: '5.8MB', downloads: 789, hwRange: 'TXB-800 Rev.A~B', upgradeType: '可选', signed: false, md5: 'a1c3e5b2d4f6...', sha256: '3f8e6d4b2a9c...', notes: ['修复高压输出不稳定'] },
{ id: 8, version: 'v1.1.0', boardModel: 'BST-500', type: '升压板', date: '2024-04-01', status: '草稿', size: '4.2MB', downloads: 0, hwRange: 'BST-500 Rev.A', upgradeType: '可选', signed: false, md5: '-', sha256: '-', notes: ['初始版本', '支持500V~1500V输出'] }, { id: 8, version: 'v1.1.0', boardVersion: 'BP600-V1.2', type: '升压板', date: '2024-04-01', status: '草稿', size: '4.2MB', downloads: 0, hwRange: 'BST-500 Rev.A', upgradeType: '可选', signed: false, md5: '-', sha256: '-', notes: ['初始版本', '支持100V~600V输出'] },
{ id: 9, version: 'v0.9.8', boardModel: 'BST-300', type: '升压板', date: '2023-08-25', status: '已发布', size: '3.9MB', downloads: 345, hwRange: 'BST-300 Rev.A', upgradeType: '可选', signed: true, md5: 'b2d4f6a1c3e5...', sha256: '2a9c8e6d4b3f...', notes: ['修复过温保护阈值'] },
] ]
/** 设备型号固件数据:按型号 + 固件类别 */
const deviceFirmwareData: Record<string, Record<string, typeof firmwareData>> = {
GD30: {
'主机固件': [
{ id: 101, version: 'v4.2.0', boardVersion: '-', type: '主机固件', date: '2025-03-15', status: '已发布', size: '45.6MB', downloads: 320, hwRange: 'GD30 Rev.A~C', upgradeType: '可选', signed: true, md5: 'gd30h42a1b2c3...', sha256: 'gd30h42x9f8e7d...', notes: ['新增远程诊断功能', '优化数据采集流程', '修复蓝牙连接稳定性'] },
{ id: 102, version: 'v4.1.0', boardVersion: '-', type: '主机固件', date: '2025-01-20', status: '已发布', size: '44.2MB', downloads: 580, hwRange: 'GD30 Rev.A~C', upgradeType: '强制', signed: true, md5: 'gd30h41b2c3d4...', sha256: 'gd30h41y8e7d6c...', notes: ['重构通信协议栈', '新增OTA增量升级支持'] },
{ id: 103, version: 'v4.0.0', boardVersion: '-', type: '主机固件', date: '2024-10-01', status: '已发布', size: '42.8MB', downloads: 1200, hwRange: 'GD30 Rev.A~B', upgradeType: '强制', signed: true, md5: 'gd30h40c3d4e5...', sha256: 'gd30h40z7d6c5b...', notes: ['GD30 首个正式版本', '支持最大60通道采集'] },
],
'计算单元固件': [
{ id: 201, version: 'v2.3.1', boardVersion: '-', type: '计算单元固件', date: '2025-02-28', status: '已发布', size: '18.3MB', downloads: 290, hwRange: 'GD30-CU Rev.A', upgradeType: '可选', signed: true, md5: 'gd30c23a1b2c3...', sha256: 'gd30c23x9f8e7d...', notes: ['优化实时数据处理算法', '修复大数据量下内存溢出'] },
{ id: 202, version: 'v2.2.0', boardVersion: '-', type: '计算单元固件', date: '2024-12-10', status: '已发布', size: '17.8MB', downloads: 450, hwRange: 'GD30-CU Rev.A', upgradeType: '可选', signed: true, md5: 'gd30c22b2c3d4...', sha256: 'gd30c22y8e7d6c...', notes: ['新增Wenner排列自动识别', '优化反演计算性能'] },
],
},
GD20: {
'主机固件': [
{ id: 301, version: 'v3.5.2', boardVersion: '-', type: '主机固件', date: '2025-02-10', status: '已发布', size: '38.1MB', downloads: 670, hwRange: 'GD20 Rev.A~D', upgradeType: '可选', signed: true, md5: 'gd20h35a1b2c3...', sha256: 'gd20h35x9f8e7d...', notes: ['修复低温环境下启动异常', '优化电池管理策略'] },
{ id: 302, version: 'v3.5.0', boardVersion: '-', type: '主机固件', date: '2024-11-05', status: '已发布', size: '37.5MB', downloads: 890, hwRange: 'GD20 Rev.A~D', upgradeType: '强制', signed: true, md5: 'gd20h35b2c3d4...', sha256: 'gd20h35y8e7d6c...', notes: ['新增GPS定位集成', '支持WiFi数据传输'] },
],
'计算单元固件': [
{ id: 401, version: 'v1.8.0', boardVersion: '-', type: '计算单元固件', date: '2025-01-15', status: '已发布', size: '14.2MB', downloads: 340, hwRange: 'GD20-CU Rev.A~B', upgradeType: '可选', signed: true, md5: 'gd20c18a1b2c3...', sha256: 'gd20c18x9f8e7d...', notes: ['优化数据预处理流水线', '新增异常数据自动剔除'] },
],
},
GD10: {
'主机固件': [
{ id: 501, version: 'v2.1.3', boardVersion: '-', type: '主机固件', date: '2024-06-20', status: '已发布', size: '28.5MB', downloads: 1450, hwRange: 'GD10 Rev.A~C', upgradeType: '可选', signed: true, md5: 'gd10h21a1b2c3...', sha256: 'gd10h21x9f8e7d...', notes: ['最终维护版本', '修复已知稳定性问题'] },
],
'计算单元固件': [
{ id: 601, version: 'v1.2.0', boardVersion: '-', type: '计算单元固件', date: '2024-04-10', status: '已发布', size: '10.8MB', downloads: 980, hwRange: 'GD10-CU Rev.A', upgradeType: '可选', signed: false, md5: 'gd10c12a1b2c3...', sha256: 'gd10c12x9f8e7d...', notes: ['最终维护版本'] },
],
},
}
function getStatusStyle(status: string) { function getStatusStyle(status: string) {
switch (status) { switch (status) {
case '已发布': return { backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' } case '已发布': return { backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' }
@ -37,52 +68,56 @@ function FirmwareContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const boardParam = searchParams.get('board') || '' const boardParam = searchParams.get('board') || ''
const modelParam = searchParams.get('model') || ''
// Derive initial type filter from board param const isFromBoards = !!boardParam
const matchedFw = boardParam ? firmwareData.find(f => f.boardModel === boardParam) : null const isFromModels = !!modelParam
const matchedFw = boardParam ? firmwareData.find(f => f.boardVersion === boardParam) : null
const initialType = matchedFw ? matchedFw.type : '全部' const initialType = matchedFw ? matchedFw.type : '全部'
const [filterType, setFilterType] = useState(initialType) const [filterType, setFilterType] = useState(initialType)
const [filterBoard, setFilterBoard] = useState(boardParam) const [filterBoard, setFilterBoard] = useState('')
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
const [uploadOpen, setUploadOpen] = useState(false) const [uploadOpen, setUploadOpen] = useState(false)
const [uploadForm, setUploadForm] = useState({ version: '', hwRange: '', upgradeType: '可选', firmwareType: '主协板', signed: false, notes: '' }) const [uploadForm, setUploadForm] = useState({ version: '', hwRange: '', upgradeType: '可选', firmwareType: '主协板', signed: false, notes: '' })
const [deviceFwTab, setDeviceFwTab] = useState<'主机固件' | '计算单元固件'>('主机固件')
const filtered = firmwareData.filter(f => { // Device model firmware mode
const modelFirmware = modelParam ? deviceFirmwareData[modelParam] : null
const deviceFiltered = modelFirmware ? (modelFirmware[deviceFwTab] || []) : []
// Board firmware mode
const filtered = isFromModels ? deviceFiltered : firmwareData.filter(f => {
if (isFromBoards) return f.boardVersion === boardParam
if (filterType !== '全部' && f.type !== filterType) return false if (filterType !== '全部' && f.type !== filterType) return false
if (filterBoard && f.boardModel !== filterBoard) return false
return true return true
}) })
// Get unique board models for the current type filter
const boardModels = [...new Set(
firmwareData
.filter(f => filterType === '全部' || f.type === filterType)
.map(f => f.boardModel)
)]
const handleTypeChange = (type: string) => { const handleTypeChange = (type: string) => {
setFilterType(type) setFilterType(type)
setFilterBoard('') setFilterBoard('')
} }
const pageTitle = filterBoard const pageTitle = isFromModels
? `固件库 — ${filterBoard}` ? `固件管理 — ${modelParam}`
: '固件库' : isFromBoards
? `固件库 — ${boardParam}`
: '固件库'
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{boardParam && ( {(isFromBoards || isFromModels) && (
<button onClick={() => router.back()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}> <button onClick={() => router.back()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
)} )}
<div> <div>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>{pageTitle}</h2> <h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>{pageTitle}</h2>
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p> <p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}>
{isFromModels ? '管理设备型号的主机固件与计算单元固件' : '管理固件版本,支持上传与下载'}
</p>
</div> </div>
</div> </div>
<button onClick={() => setUploadOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}> <button onClick={() => setUploadOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
@ -90,7 +125,27 @@ function FirmwareContent() {
</button> </button>
</div> </div>
{/* Device Model Firmware Tabs */}
{isFromModels && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, marginBottom: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', borderBottom: '1px solid #F0F0F0' }}>
{(['主机固件', '计算单元固件'] as const).map(tab => (
<button key={tab} onClick={() => { setDeviceFwTab(tab); setExpandedId(null) }} style={{
padding: '10px 20px', fontSize: 14, cursor: 'pointer', border: 'none', backgroundColor: 'transparent',
borderBottom: deviceFwTab === tab ? '2px solid #4a7c59' : '2px solid transparent',
color: deviceFwTab === tab ? '#4a7c59' : 'rgba(0,0,0,0.65)', fontWeight: deviceFwTab === tab ? 600 : 400,
}}>{tab}
<span style={{ marginLeft: 6, padding: '1px 6px', borderRadius: 10, fontSize: 11, backgroundColor: deviceFwTab === tab ? '#eef5f0' : '#F5F5F5', color: deviceFwTab === tab ? '#4a7c59' : 'rgba(0,0,0,0.45)' }}>
{(modelFirmware?.[tab] || []).length}
</span>
</button>
))}
</div>
</div>
)}
{/* Type Tabs + Board Filter */} {/* Type Tabs + Board Filter */}
{!isFromBoards && !isFromModels && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, marginBottom: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}> <div style={{ backgroundColor: '#fff', borderRadius: 8, marginBottom: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<div style={{ display: 'flex', borderBottom: '1px solid #F0F0F0' }}> <div style={{ display: 'flex', borderBottom: '1px solid #F0F0F0' }}>
{firmwareTypes.map(type => ( {firmwareTypes.map(type => (
@ -101,16 +156,8 @@ function FirmwareContent() {
}}>{type}</button> }}>{type}</button>
))} ))}
</div> </div>
{boardModels.length > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 20px' }}>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', flexShrink: 0 }}></span>
<button onClick={() => setFilterBoard('')} style={{ padding: '4px 12px', borderRadius: 4, fontSize: 13, cursor: 'pointer', border: !filterBoard ? '1px solid #4a7c59' : '1px solid #D9D9D9', backgroundColor: !filterBoard ? '#eef5f0' : '#fff', color: !filterBoard ? '#4a7c59' : 'rgba(0,0,0,0.65)' }}></button>
{boardModels.map(bm => (
<button key={bm} onClick={() => setFilterBoard(bm)} style={{ padding: '4px 12px', borderRadius: 4, fontSize: 13, cursor: 'pointer', border: filterBoard === bm ? '1px solid #4a7c59' : '1px solid #D9D9D9', backgroundColor: filterBoard === bm ? '#eef5f0' : '#fff', color: filterBoard === bm ? '#4a7c59' : 'rgba(0,0,0,0.65)' }}>{bm}</button>
))}
</div>
)}
</div> </div>
)}
{/* Firmware Cards */} {/* Firmware Cards */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@ -125,7 +172,6 @@ function FirmwareContent() {
<span style={{ fontSize: 18, fontWeight: 600 }}>{fw.version}</span> <span style={{ fontSize: 18, fontWeight: 600 }}>{fw.version}</span>
<span style={{ ...getStatusStyle(fw.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{fw.status}</span> <span style={{ ...getStatusStyle(fw.status), padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>{fw.status}</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{fw.type}</span> <span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{fw.type}</span>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{fw.boardModel}</span>
</div> </div>
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}> <div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
<span>{fw.date}</span> <span>{fw.date}</span>
@ -228,12 +274,6 @@ function FirmwareContent() {
<option value="强制"></option> <option value="强制"></option>
</select> </select>
</div> </div>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={uploadForm.firmwareType} onChange={e => setUploadForm({ ...uploadForm, firmwareType: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{['主协板', '采集板', '发射板', '升压板', '主机固件', '计算单元固件'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Download, Plus, Search, Info, ChevronLeft, ChevronRight, X, Check } from 'lucide-react' import { Download, Plus, Search, Info, ChevronLeft, ChevronRight, X, Check, ArrowLeft } from 'lucide-react'
const allAuthItems = [ const allAuthItems = [
{ id: '1D_SP', name: '1D SP', description: '一维自然电位法' }, { id: '1D_SP', name: '1D SP', description: '一维自然电位法' },
@ -44,8 +44,28 @@ const statusStyle = (status: string) => {
} }
export default function LicensesPage() { export default function LicensesPage() {
return (
<Suspense fallback={<div style={{ padding: 24 }}>...</div>}>
<LicensesContent />
</Suspense>
)
}
function LicensesContent() {
const router = useRouter() const router = useRouter()
const [filterModel, setFilterModel] = useState('') const searchParams = useSearchParams()
const modelParam = searchParams.get('model') || ''
// Map model name (e.g. "GD-30 Supreme") to license model format (e.g. "GD-30")
const modelNameToLicense: Record<string, string> = {
'GD-30 Supreme': 'GD-30',
'GD-20 Supreme': 'GD-20',
'GD-10 Supreme': 'GD-10',
}
const initialModelFilter = modelParam ? (modelNameToLicense[modelParam] || modelParam) : ''
const isFromModels = !!modelParam
const [filterModel, setFilterModel] = useState(initialModelFilter)
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
@ -82,9 +102,16 @@ export default function LicensesPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold mb-1"></h2> {isFromModels && (
<p className="text-sm" style={{ color: 'rgba(0,0,0,0.45)' }}></p> <button onClick={() => router.back()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} />
</button>
)}
<div>
<h2 className="text-2xl font-semibold mb-1">{isFromModels ? `授权管理 — ${modelParam}` : '授权管理'}</h2>
<p className="text-sm" style={{ color: 'rgba(0,0,0,0.45)' }}></p>
</div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm" style={{ border: '1px solid #D9D9D9', backgroundColor: '#fff', color: 'rgba(0,0,0,0.65)' }}> <button className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm" style={{ border: '1px solid #D9D9D9', backgroundColor: '#fff', color: 'rgba(0,0,0,0.65)' }}>

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Plus, X, Info, GripVertical, Trash2, Edit, Key, FileCode, ChevronDown } from 'lucide-react' import { Plus, X, Info, GripVertical, Trash2, Edit, Key, FileCode, Cpu } from 'lucide-react'
const initialModelsData = [ const initialModelsData = [
{ id: 1, name: 'GD-30 Supreme', code: 'GD30', status: '在产', description: '高端高密度电法仪', createDate: '2023-06-01' }, { id: 1, name: 'GD-30 Supreme', code: 'GD30', status: '在产', description: '高端高密度电法仪', createDate: '2023-06-01' },
@ -61,13 +61,11 @@ export default function ModelsPage() {
const [editDrawer, setEditDrawer] = useState(false) const [editDrawer, setEditDrawer] = useState(false)
const [editingModel, setEditingModel] = useState<typeof initialModelsData[0] | null>(null) const [editingModel, setEditingModel] = useState<typeof initialModelsData[0] | null>(null)
const [editStatus, setEditStatus] = useState('') const [editStatus, setEditStatus] = useState('')
const [actionMenuId, setActionMenuId] = useState<number | null>(null)
const handleEdit = (model: typeof initialModelsData[0]) => { const handleEdit = (model: typeof initialModelsData[0]) => {
setEditingModel(model) setEditingModel(model)
setEditStatus(model.status) setEditStatus(model.status)
setEditDrawer(true) setEditDrawer(true)
setActionMenuId(null)
} }
const handleSaveEdit = () => { const handleSaveEdit = () => {
@ -134,41 +132,31 @@ export default function ModelsPage() {
</td> </td>
<td style={{ padding: '12px 16px', fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>{model.createDate}</td> <td style={{ padding: '12px 16px', fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>{model.createDate}</td>
<td style={{ padding: '12px 16px' }}> <td style={{ padding: '12px 16px' }}>
<div style={{ position: 'relative' }}> <div style={{ display: 'flex', gap: 12 }}>
<button <button
onClick={() => setActionMenuId(actionMenuId === model.id ? null : model.id)} onClick={() => handleEdit(model)}
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.65)' }} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}
> >
<ChevronDown size={14} /> <Edit size={14} />
</button>
<button
onClick={() => router.push(`/licenses?model=${model.name}`)}
style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}
>
<Key size={14} />
</button>
<button
onClick={() => router.push(`/config-files?model=${model.name}`)}
style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}
>
<FileCode size={14} />
</button>
<button
onClick={() => router.push(`/firmware?model=${model.code}`)}
style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}
>
<Cpu size={14} />
</button> </button>
{actionMenuId === model.id && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, backgroundColor: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid #F0F0F0', zIndex: 10, minWidth: 160, overflow: 'hidden' }}>
<button
onClick={() => handleEdit(model)}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '10px 16px', border: 'none', backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.85)', textAlign: 'left' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#F5F5F5')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = '#fff')}
>
<Edit size={14} style={{ color: '#4a7c59' }} />
</button>
<button
onClick={() => { router.push('/licenses'); setActionMenuId(null) }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '10px 16px', border: 'none', backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.85)', textAlign: 'left' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#F5F5F5')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = '#fff')}
>
<Key size={14} style={{ color: '#4a7c59' }} />
</button>
<button
onClick={() => { router.push('/config-files'); setActionMenuId(null) }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '10px 16px', border: 'none', backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.85)', textAlign: 'left' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#F5F5F5')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = '#fff')}
>
<FileCode size={14} style={{ color: '#4a7c59' }} />
</button>
</div>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -256,10 +244,6 @@ export default function ModelsPage() {
</div> </div>
)} )}
{/* Click outside to close action menu */}
{actionMenuId !== null && (
<div onClick={() => setActionMenuId(null)} style={{ position: 'fixed', inset: 0, zIndex: 5 }} />
)}
{/* New Model Drawer */} {/* New Model Drawer */}
{modelDrawer && ( {modelDrawer && (

View File

@ -22,18 +22,10 @@ const deviceStatusData = [
] ]
const taskGroups = [ const taskGroups = [
{ title: '校准即将到期', count: 23, link: '/calibration', tasks: [
{ deviceSN: 'AC20240308005', description: '采集板校准即将到期', time: '3天后到期', link: '/calibration' },
{ deviceSN: 'AC20240308006', description: '采集板校准即将到期', time: '今天到期', link: '/calibration' },
]},
{ title: '维修工单', count: 5, link: '/repair', tasks: [ { title: '维修工单', count: 5, link: '/repair', tasks: [
{ deviceSN: 'GD30-2024-000056', description: '板卡故障,待处理', time: '4小时前', link: '/repair/WO-2024-0001' }, { deviceSN: 'GD30-2024-000056', description: '板卡故障,待处理', time: '4小时前', link: '/repair/WO-2024-0001' },
{ deviceSN: 'GD30-2024-000078', description: '固件异常', time: '6小时前', link: '/repair/WO-2024-0002' }, { deviceSN: 'GD30-2024-000078', description: '固件异常', time: '6小时前', link: '/repair/WO-2024-0002' },
]}, ]},
{ title: '固件升级通知', count: 8, link: '/firmware', tasks: [
{ deviceSN: 'GD-30 Supreme', description: '固件版本v2.3.5可用', time: '1天前', link: '/firmware' },
{ deviceSN: 'GD-10 Supreme', description: '固件版本v2.3.5可用', time: '1天前', link: '/firmware' },
]},
{ title: '授权即将到期', count: 45, link: '/licenses', tasks: [ { title: '授权即将到期', count: 45, link: '/licenses', tasks: [
{ deviceSN: 'GD30-2025-000001', description: '授权将于30天后到期', time: '30天', link: '/licenses' }, { deviceSN: 'GD30-2025-000001', description: '授权将于30天后到期', time: '30天', link: '/licenses' },
{ deviceSN: 'GT20-2025-000045', description: '授权将于15天后到期', time: '15天', link: '/licenses' }, { deviceSN: 'GT20-2025-000045', description: '授权将于15天后到期', time: '15天', link: '/licenses' },