Compare commits

..

15 Commits

Author SHA1 Message Date
Developer
57badf213a feat(auth): 添加启动认证功能
- 在本地化文件中添加认证相关的多语言支持
- 实现密码验证逻辑和锁定机制
- 创建登录页面UI组件
- 集成路由保护,未认证用户自动重定向到登录页
- 支持密码错误次数限制和倒计时锁定功能
2026-06-12 15:30:25 +08:00
Developer
3d849bd468 feat(i18n): 完成全量 UI 文本国际化,替换所有硬编码中文为 AppLocalizations 调用
- core/localization: 新增约 60 个翻译键(含参数化方法),中英双语覆盖
- shared/widgets: CommonDialog 默认参数国际化
- features/home: 完成页操作步骤指引、状态栏串口连接状态、程序列表状态标签
- features/programs: 表头状态列、表单验证提示、导入/模板操作反馈、删除确认(参数化)
- features/program_detail: 步骤列表/表单标题、删除确认、速度档位显示(参数化)
- features/device: run_state_provider 错误消息改为错误码
- features/settings: 升级页、密码面板、语言面板、U盘导入面板、串口配置面板全部替换

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 15:09:47 +08:00
Developer
5d65744618 chore(daemon): 更新守护进程PID文件版本信息
- 更新进程ID从67540到111556
- 升级版本号从0.9.7到0.9.9
- 更新启动时间戳为最新值
2026-06-10 16:34:03 +08:00
Developer
7523060927 docs(ui): 更新画布尺寸适配说明
- 将实际开发适配尺寸从 1920×1080 修改为 1024x600 横屏
- 同步更新 AGENTS.md、CLAUDE.md 和 README.md 中的画布尺寸说明
2026-06-05 10:08:54 +08:00
Developer
5f9cb06a1e docs(readme): 更新项目文档为开发者指南
- 将 README.md 重命名为 AGENTS.md,提供项目开发指导
- 添加项目概述,说明污水毒品快检一体机控制软件功能
- 添加开发命令说明,包括获取依赖、运行、构建等常用命令
- 详细描述项目架构,包含 core、features、shared 三层结构
- 补充技术栈信息,明确状态管理、路由、数据持久化方案
- 添加核心数据模型说明,包含 Program 和 Step 实体定义
- 说明关键 Provider 和数据库表结构设计
- 补充国际化配置和代码规范要求
- 详细描述各功能模块,包括首页、程序管理、详情、设置模块
- 添加步骤参数说明表,明确各操作参数取值范围
- 更新当前实现状态,标注已完成和待完善功能
- 添加 UI 设计稿链接和页面截图参考
- 补充设计规范要求,包括配色、字体、适配等说明
2026-06-05 10:07:36 +08:00
Developer
8c2e26ec87 feat(home): 更新完成页面UI并优化串口连接状态管理
- 更新设备屏幕尺寸配置从1920*1080调整为1024x600
- 添加完成页面的AppBar导航和返回功能
- 重构CompletePage布局,使用SafeArea和ConstrainedBox适配不同屏幕
- 添加国际化支持的完成按钮文本
- 优化完成页面视觉元素,包括图标大小和间距调整
- 实现串口连接状态的响应式管理,解决UI状态同步问题
- 优化串口运行器的状态更新逻辑,实现乐观更新机制
- 调整完成页面按钮布局,提供完成和重新运行选项
2026-06-05 10:03:41 +08:00
Developer
87c4b669a0 refactor(home): 优化运行状态监控界面布局和参数显示
- 将程序名文本包装在Flexible组件中并添加省略号处理
- 为步骤信息添加Flexible组件以改善布局
- 使用ScrollableVIew包装内容以支持滚动
- 重构步骤参数显示为三列布局
- 移除硬编码的温度和磁力时间参数
- 更新速度、持续时间和样品体积的单位显示
- 从状态栏移除设备名称显示
- 从设置菜单移除USB导入功能选项
2026-06-04 17:47:12 +08:00
Developer
736c36a98e refactor(home): 优化主页布局和运行控制面板
- 将主页左右布局改为弹性布局,左侧程序列表占2/5宽度,右侧控制区域占3/5宽度
- 移除程序列表组件的固定宽度设置,使其能够自适应布局
- 在运行控制面板中添加主轴最小尺寸限制以优化空间使用
- 移除暂停/继续按钮中的占位按钮,简化按钮逻辑
- 修改开始/继续按钮为暂停/继续按钮,支持运行中状态切换
- 更新按钮图标和文字根据当前运行状态动态显示
- 移除运行状态指示器,精简界面元素
2026-06-04 17:22:38 +08:00
Developer
37d2af70b7 feat(device): 启动自动连接 USB 串口 + 隐藏设置页配置项 + 标题栏连接状态
- 新增 AutoSerialConnect 服务:启动后自动连接第一个 USB 串口设备,
  固定 115200/8/N/1,连接失败时每 3s 重试,断开后重新进入重试循环
- main.dart 通过 ProviderContainer 在 runApp 之前触发 autoSerialConnectProvider
- 移除设置页「串口配置」菜单项及对应面板分支
- StatusBar 在「设备运行状态」前增加串口连接状态指示(已连接/连接中/未连接)
2026-06-04 16:57:45 +08:00
Developer
3ab2232845 refactor(device): 替换消息ID生成器为UUID实现
- 移除自定义时间戳+随机数ID生成逻辑
- 集成uuid包依赖并配置版本
- 使用Uuid.v4()替换原有next()方法实现
- 更新MessageIdGenerator类文档注释
- 在JSON协议层添加设备日志警告输出
- 修改pubspec.yaml添加uuid依赖声明
2026-06-04 16:45:06 +08:00
Developer
55bdaa9211 feat(programs): Excel 导入改为全量覆盖模式
已存在 code 的程序不再跳过,而是:
- 用 Excel 中的字段更新 program(保留 id)
- 删除该 program 的全部旧步骤
- 按 Excel 中的步骤重新写入

返回值变量名 importedCount -> processedCount 更准确。
Toast 文案同步:成功处理 / Excel 无有效数据。
2026-06-04 15:51:03 +08:00
Developer
cbe1e6b470 feat(programs): Android 端通过 MediaStore 写入公共 Downloads
Android 10+ 受 scoped storage 限制,getDownloadsDirectory() 返回的是
app-specific 目录 (/storage/emulated/0/Android/data/.../files/Download/),
而非用户可见的 /storage/emulated/0/Download/。

新增 MainActivity 端 MethodChannel com.xiarui.kuaishai2/downloads:
- API 29+:MediaStore.Downloads 写入公共 Downloads,无需权限
- API <=28:直接写 /storage/emulated/0/Download/(需 WRITE_EXTERNAL_STORAGE)

Dart 端 ExcelTemplateService 改用 MethodChannel,Android 平台返回
Download/<filename> 显示路径;其它平台保留 getDownloadsDirectory 行为。
返回值由 File 改为 String,调用方已同步更新。
2026-06-04 15:45:23 +08:00
Developer
16fbb7d54b refactor(programs): 模板保存目录改为下载目录
- getDownloadsDirectory() 优先,失败时回退到应用文档目录
- 更新 dartdoc 说明
2026-06-04 15:35:52 +08:00
Developer
d91791edaf feat(programs): Excel 模板下载 + .xlsx 解析导入
- 新增 excel 4.0.6 / path_provider 2.1.5 依赖
- ExcelTemplateService:生成 Programs + Steps 双表模板(保存到应用文档目录)
- ExcelImportService:解析 .xlsx 并写入数据库,跳过已存在 code、按 program_code 关联步骤
- programs_page 顶部新增「下载模板」按钮,导入按钮改用 Excel 解析
- 移除被取代的 program_import_service.dart
- AppLocalizations 新增 downloadTemplate 键
- 验证:flutter analyze 无新增 issue;flutter build apk --debug 通过
2026-06-04 15:27:34 +08:00
Developer
d53c41c300 feat(device): 添加USB设备通信支持和程序参数优化
- 在AndroidManifest.xml中添加USB Host权限和设备过滤器配置
- 新增设备控制国际化词条包括速度档位、吹气时间等
- 重构数据库结构将速度相关字段统一为档位数值存储
- 添加通用KV存储方法用于settings表数据读写
- 优化首页导航实现tab间跳转和状态保持功能
- 更新程序详情页面布局和参数表单界面
- 移除模拟运行器相关测试代码
- 添加USB串口通信依赖包usb_serial
2026-06-04 15:13:36 +08:00
54 changed files with 3099 additions and 1252 deletions

View File

@@ -1,6 +1,6 @@
{
"pid": 67540,
"version": "0.9.7",
"pid": 111556,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7209b3ccb3579134",
"startedAt": 1780364708996
"startedAt": 1781073364017
}

View File

@@ -156,11 +156,11 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
- **字体**: Inter
- **圆角**: 4px工业控制风格
- **配色模式**: Light + Tonal Spot
- **注意**: Stitch 生成的画布尺寸为 2560×2048实际开发需适配 1920×1080 横屏
- **注意**: Stitch 生成的画布尺寸为 2560×2048实际开发需适配 1024x600 横屏
---
## 其他说明
1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md)
2. 运行设备屏幕尺寸为1920*1080(横屏),UI设计必须支持此尺寸
2. 运行设备屏幕尺寸为1024x600(横屏),屏幕密度为200UI设计必须支持此尺寸

View File

@@ -131,7 +131,7 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
- 程序详情:步骤列表、步骤参数编辑
- 运行控制:启动/暂停/停止、进度监控(使用 MockRunner 模拟)
- 系统设置:基础页面框架
- 国际化:中/英文翻译配置
- 国际化:中/英文翻译配置UI 文本已全部通过 `AppLocalizations` 覆盖(含参数化方法)
待完善设备通信对接、实际硬件控制、U盘导入功能。
@@ -156,11 +156,11 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
- **字体**: Inter
- **圆角**: 4px工业控制风格
- **配色模式**: Light + Tonal Spot
- **注意**: Stitch 生成的画布尺寸为 2560×2048实际开发需适配 1920×1080 横屏
- **注意**: Stitch 生成的画布尺寸为 2560×2048实际开发需适配 1024x600 横屏
---
## 其他说明
1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md)
2. 运行设备屏幕尺寸为1920*1080(横屏),UI设计必须支持此尺寸
2. 运行设备屏幕尺寸为1024x600(横屏),UI设计必须支持此尺寸

171
README.md
View File

@@ -1,17 +1,166 @@
# kuaishai2
# AGENTS.md
A new Flutter project.
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Getting Started
## 项目概述
This project is a starting point for a Flutter application.
污水毒品快检一体机控制软件kuaishai2用于控制设备运行程序、管理程序配置、监控运行状态。
A few resources to get you started if this is your first Flutter project:
**包名**: com.xiarui.kuaishai2
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
## 开发命令
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
```bash
# 获取依赖
flutter pub get
# 运行应用 (调试模式)
flutter run
# 构建 APK
flutter build apk
# 构建发布版 APK
flutter build apk --release
# 代码分析
flutter analyze
# 运行测试
flutter test
# 清理构建缓存
flutter clean
```
## 代码架构
项目采用 **feature-first** 分层架构,结构如下:
```
lib/
├── core/ # 核心模块
│ ├── database/ # SQLite 数据库服务 (单例)
│ ├── router/ # GoRouter 路由配置
│ ├── theme/ # AppTheme 主题定义
│ └── localization/ # AppLocalizations 国际化
├── features/ # 功能模块
│ ├── home/ # 首页(设备控制面板)
│ ├── programs/ # 程序管理
│ ├── program_detail/ # 程序详情/步骤配置
│ ├── device/ # 设备运行控制
│ └── settings/ # 系统设置
└── shared/ # 共享组件
├── widgets/ # 通用组件 (CommonButton, CommonCard, StatusIndicator)
└── utils/ # 常量定义 (Constants)
```
### 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 状态管理 | flutter_riverpod | StateNotifier + Provider 模式 |
| 路由 | go_router | 声明式路由,支持路径参数 |
| 数据持久化 | sqflite | SQLite 本地数据库 |
| 国际化 | intl + flutter_localizations | 中/英双语 |
### 核心数据模型
**Program** (程序): `id`, `code`, `name`, `createdAt`, `status(1启用/0停用)`
**Step** (步骤): `id`, `programId`, `stepNo`, `position`, `name`, `mixTime`, `magnetTime`, `volume`, `mixSpeed`, `blowSpeed`, `blowTime`, `needleSpeed`
### 关键 Provider
- `programsProvider` - 程序列表状态 (StateNotifier)
- `runStateProvider` - 设备运行状态 (RunStatus: idle/running/paused/completed/error)
- `goRouterProvider` - 路由配置
### 数据库
DatabaseService 为单例模式,表结构:
- `programs` - 程序表
- `steps` - 步骤表 (外键关联 program_id)
### 国际化
AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(context)` 获取翻译。
## 代码规范
- Dart SDK 版本: ^3.11.5
- 使用 `flutter_lints` 包的推荐 lint 规则
- 代码风格遵循 Flutter 官方最佳实践
## 功能模块
根据需求文档,应用包含以下核心模块:
### 1. 首页模块(设备控制面板)
- 状态栏:设备名称、实时时钟、系统状态、照明控制
- 程序列表:卡片展示、程序选择、查看详情
- 运行控制:启动/暂停/停止程序
- 运行状态监控:当前步骤、剩余时间、总进度
### 2. 程序管理模块
- 程序列表:编号、名称、创建时间、状态
- CRUD 操作:新增、编辑、删除、导入程序
### 3. 程序详情模块
- 步骤管理:步骤列表、排序、增删改
- 步骤参数:孔位、混合时间、吸磁时间、容积、速度等
### 4. 系统设置模块
- 软件升级、语言切换(中文/英文、密码修改、U盘导入
## 步骤参数说明
| 参数 | 说明 | 取值范围 |
|------|------|----------|
| 孔位 | 操作位置 | A1, A2, B1... |
| 混合时间 | 混合持续时间 | 正整数(秒) |
| 吸磁时间 | 磁珠吸附时间 | 正整数(秒) |
| 容积 | 液体体积 | 正整数(μL) |
| 混合/吹气速度 | 操作速度 | 低速/中速/高速 |
| 下针速度 | 针头下移速度 | 1-10档 |
## 当前实现状态
已完成基础架构搭建,实现了:
- 首页:状态栏、程序列表、运行控制面板、运行状态监控
- 程序管理:列表展示、新增/编辑/删除、状态切换
- 程序详情:步骤列表、步骤参数编辑
- 运行控制:启动/暂停/停止、进度监控(使用 MockRunner 模拟)
- 系统设置:基础页面框架
- 国际化:中/英文翻译配置
待完善设备通信对接、实际硬件控制、U盘导入功能。
## UI 设计稿
**Stitch 项目链接**: https://stitch.withgoogle.com/projects/16230138564963723693
### 页面截图链接
| 页面 | 功能描述 | 截图链接 |
|------|----------|----------|
| 首页 - 待机状态 | 状态栏 + 程序列表 + 开始运行按钮 + 瓷套棒确认 | [查看](https://lh3.googleusercontent.com/aida/ADBb0ujbe00elAXxTbD-KomnaatYJ40X-ubwTDSaI--jR_FRjPow3DJn7S61WpX9NxIMfgAyup6KXC_5qQWeZi0G9qQakQpvszL-OiaTU0C_yQ5FB-E1UMrystcYL0inmiky1Z2Ai-18NSYAdr0dJ6bbTp_dmJ4-JgmK0bjqdoUSnqlzo_Q1SwyNmbJb8WdNTKIUoXNudgLOCZURLWzONviyPgs9CqlxS1KUvopuxJymQF26kwxFO33lY6_vdLzD) |
| 首页 - 运行状态 | 实时监控:孔位、步骤、倒计时、进度条、暂停/停止控制 | [查看](https://lh3.googleusercontent.com/aida/ADBb0uj1bjMG6Fu-6-yvfL-Y51mJws-NBwlkX-GCbBBjLjb-4gNvMB_y8AESOu7X-UF_SHggCttMNpY6g88WgGOSJjw-1NOyTyigDBRInEwawYt8aXSGIGknLVw6zK_99aFnfturjzoYySuHBhKP2r34XWvfAO1g0e-as3hWBOaJW0gb2w8DSLmv3MpGLvsedJNq4SQFhl50LtNFM_zCMKjv-NtqyCE3y1vndNxnxCrJUy_BHvh4XHCahf18UnOU) |
| 程序管理 - 列表视图 | 程序表格:复选框、编号、名称、状态、批量操作、分页 | [查看](https://lh3.googleusercontent.com/aida/ADBb0ujE1ZBh5_Gn-yyy1bfU-U_xCeGHREsycgxyAlWBKzWbhFzvlusSWhCEY1u6RBRqVk5XZ49x8Ljpe4yXcS5e3q_jgGMGpcd1CN0RxX0nU6D75O056euNV7fY9cBNtTVGXibmM7Am1uj5uxV7TLbW8c_ix8WubRhn0gYFU6L87b80N6Zl-2T3lmBZsp98jwSsUXP3RqHk6CwMLoQ9LYcLRQTU5Dh5XD1e9aXus9I9pDgj-ZMUsFcZJA7XnJ0y) |
| 程序详情 - 步骤配置 | 左侧步骤列表 + 右侧参数表单(孔位、时间、速度等) | [查看](https://lh3.googleusercontent.com/aida/ADBb0ujEyurJOmwUkZx3ysQMSCk4pXU9_6Jh6bsEMhwlwyC88SQZ0hCFRHMhKADFecHdLXG3D-A5Hp3Avw25j1ivrP2CyTTdeXgd4QPCARNZetrezcL1iy0b7RYsVTTnXnzIFX846pRC_rxgbh8DdqMAiHPz8Q94JciAFCAokQfduFmDWw3Wzhj_P0KtlL8UmEIJNzGFi9ySTGo6eKmz6lfdfQ6VMowhYR5Qy8VGZi8qTLlXqFSU9f-dZepaJWs) |
| 系统设置 - 控制面板 | 软件升级、语言切换、密码修改、U盘导入 | [查看](https://lh3.googleusercontent.com/aida/ADBb0ugIEqBuSXmwYtQkPXXIJ7qBdL69VyQdTRzTyXjc5M6THuTBnbJlLyIDmvo7qfTz_RwjwZE_czzN6dyR_ydqk2Er5hNYduPLypOLqqR7I2sHaKDIvCeole91oJIVkAcnfJkWQJJPgaIhqfysMV4mrsHD0rhYJpngLorfE9WMl11tG9MPz9_E8XcmwaFqXqtYvxHeBsnvSNtIQfkX9S5QfnYtOiAsnUKioY3GpVvjK6bV4PYIfCMMHXv5koS8) |
| 运行完成 - 操作指引 | 成功提示 + 样本滴入说明 + 返回/重运行按钮 | [查看](https://lh3.googleusercontent.com/aida/ADBb0ujh5-3mBPpjWkSRHGOzNRQuusc4O7p_Kov2dbd3iiJmOHlElBb9-gPWC5bnfnRuDbhIbZDhxQhTfVkRpUhQU5nMGa5y7NpV4efvG6ZF0Bx4aVdm4HJ-o2Qca-alJuSfgsggJgbZ3QptA_cAXx-VfG79sHB1zUvRDHpPkLSu0lePhmjaR6m6-pwR42Pib7OLSNqS6V9208w7HoGCX1cPC6pH-hi8rZrMbMmbk2p-pTd2tJebNsow9aLb6bLJ) |
### 设计规范
- **主色调**: #2196F3(蓝色)
- **字体**: Inter
- **圆角**: 4px工业控制风格
- **配色模式**: Light + Tonal Spot
- **注意**: Stitch 生成的画布尺寸为 2560×2048实际开发需适配 1024x600 横屏
---
## 其他说明
1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md)
2. 运行设备屏幕尺寸为1024x600(横屏),屏幕密度为200UI设计必须支持此尺寸

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- USB Host与下位机USB 转串口)通信 -->
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<application
android:label="kuaishai2"
android:name="${applicationName}"
@@ -28,6 +31,15 @@
<category android:name="android.intent.category.HOME"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<!-- USB 设备过滤当下位机USB 转串口)插入时,系统会启动本 Activity 并附带设备信息。
配合 @xml/usb_device_filter 中声明的 vendor-id 列表使用。
若下位机使用其它芯片,可在此追加 vendor-id。 -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues

View File

@@ -1,10 +1,18 @@
package com.xiarui.kuaishai2
import android.content.ContentValues
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
/**
* Kiosk 模式主 Activity
@@ -14,12 +22,80 @@ import io.flutter.embedding.android.FlutterActivity
*/
class MainActivity : FlutterActivity() {
private val downloadsChannel = "com.xiarui.kuaishai2/downloads"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 保持屏幕常亮
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadsChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveToDownloads" -> handleSaveToDownloads(call, result)
else -> result.notImplemented()
}
}
}
private fun handleSaveToDownloads(
call: io.flutter.plugin.common.MethodCall,
result: MethodChannel.Result,
) {
val filename = call.argument<String>("filename")
val bytes = call.argument<ByteArray>("bytes")
if (filename.isNullOrEmpty() || bytes == null) {
result.error("ARG_ERROR", "filename 和 bytes 必填", null)
return
}
try {
val savedPath = saveToDownloads(filename, bytes)
result.success(savedPath)
} catch (e: Exception) {
result.error("SAVE_FAILED", e.message ?: "保存失败", null)
}
}
/**
* 保存到公共 Downloads 目录。
* - Android 10+:通过 MediaStore.Downloads 写入,无需 WRITE_EXTERNAL_STORAGE 权限
* - Android 9 及以下:直接写 /storage/emulated/0/Download/,需 WRITE_EXTERNAL_STORAGE
*/
private fun saveToDownloads(filename: String, bytes: ByteArray): String {
val mimeType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, filename)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = contentResolver
val uri = resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
values,
) ?: throw IllegalStateException("无法创建 MediaStore 记录")
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: throw IllegalStateException("无法打开输出流")
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
"${Environment.DIRECTORY_DOWNLOADS}/$filename"
} else {
@Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS,
)
if (!dir.exists()) dir.mkdirs()
val file = File(dir, filename)
FileOutputStream(file).use { it.write(bytes) }
file.absolutePath
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
USB 设备过滤:下位机使用常见 USB 转串口芯片时自动识别
包含CH340 / CH341 / CH343 / CH9103、FTDI、CP210x、PL2303
若下位机使用其他芯片,可继续追加 vendor-id + product-id
-->
<resources>
<usb-device vendor-id="0x1A86" /> <!-- CH340 / CH341 / CH343 / CH9103 系列 -->
<usb-device vendor-id="0x0403" /> <!-- FTDI 系列 -->
<usb-device vendor-id="0x10C4" /> <!-- Silicon Labs CP210x 系列 -->
<usb-device vendor-id="0x067B" /> <!-- Prolific PL2303 系列 -->
</resources>

View File

@@ -0,0 +1,178 @@
---
title: 默认模块
language_tabs:
- shell: Shell
- http: HTTP
- javascript: JavaScript
- ruby: Ruby
- python: Python
- php: PHP
- java: Java
- go: Go
toc_footers: []
includes: []
search: true
code_clipboard: true
highlight_theme: darkula
headingLevel: 2
generator: "@tarslib/widdershins v4.0.30"
---
# 默认模块
Base URLs:
# Authentication
# 数据模型
<h2 id="tocS_设备基本信息">设备基本信息</h2>
<a id="schema设备基本信息"></a>
<a id="schema_设备基本信息"></a>
<a id="tocS设备基本信息"></a>
<a id="tocs设备基本信息"></a>
```json
{
"message_id": "string",
"type": "string",
"ack": "string",
"need_ack": true,
"data": {
"door_status": "string",
"task_status": "string",
"light_status": "string"
}
}
```
### 属性
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|message_id|string|true|none||none|
|type|string|true|none|类型|device_info|
|ack|string|true|none||none|
|need_ack|boolean|true|none||none|
|data|object|true|none||none|
|» door_status|string|true|none|门状态|open=开close=关|
|» task_status|string|true|none|任务运行状态|running=运行中pause=暂停idle=空闲|
|» light_status|string|true|none|灯状态|on=开off= 关|
<h2 id="tocS_下发任务">下发任务</h2>
<a id="schema下发任务"></a>
<a id="schema_下发任务"></a>
<a id="tocS下发任务"></a>
<a id="tocs下发任务"></a>
```json
{
"message_id": "string",
"type": "string",
"ack": "string",
"need_ack": true,
"data": {
"steps": [
{
"no": 0,
"slot": 0,
"name": "string",
"mixtime": 0,
"pulltime": 0,
"volume": 0,
"speed": 0
}
],
"temperature": 0,
"airflowtime": 0
}
}
```
### 属性
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|message_id|string|true|none||由uuid生成唯一识别码|
|type|string|true|none|指令类型|create_task|
|ack|string|true|none||需要响应的消息id|
|need_ack|boolean|true|none|是否需要响应|true|
|data|object|true|none||none|
|» steps|[object]|true|none|步骤列表|none|
|»» no|integer|true|none|步骤号|none|
|»» slot|integer|true|none|槽位号|none|
|»» name|string|true|none|步骤名称|none|
|»» mixtime|integer|true|none|搅拌时间|单位:秒|
|»» pulltime|integer|true|none|吸磁时间|单位:秒|
|»» volume|integer|true|none|容积|范围 0-2000|
|»» speed|integer|true|none|速度等级|范围1-10|
|» temperature|integer|true|none|加热温度|none|
|» airflowtime|integer|true|none|吹气时间|单位:秒|
<h2 id="tocS_灯光控制">灯光控制</h2>
<a id="schema灯光控制"></a>
<a id="schema_灯光控制"></a>
<a id="tocS灯光控制"></a>
<a id="tocs灯光控制"></a>
```json
{
"message_id": "string",
"type": "string",
"ack": " ",
"need_ack": true,
"data": {
"status": "string"
}
}
```
### 属性
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|message_id|string|true|none||none|
|type|string|true|none|类型|none|
|ack|string|true|none||none|
|need_ack|boolean|true|none||none|
|data|object|true|none||none|
|» status|string|true|none|开光|on / off|
<h2 id="tocS_任务控制">任务控制</h2>
<a id="schema任务控制"></a>
<a id="schema_任务控制"></a>
<a id="tocS任务控制"></a>
<a id="tocs任务控制"></a>
```json
{
"message_id": "string",
"type": "string",
"ack": "string",
"need_ack": true,
"data": {
"status": "string"
}
}
```
### 属性
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|message_id|string|true|none||none|
|type|string|true|none|类型|control|
|ack|string¦null|true|none||none|
|need_ack|boolean|true|none||none|
|data|object|true|none||none|
|» status|string|true|none|状态|continue=继续stop=停止,暂停=pause|

View File

@@ -20,7 +20,7 @@ class DatabaseService {
return await openDatabase(
path,
version: 2,
version: 3,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -34,7 +34,9 @@ class DatabaseService {
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
status INTEGER DEFAULT 1
status INTEGER DEFAULT 1,
temperature INTEGER DEFAULT 50,
airflow_time INTEGER DEFAULT 60
)
''');
@@ -49,10 +51,8 @@ class DatabaseService {
mix_time INTEGER DEFAULT 0,
magnet_time INTEGER DEFAULT 0,
volume INTEGER DEFAULT 0,
mix_speed TEXT DEFAULT '中速',
blow_speed TEXT DEFAULT '中速',
blow_time INTEGER DEFAULT 0,
needle_speed INTEGER DEFAULT 5,
speed INTEGER DEFAULT 5,
FOREIGN KEY (program_id) REFERENCES programs(id) ON DELETE CASCADE
)
''');
@@ -82,6 +82,15 @@ class DatabaseService {
// 初始化默认密码
await db.insert('settings', {'key': 'password', 'value': '123456'});
}
if (oldVersion < 3) {
// v3: 重构字段
// programs 增加 temperature, airflow_time
await db.execute('ALTER TABLE programs ADD COLUMN temperature INTEGER DEFAULT 50');
await db.execute('ALTER TABLE programs ADD COLUMN airflow_time INTEGER DEFAULT 60');
// steps 删除 mix_speed, blow_speed, needle_speed增加 speed
// sqflite 不支持 DROP COLUMN旧字段保留但不再使用
await db.execute('ALTER TABLE steps ADD COLUMN speed INTEGER DEFAULT 5');
}
}
Future<void> close() async {
@@ -91,6 +100,30 @@ class DatabaseService {
}
}
/// 通用 KV 读:读取 settings 表中 [key] 对应的 value不存在返回 null
Future<String?> readSetting(String key) async {
final db = await database;
final rows = await db.query(
'settings',
columns: ['value'],
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['value'] as String?;
}
/// 通用 KV 写:以 INSERT OR REPLACE 写入 settings 表
Future<void> writeSetting(String key, String value) async {
final db = await database;
await db.insert(
'settings',
{'key': key, 'value': value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// 初始化测试数据(仅调试模式使用)
Future<void> initTestData() async {
final db = await database;
@@ -103,11 +136,11 @@ class DatabaseService {
// 插入测试程序并添加步骤
final testPrograms = [
{'code': 'P001', 'name': '标准检测程序', 'created_at': '2026-05-19', 'status': 1},
{'code': 'P002', 'name': '快速检测程序', 'created_at': '2026-05-18', 'status': 1},
{'code': 'P003', 'name': '深度检测程序', 'created_at': '2026-05-17', 'status': 1},
{'code': 'P004', 'name': '样本预处理程序', 'created_at': '2026-05-16', 'status': 0},
{'code': 'P005', 'name': '磁珠分离程序', 'created_at': '2026-05-15', 'status': 1},
{'code': 'P001', 'name': '标准检测程序', 'created_at': '2026-05-19', 'status': 1, 'temperature': 50, 'airflow_time': 60},
{'code': 'P002', 'name': '快速检测程序', 'created_at': '2026-05-18', 'status': 1, 'temperature': 45, 'airflow_time': 30},
{'code': 'P003', 'name': '深度检测程序', 'created_at': '2026-05-17', 'status': 1, 'temperature': 60, 'airflow_time': 90},
{'code': 'P004', 'name': '样本预处理程序', 'created_at': '2026-05-16', 'status': 0, 'temperature': 50, 'airflow_time': 60},
{'code': 'P005', 'name': '磁珠分离程序', 'created_at': '2026-05-15', 'status': 1, 'temperature': 55, 'airflow_time': 60},
];
for (final program in testPrograms) {
@@ -123,10 +156,8 @@ class DatabaseService {
'mix_time': 60,
'magnet_time': 0,
'volume': 100,
'mix_speed': '中速',
'blow_speed': '中速',
'blow_time': 0,
'needle_speed': 5,
'speed': 5,
},
{
'program_id': programId,
@@ -136,10 +167,8 @@ class DatabaseService {
'mix_time': 0,
'magnet_time': 30,
'volume': 0,
'mix_speed': '中速',
'blow_speed': '中速',
'blow_time': 0,
'needle_speed': 5,
'speed': 5,
},
{
'program_id': programId,
@@ -149,10 +178,8 @@ class DatabaseService {
'mix_time': 0,
'magnet_time': 0,
'volume': 0,
'mix_speed': '中速',
'blow_speed': '高速',
'blow_time': 10,
'needle_speed': 8,
'speed': 8,
},
];

View File

@@ -18,6 +18,7 @@ class AppLocalizations {
String get running => _localizedValues[locale.languageCode]?['running'] ?? '运行中';
String get idle => _localizedValues[locale.languageCode]?['idle'] ?? '未运行';
String get lighting => _localizedValues[locale.languageCode]?['lighting'] ?? '照明';
String get deviceControl => _localizedValues[locale.languageCode]?['deviceControl'] ?? '设备控制';
// 程序管理
String get programs => _localizedValues[locale.languageCode]?['programs'] ?? '程序管理';
@@ -29,17 +30,18 @@ class AppLocalizations {
String get editProgram => _localizedValues[locale.languageCode]?['editProgram'] ?? '编辑程序';
String get deleteProgram => _localizedValues[locale.languageCode]?['deleteProgram'] ?? '删除程序';
String get importProgram => _localizedValues[locale.languageCode]?['importProgram'] ?? '导入程序';
String get downloadTemplate => _localizedValues[locale.languageCode]?['downloadTemplate'] ?? '下载模板';
String get viewDetails => _localizedValues[locale.languageCode]?['viewDetails'] ?? '查看详情';
String get selectedProgram => _localizedValues[locale.languageCode]?['selectedProgram'] ?? '当前选中程序';
String get selectedProgramLabel => _localizedValues[locale.languageCode]?['selectedProgramLabel'] ?? '当前选中';
String get availablePrograms => _localizedValues[locale.languageCode]?['availablePrograms'] ?? '可用程序';
String get ceramicNotInstalled => _localizedValues[locale.languageCode]?['ceramicNotInstalled'] ?? '瓷套棒: 未安装 — 禁止启动';
String get ceramicInstalled => _localizedValues[locale.languageCode]?['ceramicInstalled'] ?? '瓷套棒: 已安装';
String get runningMonitor => _localizedValues[locale.languageCode]?['runningMonitor'] ?? '运行状态监控';
String get currentHole => _localizedValues[locale.languageCode]?['currentHole'] ?? '当前孔位';
String get stepParams => _localizedValues[locale.languageCode]?['stepParams'] ?? '步骤参数';
String get speed => _localizedValues[locale.languageCode]?['speed'] ?? '';
String get speed => _localizedValues[locale.languageCode]?['speed'] ?? '';
String get speedLevel => _localizedValues[locale.languageCode]?['speedLevel'] ?? '';
String get temperature => _localizedValues[locale.languageCode]?['temperature'] ?? '温度';
String get airflowTime => _localizedValues[locale.languageCode]?['airflowTime'] ?? '吹气时间';
String get duration => _localizedValues[locale.languageCode]?['duration'] ?? '持续时间';
String get sampleVolume => _localizedValues[locale.languageCode]?['sampleVolume'] ?? '样品体积';
String get pleaseSelectProgram => _localizedValues[locale.languageCode]?['pleaseSelectProgram'] ?? '请选择要运行的程序';
@@ -54,6 +56,7 @@ class AppLocalizations {
String get remainingTime => _localizedValues[locale.languageCode]?['remainingTime'] ?? '剩余时间';
String get progress => _localizedValues[locale.languageCode]?['progress'] ?? '进度';
String get ceramicSleeveConfirm => _localizedValues[locale.languageCode]?['ceramicSleeveConfirm'] ?? '运行前请确认已安装瓷套棒';
String get ceramicSleeveConfirmMessage => _localizedValues[locale.languageCode]?['ceramicSleeveConfirmMessage'] ?? '请确认已放置瓷套棒后再启动程序。';
String get paused => _localizedValues[locale.languageCode]?['paused'] ?? '已暂停';
String get stopConfirm => _localizedValues[locale.languageCode]?['stopConfirm'] ?? '确定要停止当前运行的程序吗?';
String get currentProgram => _localizedValues[locale.languageCode]?['currentProgram'] ?? '当前程序';
@@ -68,15 +71,7 @@ class AppLocalizations {
String get mixTime => _localizedValues[locale.languageCode]?['mixTime'] ?? '混合时间';
String get magnetTime => _localizedValues[locale.languageCode]?['magnetTime'] ?? '吸磁时间';
String get volume => _localizedValues[locale.languageCode]?['volume'] ?? '容积';
String get mixSpeed => _localizedValues[locale.languageCode]?['mixSpeed'] ?? '混合速度';
String get blowSpeed => _localizedValues[locale.languageCode]?['blowSpeed'] ?? '吹气速度';
String get blowTime => _localizedValues[locale.languageCode]?['blowTime'] ?? '吹气时间';
String get needleSpeed => _localizedValues[locale.languageCode]?['needleSpeed'] ?? '下针速度';
// 速度选项
String get lowSpeed => _localizedValues[locale.languageCode]?['lowSpeed'] ?? '低速';
String get mediumSpeed => _localizedValues[locale.languageCode]?['mediumSpeed'] ?? '中速';
String get highSpeed => _localizedValues[locale.languageCode]?['highSpeed'] ?? '高速';
// 设置
String get settings => _localizedValues[locale.languageCode]?['settings'] ?? '系统设置';
@@ -98,6 +93,7 @@ class AppLocalizations {
// 完成提示
String get runComplete => _localizedValues[locale.languageCode]?['runComplete'] ?? '运行完成';
String get sampleDropGuide => _localizedValues[locale.languageCode]?['sampleDropGuide'] ?? '请将样本滴入检测卡';
String get complete => _localizedValues[locale.languageCode]?['complete'] ?? '完成';
// 补充缺失的翻译
String get lightOn => _localizedValues[locale.languageCode]?['lightOn'] ?? '';
@@ -134,12 +130,148 @@ class AppLocalizations {
String get updateAvailable => _localizedValues[locale.languageCode]?['updateAvailable'] ?? '有新版本可用';
String get checkUpdate => _localizedValues[locale.languageCode]?['checkUpdate'] ?? '检查更新';
// ---- 状态 ----
String get status => _localizedValues[locale.languageCode]?['status'] ?? '状态';
String get operation => _localizedValues[locale.languageCode]?['operation'] ?? '操作';
String get serialConnected => _localizedValues[locale.languageCode]?['serialConnected'] ?? '已连接';
String get serialConnecting => _localizedValues[locale.languageCode]?['serialConnecting'] ?? '连接中';
String get serialDisconnected => _localizedValues[locale.languageCode]?['serialDisconnected'] ?? '未连接';
String get serialError => _localizedValues[locale.languageCode]?['serialError'] ?? '错误';
// ---- 完成页 ----
String get programRunComplete => _localizedValues[locale.languageCode]?['programRunComplete'] ?? '程序运行完成';
String get extractSample => _localizedValues[locale.languageCode]?['extractSample'] ?? '取出样本';
String get dropToTestCard => _localizedValues[locale.languageCode]?['dropToTestCard'] ?? '滴入检测卡';
String get waitForReaction => _localizedValues[locale.languageCode]?['waitForReaction'] ?? '等待反应';
String get viewResults => _localizedValues[locale.languageCode]?['viewResults'] ?? '查看结果';
// ---- 表单 ----
String get codeLabel => _localizedValues[locale.languageCode]?['codeLabel'] ?? '编号';
String get nameLabel => _localizedValues[locale.languageCode]?['nameLabel'] ?? '名称';
String get enterCode => _localizedValues[locale.languageCode]?['enterCode'] ?? '请输入编号';
String get enterName => _localizedValues[locale.languageCode]?['enterName'] ?? '请输入名称';
String get enterStepName => _localizedValues[locale.languageCode]?['enterStepName'] ?? '请输入步骤名称';
String get hintCode => _localizedValues[locale.languageCode]?['hintCode'] ?? '例如: P001';
String get hintProgramName => _localizedValues[locale.languageCode]?['hintProgramName'] ?? '请输入程序名称';
String get hintStepName => _localizedValues[locale.languageCode]?['hintStepName'] ?? '例如: 混合、吸磁、吹气';
String get saveFailed => _localizedValues[locale.languageCode]?['saveFailed'] ?? '保存失败,请检查编号是否重复';
String get saved => _localizedValues[locale.languageCode]?['saved'] ?? '已保存';
String get stepUpdated => _localizedValues[locale.languageCode]?['stepUpdated'] ?? '步骤已更新';
String get add => _localizedValues[locale.languageCode]?['add'] ?? '添加';
String get reset => _localizedValues[locale.languageCode]?['reset'] ?? '重置';
String get confirmNewPassword => _localizedValues[locale.languageCode]?['confirmNewPassword'] ?? '确认新密码';
String get enterNewPassword => _localizedValues[locale.languageCode]?['enterNewPassword'] ?? '再次输入新密码';
String get enterCurrentPassword => _localizedValues[locale.languageCode]?['enterCurrentPassword'] ?? '请输入当前密码';
String get newPwdMinLength => _localizedValues[locale.languageCode]?['newPwdMinLength'] ?? '新密码至少6位字符';
String get defaultPassword => _localizedValues[locale.languageCode]?['defaultPassword'] ?? '默认密码为 123456';
// ---- 删除确认 ----
String get deleteConfirmSingle => _localizedValues[locale.languageCode]?['deleteConfirmSingle'] ?? '确定要删除此程序吗?';
String deleteConfirmMultiple(int count) =>
locale.languageCode == 'en'
? 'Are you sure to delete $count selected programs?'
: '确定要删除选中的 $count 个程序吗?';
String get deleteStepConfirmSingle => _localizedValues[locale.languageCode]?['deleteStepConfirmSingle'] ?? '确定要删除此步骤吗?';
String deleteStepConfirmMultiple(int count) =>
locale.languageCode == 'en'
? 'Are you sure to delete $count selected steps?'
: '确定要删除选中的 $count 个步骤吗?';
// ---- 导入 ----
String get cannotReadFile => _localizedValues[locale.languageCode]?['cannotReadFile'] ?? '无法读取文件';
String processedPrograms(int count) =>
locale.languageCode == 'en'
? 'Successfully processed $count programs'
: '成功处理 $count 个程序';
String get noValidProgramData => _localizedValues[locale.languageCode]?['noValidProgramData'] ?? 'Excel 中无有效程序数据';
String get templateSaved => _localizedValues[locale.languageCode]?['templateSaved'] ?? '模板已保存';
String get generateTemplateFailed => _localizedValues[locale.languageCode]?['generateTemplateFailed'] ?? '生成模板失败';
String get importingPrograms => _localizedValues[locale.languageCode]?['importingPrograms'] ?? '正在导入程序...';
// ---- 语言设置 ----
String get languageSettings => _localizedValues[locale.languageCode]?['languageSettings'] ?? '语言设置';
String get switchLanguageEffect => _localizedValues[locale.languageCode]?['switchLanguageEffect'] ?? '切换语言后立即生效';
// ---- 串口配置 ----
String get serialStatus => _localizedValues[locale.languageCode]?['serialStatus'] ?? '串口状态';
String get availableSerialDevices => _localizedValues[locale.languageCode]?['availableSerialDevices'] ?? '可用串口设备';
String get refresh => _localizedValues[locale.languageCode]?['refresh'] ?? '刷新';
String get noSerialDevice => _localizedValues[locale.languageCode]?['noSerialDevice'] ?? '未检测到 USB 串口设备';
String get serialDeviceHint => _localizedValues[locale.languageCode]?['serialDeviceHint'] ?? '请确认下位机已上电并通过 USB 接入设备';
String get scanFailed => _localizedValues[locale.languageCode]?['scanFailed'] ?? '扫描设备失败';
String get selectSerialFirst => _localizedValues[locale.languageCode]?['selectSerialFirst'] ?? '请先选择串口设备';
String get connectSuccess => _localizedValues[locale.languageCode]?['connectSuccess'] ?? '连接成功';
String get connectFailed => _localizedValues[locale.languageCode]?['connectFailed'] ?? '连接失败';
String get disconnected => _localizedValues[locale.languageCode]?['disconnected'] ?? '已断开串口';
String get connectFirst => _localizedValues[locale.languageCode]?['connectFirst'] ?? '请先连接串口';
String get sendTestFrame => _localizedValues[locale.languageCode]?['sendTestFrame'] ?? '发送测试帧';
String get serialParams => _localizedValues[locale.languageCode]?['serialParams'] ?? '串口参数';
String get baudRate => _localizedValues[locale.languageCode]?['baudRate'] ?? '波特率';
String get dataBits => _localizedValues[locale.languageCode]?['dataBits'] ?? '数据位';
String get stopBits => _localizedValues[locale.languageCode]?['stopBits'] ?? '停止位';
String get parity => _localizedValues[locale.languageCode]?['parity'] ?? '校验位';
String get parityNone => _localizedValues[locale.languageCode]?['parityNone'] ?? '';
String get parityOdd => _localizedValues[locale.languageCode]?['parityOdd'] ?? '';
String get parityEven => _localizedValues[locale.languageCode]?['parityEven'] ?? '';
String get parityMark => _localizedValues[locale.languageCode]?['parityMark'] ?? '标记';
String get paritySpace => _localizedValues[locale.languageCode]?['paritySpace'] ?? '';
String get flowControl => _localizedValues[locale.languageCode]?['flowControl'] ?? '流控';
String get autoSaveParams => _localizedValues[locale.languageCode]?['autoSaveParams'] ?? '参数修改后自动保存';
String get connect => _localizedValues[locale.languageCode]?['connect'] ?? '连接';
String get disconnect => _localizedValues[locale.languageCode]?['disconnect'] ?? '断开';
String get unknownError => _localizedValues[locale.languageCode]?['unknownError'] ?? '未知错误';
// ---- USB 导入 ----
String get usbConnected => _localizedValues[locale.languageCode]?['usbConnected'] ?? 'U盘已连接';
String get mountPath => _localizedValues[locale.languageCode]?['mountPath'] ?? '挂载路径';
String get reDetect => _localizedValues[locale.languageCode]?['reDetect'] ?? '重新检测';
String get usageInstructions => _localizedValues[locale.languageCode]?['usageInstructions'] ?? '使用说明';
String get usbUsageStep1 => _localizedValues[locale.languageCode]?['usbUsageStep1'] ?? '将程序文件 (.json) 放入 U盘根目录的 programs 文件夹';
String get usbUsageStep2 => _localizedValues[locale.languageCode]?['usbUsageStep2'] ?? '插入 U盘后点击"重新检测"';
String get usbUsageStep3 => _localizedValues[locale.languageCode]?['usbUsageStep3'] ?? '检测成功后点击"导入程序"加载程序列表';
String get usbPathInvalid => _localizedValues[locale.languageCode]?['usbPathInvalid'] ?? 'U盘路径无效';
// ---- 启动认证 ----
String get authTitle => _localizedValues[locale.languageCode]?['authTitle'] ?? '身份验证';
String get authSubtitle => _localizedValues[locale.languageCode]?['authSubtitle'] ?? '请输入操作员密码以继续使用';
String get enterPassword => _localizedValues[locale.languageCode]?['enterPassword'] ?? '请输入密码';
String get passwordError => _localizedValues[locale.languageCode]?['passwordError'] ?? '密码错误';
String lockCountdown(int seconds) =>
locale.languageCode == 'en'
? 'Please wait $seconds seconds to retry'
: '请等待 $seconds 秒后重试';
String remainingAttempts(int count) =>
locale.languageCode == 'en'
? '$count attempt${count > 1 ? 's' : ''} remaining'
: '剩余 $count 次尝试机会';
// ---- 通用 ----
String get back => _localizedValues[locale.languageCode]?['back'] ?? '返回';
String get totalProgress => _localizedValues[locale.languageCode]?['totalProgress'] ?? '总进度';
String get appTitle => _localizedValues[locale.languageCode]?['appTitle'] ?? '污水毒品快检一体机';
// ---- 参数化方法 ----
String get stepUnit => _localizedValues[locale.languageCode]?['stepUnit'] ?? '';
String speedLevelValue(int speed) =>
locale.languageCode == 'en'
? '$speed level'
: '$speed';
String stepLabel(int stepNo) =>
locale.languageCode == 'en'
? 'Step $stepNo'
: '步骤 $stepNo';
String stepsCountLabel(int count) =>
locale.languageCode == 'en'
? '$count steps'
: '$count';
static final Map<String, Map<String, String>> _localizedValues = {
'zh': {
'deviceName': '污水毒品前处理一体机',
'running': '运行中',
'idle': '未运行',
'lighting': '照明',
'deviceControl': '设备控制',
'programs': '程序管理',
'programList': '程序列表',
'programName': '程序名称',
@@ -149,17 +281,18 @@ class AppLocalizations {
'editProgram': '编辑程序',
'deleteProgram': '删除程序',
'importProgram': '导入程序',
'downloadTemplate': '下载模板',
'viewDetails': '查看详情',
'selectedProgram': '当前选中程序',
'selectedProgramLabel': '当前选中',
'availablePrograms': '可用程序',
'ceramicNotInstalled': '瓷套棒: 未安装 — 禁止启动',
'ceramicInstalled': '瓷套棒: 已安装',
'runningMonitor': '运行状态监控',
'currentHole': '当前孔位',
'stepParams': '步骤参数',
'speed': '',
'speed': '',
'speedLevel': '',
'temperature': '温度',
'airflowTime': '吹气时间',
'duration': '持续时间',
'sampleVolume': '样品体积',
'pleaseSelectProgram': '请选择要运行的程序',
@@ -172,6 +305,7 @@ class AppLocalizations {
'remainingTime': '剩余时间',
'progress': '进度',
'ceramicSleeveConfirm': '运行前请确认已安装瓷套棒',
'ceramicSleeveConfirmMessage': '请确认已放置瓷套棒后再启动程序。',
'paused': '已暂停',
'stopConfirm': '确定要停止当前运行的程序吗?',
'currentProgram': '当前程序',
@@ -184,13 +318,7 @@ class AppLocalizations {
'mixTime': '混合时间',
'magnetTime': '吸磁时间',
'volume': '容积',
'mixSpeed': '混合速度',
'blowSpeed': '吹气速度',
'blowTime': '吹气时间',
'needleSpeed': '下针速度',
'lowSpeed': '低速',
'mediumSpeed': '中速',
'highSpeed': '高速',
'settings': '系统设置',
'language': '语言设置',
'password': '密码修改',
@@ -206,6 +334,7 @@ class AppLocalizations {
'noData': '暂无数据',
'runComplete': '运行完成',
'sampleDropGuide': '请将样本滴入检测卡',
'complete': '完成',
'lightOn': '',
'lightOff': '',
'enabled': '启用',
@@ -239,12 +368,94 @@ class AppLocalizations {
'latestVersion': '已是最新版本',
'updateAvailable': '有新版本可用',
'checkUpdate': '检查更新',
'status': '状态',
'operation': '操作',
'serialConnected': '已连接',
'serialConnecting': '连接中',
'serialDisconnected': '未连接',
'serialError': '错误',
'programRunComplete': '程序运行完成',
'extractSample': '取出样本',
'dropToTestCard': '滴入检测卡',
'waitForReaction': '等待反应',
'viewResults': '查看结果',
'codeLabel': '编号',
'nameLabel': '名称',
'enterCode': '请输入编号',
'enterName': '请输入名称',
'enterStepName': '请输入步骤名称',
'hintCode': '例如: P001',
'hintProgramName': '请输入程序名称',
'hintStepName': '例如: 混合、吸磁、吹气',
'saveFailed': '保存失败,请检查编号是否重复',
'saved': '已保存',
'stepUpdated': '步骤已更新',
'add': '添加',
'reset': '重置',
'confirmNewPassword': '确认新密码',
'enterNewPassword': '再次输入新密码',
'enterCurrentPassword': '请输入当前密码',
'newPwdMinLength': '新密码至少6位字符',
'defaultPassword': '默认密码为 123456',
'deleteConfirmSingle': '确定要删除此程序吗?',
'deleteStepConfirmSingle': '确定要删除此步骤吗?',
'cannotReadFile': '无法读取文件',
'noValidProgramData': 'Excel 中无有效程序数据',
'templateSaved': '模板已保存',
'generateTemplateFailed': '生成模板失败',
'importingPrograms': '正在导入程序...',
'languageSettings': '语言设置',
'switchLanguageEffect': '切换语言后立即生效',
'serialStatus': '串口状态',
'availableSerialDevices': '可用串口设备',
'refresh': '刷新',
'noSerialDevice': '未检测到 USB 串口设备',
'serialDeviceHint': '请确认下位机已上电并通过 USB 接入设备',
'scanFailed': '扫描设备失败',
'selectSerialFirst': '请先选择串口设备',
'connectSuccess': '连接成功',
'connectFailed': '连接失败',
'disconnected': '已断开串口',
'connectFirst': '请先连接串口',
'sendTestFrame': '发送测试帧',
'serialParams': '串口参数',
'baudRate': '波特率',
'dataBits': '数据位',
'stopBits': '停止位',
'parity': '校验位',
'parityNone': '',
'parityOdd': '',
'parityEven': '',
'parityMark': '标记',
'paritySpace': '',
'flowControl': '流控',
'autoSaveParams': '参数修改后自动保存',
'connect': '连接',
'disconnect': '断开',
'unknownError': '未知错误',
'usbConnected': 'U盘已连接',
'mountPath': '挂载路径',
'reDetect': '重新检测',
'usageInstructions': '使用说明',
'usbUsageStep1': '将程序文件 (.json) 放入 U盘根目录的 programs 文件夹',
'usbUsageStep2': '插入 U盘后点击"重新检测"',
'usbUsageStep3': '检测成功后点击"导入程序"加载程序列表',
'usbPathInvalid': 'U盘路径无效',
'authTitle': '身份验证',
'authSubtitle': '请输入操作员密码以继续使用',
'enterPassword': '请输入密码',
'passwordError': '密码错误',
'back': '返回',
'totalProgress': '总进度',
'appTitle': '污水毒品快检一体机',
'stepUnit': '',
},
'en': {
'deviceName': 'Wastewater Drug Pretreatment System',
'running': 'Running',
'idle': 'Idle',
'lighting': 'Lighting',
'deviceControl': 'Device Control',
'programs': 'Programs',
'programList': 'Program List',
'programName': 'Program Name',
@@ -254,17 +465,18 @@ class AppLocalizations {
'editProgram': 'Edit Program',
'deleteProgram': 'Delete Program',
'importProgram': 'Import Program',
'downloadTemplate': 'Download Template',
'viewDetails': 'View Details',
'selectedProgram': 'Selected Program',
'selectedProgramLabel': 'Selected',
'availablePrograms': 'Available Programs',
'ceramicNotInstalled': 'Ceramic sleeve: Not installed — Cannot start',
'ceramicInstalled': 'Ceramic sleeve: Installed',
'runningMonitor': 'Running Status Monitor',
'currentHole': 'Current Position',
'stepParams': 'Step Parameters',
'speed': 'Speed',
'speedLevel': 'level',
'temperature': 'Temperature',
'airflowTime': 'Airflow Time',
'duration': 'Duration',
'sampleVolume': 'Sample Volume',
'pleaseSelectProgram': 'Please select a program',
@@ -277,6 +489,7 @@ class AppLocalizations {
'remainingTime': 'Remaining',
'progress': 'Progress',
'ceramicSleeveConfirm': 'Please confirm ceramic sleeve is installed',
'ceramicSleeveConfirmMessage': 'Please make sure the ceramic sleeve is in place before starting the program.',
'paused': 'Paused',
'stopConfirm': 'Are you sure to stop the running program?',
'currentProgram': 'Current Program',
@@ -289,13 +502,7 @@ class AppLocalizations {
'mixTime': 'Mix Time',
'magnetTime': 'Magnet Time',
'volume': 'Volume',
'mixSpeed': 'Mix Speed',
'blowSpeed': 'Blow Speed',
'blowTime': 'Blow Time',
'needleSpeed': 'Needle Speed',
'lowSpeed': 'Low',
'mediumSpeed': 'Medium',
'highSpeed': 'High',
'settings': 'Settings',
'language': 'Language',
'password': 'Password',
@@ -311,6 +518,7 @@ class AppLocalizations {
'noData': 'No Data',
'runComplete': 'Complete',
'sampleDropGuide': 'Drop sample to test card',
'complete': 'Done',
'lightOn': 'On',
'lightOff': 'Off',
'enabled': 'Enabled',
@@ -344,6 +552,87 @@ class AppLocalizations {
'latestVersion': 'Already latest version',
'updateAvailable': 'Update available',
'checkUpdate': 'Check Update',
'status': 'Status',
'operation': 'Actions',
'serialConnected': 'Connected',
'serialConnecting': 'Connecting',
'serialDisconnected': 'Disconnected',
'serialError': 'Error',
'programRunComplete': 'Program Run Complete',
'extractSample': 'Extract Sample',
'dropToTestCard': 'Drop to Test Card',
'waitForReaction': 'Wait for Reaction',
'viewResults': 'View Results',
'codeLabel': 'Code',
'nameLabel': 'Name',
'enterCode': 'Please enter code',
'enterName': 'Please enter name',
'enterStepName': 'Please enter step name',
'hintCode': 'e.g. P001',
'hintProgramName': 'Enter program name',
'hintStepName': 'e.g. Mix, Magnet, Blow',
'saveFailed': 'Save failed, please check if code is duplicate',
'saved': 'Saved',
'stepUpdated': 'Step updated',
'add': 'Add',
'reset': 'Reset',
'confirmNewPassword': 'Confirm New Password',
'enterNewPassword': 'Re-enter new password',
'enterCurrentPassword': 'Enter current password',
'newPwdMinLength': 'New password at least 6 characters',
'defaultPassword': 'Default password is 123456',
'deleteConfirmSingle': 'Are you sure to delete this program?',
'deleteStepConfirmSingle': 'Are you sure to delete this step?',
'cannotReadFile': 'Cannot read file',
'noValidProgramData': 'No valid program data in Excel',
'templateSaved': 'Template saved',
'generateTemplateFailed': 'Failed to generate template',
'importingPrograms': 'Importing programs...',
'languageSettings': 'Language Settings',
'switchLanguageEffect': 'Language change takes effect immediately',
'serialStatus': 'Serial Status',
'availableSerialDevices': 'Available Serial Devices',
'refresh': 'Refresh',
'noSerialDevice': 'No USB serial device detected',
'serialDeviceHint': 'Please ensure the device is powered on and connected via USB',
'scanFailed': 'Device scan failed',
'selectSerialFirst': 'Please select a serial device first',
'connectSuccess': 'Connected successfully',
'connectFailed': 'Connection failed',
'disconnected': 'Serial disconnected',
'connectFirst': 'Please connect serial first',
'sendTestFrame': 'Send Test Frame',
'serialParams': 'Serial Parameters',
'baudRate': 'Baud Rate',
'dataBits': 'Data Bits',
'stopBits': 'Stop Bits',
'parity': 'Parity',
'parityNone': 'None',
'parityOdd': 'Odd',
'parityEven': 'Even',
'parityMark': 'Mark',
'paritySpace': 'Space',
'flowControl': 'Flow Control',
'autoSaveParams': 'Parameters auto-saved after change',
'connect': 'Connect',
'disconnect': 'Disconnect',
'unknownError': 'Unknown error',
'usbConnected': 'USB Connected',
'mountPath': 'Mount Path',
'reDetect': 'Re-detect',
'usageInstructions': 'Usage Instructions',
'usbUsageStep1': 'Place program files (.json) in the programs folder on USB root',
'usbUsageStep2': 'Insert USB then click "Re-detect"',
'usbUsageStep3': 'After detection, click "Import Programs" to load',
'usbPathInvalid': 'USB path invalid',
'authTitle': 'Authentication',
'authSubtitle': 'Enter operator password to continue',
'enterPassword': 'Enter password',
'passwordError': 'Password incorrect',
'back': 'Back',
'totalProgress': 'Total Progress',
'appTitle': 'Wastewater Drug Detection System',
'stepUnit': 'steps',
},
};
}

View File

@@ -1,21 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/pages/login_page.dart';
import '../../features/home/pages/home_page.dart';
import '../../features/programs/pages/programs_page.dart';
import '../../features/program_detail/pages/program_detail_page.dart';
import '../../features/settings/pages/settings_page.dart';
import '../../features/home/pages/complete_page.dart';
import '../../features/auth/providers/auth_provider.dart';
final _authRefreshStream = AuthRefreshStream();
/// 应用路由配置
final goRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
// 监听认证状态变化,触发 GoRouter 重新评估 redirect
ref.listen(authProvider, (prev, next) {
_authRefreshStream.notify();
});
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState.status == AuthStatus.authenticated;
final isLoginPage = state.uri.path == '/login';
if (!isAuthenticated && !isLoginPage) {
return '/login';
}
if (isAuthenticated && isLoginPage) {
return '/';
}
return null;
},
refreshListenable: _authRefreshStream,
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
builder: (context, state) {
final tabParam = state.uri.queryParameters['tab'];
final initialTab = int.tryParse(tabParam ?? '') ?? 0;
return HomePage(initialTab: initialTab);
},
),
GoRoute(
path: '/programs',
@@ -42,4 +78,9 @@ final goRouterProvider = Provider<GoRouter>((ref) {
),
],
);
});
});
/// 桥接 Riverpod 到 GoRouter 的 refreshListenable
class AuthRefreshStream extends ChangeNotifier {
void notify() => notifyListeners();
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../providers/auth_provider.dart';
/// 启动密码验证页面
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _passwordCtrl = TextEditingController();
final _focusNode = FocusNode();
String? _errorMsg;
bool _submitting = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_passwordCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_submitting) return;
final password = _passwordCtrl.text.trim();
if (password.isEmpty) return;
setState(() {
_submitting = true;
_errorMsg = null;
});
final success = await ref.read(authProvider.notifier).verify(password);
if (!mounted) return;
_passwordCtrl.clear();
if (!success) {
final l10n = AppLocalizations.of(context);
setState(() {
_submitting = false;
_errorMsg = l10n?.passwordError ?? '密码错误';
});
}
}
void _onKeyEvent(KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter) {
_submit();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState = ref.watch(authProvider);
final isLocked = authState.isLocked;
final lockSeconds = authState.lockSecondsRemaining;
return Scaffold(
backgroundColor: AppTheme.bgPage,
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppTheme.bgSurface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.borderLight),
boxShadow: AppTheme.shadowCard,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 设备名称标识
Icon(
Icons.shield_outlined,
size: 48,
color: AppTheme.primaryColor,
),
const SizedBox(height: 16),
Text(
l10n?.authTitle ?? '身份验证',
style: TextStyle(
color: AppTheme.textHeading,
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
l10n?.authSubtitle ?? '请输入操作员密码以继续使用',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
const SizedBox(height: 32),
// 密码输入框
KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: _onKeyEvent,
child: TextField(
controller: _passwordCtrl,
focusNode: _focusNode,
obscureText: true,
enabled: !isLocked && !_submitting,
decoration: InputDecoration(
labelText: l10n?.enterPassword ?? '请输入密码',
border: const OutlineInputBorder(),
errorText: _errorMsg,
prefixIcon: const Icon(Icons.lock_outline),
),
),
),
const SizedBox(height: 24),
// 确认按钮 / 锁定提示
if (isLocked)
Column(
children: [
Icon(Icons.lock_clock,
size: 32, color: AppTheme.warningColor),
const SizedBox(height: 8),
Text(
l10n?.lockCountdown(lockSeconds) ??
'请等待 $lockSeconds 秒后重试',
style: TextStyle(
color: AppTheme.warningColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
)
else
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(l10n?.confirm ?? '确认'),
),
),
// 剩余尝试次数提示
if (!isLocked && authState.remainingAttempts < 5)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
l10n?.remainingAttempts(authState.remainingAttempts) ??
'剩余 ${authState.remainingAttempts} 次尝试机会',
style: TextStyle(
color: AppTheme.errorColor,
fontSize: 12,
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../settings/services/settings_service.dart';
/// 认证状态枚举
enum AuthStatus { unauthenticated, authenticated }
/// 认证状态数据
class AuthState {
final AuthStatus status;
final int remainingAttempts;
final DateTime? lockUntil;
const AuthState({
this.status = AuthStatus.unauthenticated,
this.remainingAttempts = 5,
this.lockUntil,
});
bool get isLocked => lockUntil != null && DateTime.now().isBefore(lockUntil!);
int get lockSecondsRemaining {
if (lockUntil == null) return 0;
final diff = lockUntil!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
AuthState copyWith({
AuthStatus? status,
int? remainingAttempts,
DateTime? Function()? lockUntil,
}) {
return AuthState(
status: status ?? this.status,
remainingAttempts: remainingAttempts ?? this.remainingAttempts,
lockUntil: lockUntil != null ? lockUntil() : this.lockUntil,
);
}
}
/// 认证状态管理器
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
static const maxAttempts = 5;
static const lockDuration = Duration(seconds: 30);
/// 验证密码
Future<bool> verify(String password) async {
if (state.isLocked) {
return false;
}
final isValid = await SettingsService.instance.verifyPassword(password);
if (isValid) {
state = const AuthState(status: AuthStatus.authenticated);
return true;
}
final newAttempts = state.remainingAttempts - 1;
if (newAttempts <= 0) {
state = state.copyWith(
remainingAttempts: 0,
lockUntil: () => DateTime.now().add(lockDuration),
);
_startLockTimer();
} else {
state = state.copyWith(remainingAttempts: newAttempts);
}
return false;
}
/// 锁定倒计时结束后自动解锁
void _startLockTimer() {
Timer(lockDuration, () {
if (state.status == AuthStatus.authenticated) return;
state = AuthState(
status: state.status,
remainingAttempts: maxAttempts,
lockUntil: null,
);
});
}
/// 重置认证状态(用于登出)
void reset() {
state = const AuthState();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});

View File

@@ -0,0 +1,126 @@
import 'dart:convert';
/// 串口奇偶校验
enum SerialParity { none, odd, even, mark, space }
/// 串口流控
enum SerialFlowControl { none, rtsCts, xonXoff, dtrDsr }
/// 串口配置
///
/// 持久化到 settings 表的 `serial_config` key 中;
/// 打开串口时根据此配置构造底层 `UsbConfig`。
class SerialConfig {
/// 设备 Vendor ID十六进制
final int vendorId;
/// 设备 Product ID十六进制0 表示不指定
final int productId;
/// 波特率
final int baudRate;
/// 数据位 (5/6/7/8)
final int dataBits;
/// 停止位 (1/2)
final int stopBits;
/// 校验位
final SerialParity parity;
/// 流控
final SerialFlowControl flowControl;
/// 读超时(毫秒)
final int readTimeoutMs;
/// 写超时(毫秒)
final int writeTimeoutMs;
const SerialConfig({
this.vendorId = 0x1A86,
this.productId = 0x7523,
this.baudRate = 9600,
this.dataBits = 8,
this.stopBits = 1,
this.parity = SerialParity.none,
this.flowControl = SerialFlowControl.none,
this.readTimeoutMs = 2000,
this.writeTimeoutMs = 2000,
});
/// 默认配置
static const SerialConfig defaults = SerialConfig();
/// 常用波特率
static const List<int> commonBaudRates = [
1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
];
SerialConfig copyWith({
int? vendorId,
int? productId,
int? baudRate,
int? dataBits,
int? stopBits,
SerialParity? parity,
SerialFlowControl? flowControl,
int? readTimeoutMs,
int? writeTimeoutMs,
}) {
return SerialConfig(
vendorId: vendorId ?? this.vendorId,
productId: productId ?? this.productId,
baudRate: baudRate ?? this.baudRate,
dataBits: dataBits ?? this.dataBits,
stopBits: stopBits ?? this.stopBits,
parity: parity ?? this.parity,
flowControl: flowControl ?? this.flowControl,
readTimeoutMs: readTimeoutMs ?? this.readTimeoutMs,
writeTimeoutMs: writeTimeoutMs ?? this.writeTimeoutMs,
);
}
/// 编码为 JSON 字符串(用于持久化)
String toJsonString() => jsonEncode({
'vendorId': vendorId,
'productId': productId,
'baudRate': baudRate,
'dataBits': dataBits,
'stopBits': stopBits,
'parity': parity.name,
'flowControl': flowControl.name,
'readTimeoutMs': readTimeoutMs,
'writeTimeoutMs': writeTimeoutMs,
});
/// 从 JSON 字符串解码;解析失败时返回默认值
factory SerialConfig.fromJsonString(String? raw) {
if (raw == null || raw.isEmpty) return defaults;
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
return SerialConfig(
vendorId: (map['vendorId'] as num?)?.toInt() ?? defaults.vendorId,
productId: (map['productId'] as num?)?.toInt() ?? defaults.productId,
baudRate: (map['baudRate'] as num?)?.toInt() ?? defaults.baudRate,
dataBits: (map['dataBits'] as num?)?.toInt() ?? defaults.dataBits,
stopBits: (map['stopBits'] as num?)?.toInt() ?? defaults.stopBits,
parity: SerialParity.values.firstWhere(
(e) => e.name == map['parity'],
orElse: () => defaults.parity,
),
flowControl: SerialFlowControl.values.firstWhere(
(e) => e.name == map['flowControl'],
orElse: () => defaults.flowControl,
),
readTimeoutMs:
(map['readTimeoutMs'] as num?)?.toInt() ?? defaults.readTimeoutMs,
writeTimeoutMs:
(map['writeTimeoutMs'] as num?)?.toInt() ?? defaults.writeTimeoutMs,
);
} catch (_) {
return defaults;
}
}
}

View File

@@ -1,17 +1,18 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../programs/models/program.dart';
import '../../programs/models/step.dart';
import '../../programs/services/program_service.dart';
import '../services/mock_runner.dart';
import '../services/runner_interface.dart';
import 'serial_provider.dart';
/// 运行状态枚举
enum RunStatus {
idle, // 待机
running, // 运行中
paused, // 已暂停
completed,// 已完成
error, // 错误
idle, // 待机
running, // 运行中
paused, // 已暂停
completed, // 已完成
error, // 错误
}
/// 运行状态
@@ -23,6 +24,7 @@ class RunState {
final int remainingSeconds;
final double progress;
final String? currentWell;
final String? errorMessage;
const RunState({
this.status = RunStatus.idle,
@@ -32,6 +34,7 @@ class RunState {
this.remainingSeconds = 0,
this.progress = 0,
this.currentWell,
this.errorMessage,
});
RunState copyWith({
@@ -42,17 +45,21 @@ class RunState {
int? remainingSeconds,
double? progress,
String? currentWell,
String? errorMessage,
bool clearProgram = false,
bool clearWell = false,
bool clearError = false,
}) {
return RunState(
status: status ?? this.status,
currentProgram: clearProgram ? null : (currentProgram ?? this.currentProgram),
currentProgram:
clearProgram ? null : (currentProgram ?? this.currentProgram),
steps: steps ?? this.steps,
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
progress: progress ?? this.progress,
currentWell: clearWell ? null : (currentWell ?? this.currentWell),
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
);
}
@@ -68,8 +75,8 @@ class RunState {
final minutes = (remainingSeconds % 3600) ~/ 60;
final seconds = remainingSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 格式化进度百分比
@@ -80,18 +87,24 @@ class RunState {
/// 运行状态 Notifier
class RunStateNotifier extends StateNotifier<RunState> {
final MockRunner _runner;
final Runner _runner;
final ProgramService _programService;
RunStateNotifier(this._runner, this._programService) : super(const RunState());
/// 开始运行程序
Future<void> start(Program program) async {
// 获取程序步骤(这里使用模拟数据,实际应从数据库读取)
final steps = await _loadSteps(program.id!);
if (state.status == RunStatus.running ||
state.status == RunStatus.paused) {
return;
}
final steps = await _programService.getStepsByProgramId(program.id!);
if (steps.isEmpty) {
state = state.copyWith(status: RunStatus.error);
state = state.copyWith(
status: RunStatus.error,
errorMessage: 'PROGRAM_STEPS_EMPTY',
);
return;
}
@@ -101,26 +114,36 @@ class RunStateNotifier extends StateNotifier<RunState> {
steps: steps,
currentStepIndex: 0,
progress: 0,
currentWell: steps.first.position,
clearError: true,
);
_runner.start(
program,
steps,
(stepIndex, remaining, progress, well) {
state = state.copyWith(
currentStepIndex: stepIndex,
remainingSeconds: remaining,
progress: progress,
currentWell: well,
);
},
() {
state = state.copyWith(
status: RunStatus.completed,
progress: 1,
clearWell: true,
);
},
RunnerCallbacks(
onProgress: (stepIndex, remaining, progress, well) {
state = state.copyWith(
currentStepIndex: stepIndex,
remainingSeconds: remaining,
progress: progress,
currentWell: well,
);
},
onComplete: () {
state = state.copyWith(
status: RunStatus.completed,
progress: 1,
currentWell: steps.last.position,
);
},
onError: (msg) {
state = state.copyWith(
status: RunStatus.error,
errorMessage: msg,
);
},
),
);
}
@@ -142,26 +165,17 @@ class RunStateNotifier extends StateNotifier<RunState> {
/// 停止运行
void stop() {
_runner.stop();
if (state.status == RunStatus.running ||
state.status == RunStatus.paused) {
_runner.stop();
}
state = const RunState(status: RunStatus.idle);
}
/// 重置状态
void reset() {
stop();
}
/// 加载程序步骤(从数据库读取)
Future<List<Step>> _loadSteps(int programId) async {
return await _programService.getStepsByProgramId(programId);
}
void reset() => stop();
}
/// MockRunner Provider
final mockRunnerProvider = Provider<MockRunner>((ref) {
return MockRunner();
});
/// ProgramService Provider
final programServiceProvider = Provider<ProgramService>((ref) {
return ProgramService.instance;
@@ -170,7 +184,7 @@ final programServiceProvider = Provider<ProgramService>((ref) {
/// 运行状态 Provider
final runStateProvider =
StateNotifierProvider<RunStateNotifier, RunState>((ref) {
final runner = ref.watch(mockRunnerProvider);
final runner = ref.watch(runnerProvider);
final programService = ref.watch(programServiceProvider);
return RunStateNotifier(runner, programService);
});
@@ -185,4 +199,4 @@ final isRunningProvider = Provider<bool>((ref) {
final isPausedProvider = Provider<bool>((ref) {
final status = ref.watch(runStateProvider).status;
return status == RunStatus.paused;
});
});

View File

@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/database/database_service.dart';
import '../models/serial_config.dart';
import '../services/auto_serial_connect.dart';
import '../services/device_message_service.dart';
import '../services/json_protocol.dart';
import '../services/runner_interface.dart';
@@ -15,6 +18,46 @@ final serialPortServiceProvider = Provider<SerialPortService>((ref) {
return service;
});
/// 串口连接状态(响应式)
///
/// 直接 `ref.watch(serialPortServiceProvider).state` 不会触发 UI 重建,
/// 因为 [SerialPortService] 内部 `_state` 的变化不会冒泡到 Provider 层。
/// 这里把状态抽出为独立的 StateNotifierProvider让状态栏等 UI 能即时
/// 反映连接/断开事件,避免出现"标题栏显示已连接、实际下发失败"的错觉。
class SerialConnectionStateNotifier
extends StateNotifier<SerialConnectionState> {
SerialConnectionStateNotifier(this._service) : super(_service.state) {
_sub = _service.connectionStateChanges.listen((s) => state = s);
}
final SerialPortService _service;
late final StreamSubscription<SerialConnectionState> _sub;
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
final serialConnectionStateProvider = StateNotifierProvider<
SerialConnectionStateNotifier, SerialConnectionState>((ref) {
final service = ref.watch(serialPortServiceProvider);
return SerialConnectionStateNotifier(service);
});
/// 启动自动连接服务
///
/// 通过 [main] 中的 ProviderContainer 在 runApp 之前触发一次,
/// 服务内部立即尝试连接第一个 USB 串口设备,失败时按 3s 间隔重试。
final autoSerialConnectProvider = Provider<AutoSerialConnect>((ref) {
final service = ref.watch(serialPortServiceProvider);
final auto = AutoSerialConnect(service);
auto.start();
ref.onDispose(auto.dispose);
return auto;
});
/// JSON 协议编解码器(可在调试/真机协议不一致时整体替换)
final jsonProtocolProvider = Provider<JsonProtocolService>((ref) {
return JsonProtocolService();

View File

@@ -0,0 +1,109 @@
import 'dart:async';
import 'package:usb_serial/usb_serial.dart';
import '../models/serial_config.dart';
import 'device_log.dart';
import 'serial_port_service.dart';
/// 启动自动连接服务
///
/// App 启动时调用 [start],自动连接第一个可用的 USB 串口设备:
/// - 固定 115200 波特率 / 8 数据位 / 1 停止位 / 默认其它
/// - 连接失败时每 3 秒重试一次
/// - 连接成功后停止重试;连接断开后重新进入重试循环
class AutoSerialConnect {
/// 重试间隔
static const Duration retryInterval = Duration(seconds: 3);
/// 自动连接使用的固定参数
static const SerialConfig autoConfig = SerialConfig(
baudRate: 115200,
dataBits: 8,
stopBits: 1,
);
final SerialPortService _service;
Timer? _retryTimer;
StreamSubscription<SerialConnectionState>? _stateSub;
bool _disposed = false;
AutoSerialConnect(this._service);
/// 启动自动连接:立即尝试一次,失败则按 [retryInterval] 周期重试
void start() {
if (_stateSub != null) return;
_stateSub = _service.connectionStateChanges.listen(_onStateChange);
unawaited(_tryConnect());
}
void _onStateChange(SerialConnectionState s) {
if (_disposed) return;
switch (s) {
case SerialConnectionState.connected:
// 连接成功:停止重试
_cancelRetry();
case SerialConnectionState.disconnected:
case SerialConnectionState.error:
// 设备断开或出错:进入重试
_scheduleRetry();
case SerialConnectionState.connecting:
// 正在连接中,忽略
break;
}
}
Future<void> _tryConnect() async {
if (_disposed) return;
if (_service.isConnected ||
_service.state == SerialConnectionState.connecting) {
return;
}
final List<UsbDevice> devices;
try {
devices = await _service.listDevices();
} catch (e) {
DeviceLog.warn('列出 USB 设备失败: $e${retryInterval.inSeconds}s 后重试');
_scheduleRetry();
return;
}
if (devices.isEmpty) {
DeviceLog.info('未检测到 USB 串口设备,${retryInterval.inSeconds}s 后重试');
_scheduleRetry();
return;
}
final device = devices.first;
final ok = await _service.connect(device, autoConfig);
if (!ok) {
DeviceLog.warn('串口自动连接失败: '
'${_service.lastError ?? "未知错误"}${retryInterval.inSeconds}s 后重试');
_scheduleRetry();
}
}
void _scheduleRetry() {
if (_disposed) return;
if (_retryTimer != null) return;
_retryTimer = Timer(retryInterval, () {
_retryTimer = null;
unawaited(_tryConnect());
});
}
void _cancelRetry() {
_retryTimer?.cancel();
_retryTimer = null;
}
/// 释放资源
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_cancelRetry();
await _stateSub?.cancel();
_stateSub = null;
}
}

View File

@@ -1,5 +1,7 @@
import 'dart:convert';
import 'package:uuid/uuid.dart';
/// 下位机消息类型
///
/// 对应《下位机交互数据模型.md》中定义的四种 type 字段。
@@ -147,18 +149,11 @@ class DeviceMessage {
/// 消息 ID 生成器
///
/// 使用时间戳 + 随机数生成全局唯一 ID避免引入 uuid 依赖)。
/// 格式:`<millis>-<rand>`,例如 `1717500000000-1a2b3c`
/// 基于 [Uuid.v4] 生成全局唯一 ID128 位随机,标准 36 字符 hex-with-dashes 形式)。
/// 例如 `550e8400-e29b-41d4-a716-446655440000`。
class MessageIdGenerator {
int _counter = 0;
static const Uuid _uuid = Uuid();
/// 生成下一个唯一 ID
String next() {
_counter = (_counter + 1) & 0xFFFFFF;
final ts = DateTime.now().millisecondsSinceEpoch.toRadixString(36);
final rand = (_counter.toRadixString(36) +
(DateTime.now().microsecondsSinceEpoch & 0xFFFF).toRadixString(36))
.padLeft(4, '0');
return '$ts-$rand';
}
String next() => _uuid.v4();
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'device_log.dart';
import 'device_message.dart';
/// JSON 协议层帧编解码器
@@ -67,7 +68,7 @@ class JsonProtocolService {
(_buffer[2] << 8) |
_buffer[3];
if (len <= 0 || len > _maxFrameBytes) {
// 长度异常,丢弃首字节重新对齐
DeviceLog.warn('tryDecode: 异常长度=$len 丢弃首字节 0x${_buffer[0].toRadixString(16).padLeft(2, '0')}');
_buffer.removeAt(0);
return (null, 0);
}

View File

@@ -1,190 +0,0 @@
import 'dart:async';
import '../../programs/models/step.dart';
import '../../programs/models/program.dart';
/// 模拟运行器回调
typedef RunProgressCallback = void Function(
int currentStepIndex,
int remainingSeconds,
double progress,
String currentWell,
);
typedef RunCompleteCallback = void Function();
/// 模拟运行器
/// 用于在没有实际硬件连接时模拟程序执行过程
class MockRunner {
Timer? _timer;
Program? _currentProgram;
List<Step> _steps = [];
int _currentStepIndex = 0;
int _remainingSeconds = 0;
bool _isPaused = false;
RunProgressCallback? _onProgress;
RunCompleteCallback? _onComplete;
/// 是否正在运行
bool get isRunning => _timer != null && !_isPaused;
/// 是否已暂停
bool get isPaused => _isPaused;
/// 当前程序
Program? get currentProgram => _currentProgram;
/// 开始运行程序
void start(
Program program,
List<Step> steps,
RunProgressCallback onProgress,
RunCompleteCallback onComplete,
) {
_currentProgram = program;
_steps = steps;
_onProgress = onProgress;
_onComplete = onComplete;
_currentStepIndex = 0;
_isPaused = false;
if (steps.isEmpty) {
onComplete();
return;
}
// 开始执行第一个步骤
_startStep(steps[0]);
}
/// 暂停运行
void pause() {
if (_timer != null && !_isPaused) {
_isPaused = true;
_timer!.cancel();
_timer = null;
}
}
/// 继续运行
void resume() {
if (_isPaused && _currentProgram != null) {
_isPaused = false;
_resumeStep();
}
}
/// 停止运行
void stop() {
_timer?.cancel();
_timer = null;
_currentProgram = null;
_steps = [];
_currentStepIndex = 0;
_remainingSeconds = 0;
_isPaused = false;
}
/// 开始执行步骤
void _startStep(Step step) {
// 计算步骤总时间(混合时间 + 吸磁时间 + 吹气时间)
_remainingSeconds = step.mixTime + step.magnetTime + step.blowTime;
// 如果步骤时间为0设置最小演示时间5秒
if (_remainingSeconds == 0) {
_remainingSeconds = 5;
}
// 启动定时器,每秒更新
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_remainingSeconds--;
// 计算总进度
final totalSeconds = _calculateTotalSeconds();
final elapsedSeconds = _calculateElapsedSeconds();
final progress = totalSeconds > 0 ? elapsedSeconds / totalSeconds : 0.0;
// 回调进度更新
_onProgress?.call(
_currentStepIndex,
_remainingSeconds,
progress,
step.position,
);
// 步骤完成
if (_remainingSeconds <= 0) {
timer.cancel();
_timer = null;
_nextStep();
}
});
}
/// 继续执行步骤(从暂停恢复)
void _resumeStep() {
if (_currentStepIndex >= _steps.length) return;
final step = _steps[_currentStepIndex];
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_remainingSeconds--;
final totalSeconds = _calculateTotalSeconds();
final elapsedSeconds = _calculateElapsedSeconds();
final progress = totalSeconds > 0 ? elapsedSeconds / totalSeconds : 0.0;
_onProgress?.call(
_currentStepIndex,
_remainingSeconds,
progress,
step.position,
);
if (_remainingSeconds <= 0) {
timer.cancel();
_timer = null;
_nextStep();
}
});
}
/// 执行下一个步骤
void _nextStep() {
_currentStepIndex++;
if (_currentStepIndex >= _steps.length) {
// 所有步骤完成
_onComplete?.call();
stop();
} else {
// 执行下一个步骤
_startStep(_steps[_currentStepIndex]);
}
}
/// 计算总执行时间
int _calculateTotalSeconds() {
int total = 0;
for (final step in _steps) {
int stepTime = step.mixTime + step.magnetTime + step.blowTime;
if (stepTime == 0) stepTime = 5;
total += stepTime;
}
return total;
}
/// 计算已执行时间
int _calculateElapsedSeconds() {
int elapsed = 0;
for (int i = 0; i < _currentStepIndex; i++) {
int stepTime = _steps[i].mixTime + _steps[i].magnetTime + _steps[i].blowTime;
if (stepTime == 0) stepTime = 5;
elapsed += stepTime;
}
// 加上当前步骤已执行的时间
final currentStep = _steps[_currentStepIndex];
int currentStepTime = currentStep.mixTime + currentStep.magnetTime + currentStep.blowTime;
if (currentStepTime == 0) currentStepTime = 5;
elapsed += currentStepTime - _remainingSeconds;
return elapsed;
}
}

View File

@@ -1,114 +0,0 @@
import '../../programs/models/program.dart';
import '../../programs/models/step.dart';
import 'runner_interface.dart';
/// 模拟运行器(用于开发测试)
/// 模拟硬件运行过程
class MockRunner implements Runner {
@override
RunnerStatus status = RunnerStatus.idle;
bool _isRunning = false;
int _currentStep = 0;
int _remainingSeconds = 0;
RunnerCallbacks? _callbacks;
List<Step> _steps = [];
@override
void start(Program program, List<Step> steps, RunnerCallbacks callbacks) {
if (steps.isEmpty) {
callbacks.onError?.call('No steps to run');
status = RunnerStatus.error;
return;
}
_steps = steps;
_callbacks = callbacks;
_currentStep = 0;
_isRunning = true;
status = RunnerStatus.running;
// 开始模拟运行
_runSimulation();
}
void _runSimulation() {
if (!_isRunning || _currentStep >= _steps.length) {
_completeRun();
return;
}
final step = _steps[_currentStep];
// 计算步骤时间(混合时间 + 吸磁时间 + 吹气时间 + 5秒最小
final stepTime = (step.mixTime ?? 0) + (step.magnetTime ?? 0) + (step.blowTime ?? 0) + 5;
_remainingSeconds = stepTime.clamp(5, 300);
// 模拟倒计时
_simulateStepProgress(stepTime);
}
void _simulateStepProgress(int totalSeconds) {
// 简化模拟:每秒更新进度
int elapsed = 0;
while (_isRunning && elapsed < totalSeconds) {
elapsed++;
final remaining = totalSeconds - elapsed;
final progress = elapsed / totalSeconds;
_callbacks?.onProgress?.call(
_currentStep,
remaining,
(_currentStep + progress) / _steps.length,
_steps[_currentStep].position,
);
// 实际实现需要使用 Timer
// await Future.delayed(Duration(seconds: 1));
}
if (_isRunning) {
_currentStep++;
_runSimulation();
}
}
void _completeRun() {
status = RunnerStatus.completed;
_isRunning = false;
_callbacks?.onComplete?.call();
}
@override
void pause() {
if (status == RunnerStatus.running) {
_isRunning = false;
status = RunnerStatus.paused;
}
}
@override
void resume() {
if (status == RunnerStatus.paused) {
_isRunning = true;
status = RunnerStatus.running;
// 继续运行
_runSimulation();
}
}
@override
void stop() {
_isRunning = false;
status = RunnerStatus.idle;
_currentStep = 0;
_remainingSeconds = 0;
}
@override
RunnerStatus getStatus() => status;
@override
void dispose() {
stop();
}
}

View File

@@ -66,6 +66,9 @@ class JsonSerialRunner implements Runner {
final messageId = _msg.nextId();
_pendingCreateTaskId = messageId;
final msg = payload.toMessage(messageId, needAck: true);
// 乐观更新:与 RunStateNotifier 的运行态保持一致,
// 避免在 create_task 应答到达前的窗口里pause/stop 被状态守卫静默丢弃。
status = RunnerStatus.running;
DeviceLog.info('Runner.start: program="${program.name}" steps=${steps.length} '
'temperature=${program.temperature} airflow=${program.airflowTime}');
_msg.send(msg).then((ok) {
@@ -132,9 +135,9 @@ class JsonSerialRunner implements Runner {
if (ack.ack != _pendingCreateTaskId) return;
_pendingCreateTaskId = null;
DeviceLog.info('Runner received create_task ack: id=${ack.messageId}');
// ack 即视为下位机已接受任务,进入 running 状态
if (status == RunnerStatus.idle || status == RunnerStatus.error) {
status = RunnerStatus.running;
// 状态已由 start() 乐观置为 running此处仅启动本地兜底倒计时。
// 若发送失败,.then() 已将 status 置为 error不应再启动倒计时。
if (status == RunnerStatus.running) {
_startLocalTicker();
}
}

View File

@@ -7,6 +7,12 @@ import '../../../shared/widgets/common_button.dart';
import '../../device/providers/run_state_provider.dart';
/// 运行完成提示页面
///
/// 页面提供两种返回设备控制页的方式:
/// 1. 顶部 AppBar 的返回箭头
/// 2. 卡片底部的「完成」主按钮
///
/// 内部使用 [SingleChildScrollView] 包裹,避免在父级高度受限时按钮被裁切。
class CompletePage extends ConsumerWidget {
const CompletePage({super.key});
@@ -16,115 +22,143 @@ class CompletePage extends ConsumerWidget {
final runState = ref.watch(runStateProvider);
final runNotifier = ref.read(runStateProvider.notifier);
/// 完成并返回设备控制页
void finishAndGoHome() {
runNotifier.reset();
context.go('/');
}
return Scaffold(
body: Container(
color: AppTheme.backgroundColor,
backgroundColor: AppTheme.backgroundColor,
appBar: AppBar(
backgroundColor: AppTheme.backgroundColor,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
color: AppTheme.textPrimary,
tooltip: l10n?.complete ?? '完成',
onPressed: finishAndGoHome,
),
title: Text(
l10n?.runComplete ?? '运行完成',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
body: SafeArea(
child: Center(
child: Container(
width: 600,
padding: const EdgeInsets.all(40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 成功图标
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppTheme.successColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle,
size: 60,
color: AppTheme.successColor,
),
),
const SizedBox(height: 24),
// 标题
Text(
l10n?.runComplete ?? '程序运行完成',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 提示信息
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: AppTheme.warningColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n?.sampleDropGuide ?? '请将样本滴入检测卡',
style: TextStyle(
color: AppTheme.warningColor,
fontSize: 16,
),
),
),
const SizedBox(height: 32),
// 操作示意图
_buildOperationGuide(),
const SizedBox(height: 32),
// 按钮区域
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 返回首页按钮
CommonButton(
text: l10n?.backToHome ?? '返回首页',
icon: Icons.home,
type: ButtonType.primary,
onPressed: () {
runNotifier.reset();
context.go('/');
},
),
const SizedBox(width: 24),
// 重新运行按钮
CommonButton(
text: l10n?.runAgain ?? '重新运行',
icon: Icons.refresh,
type: ButtonType.secondary,
onPressed: () {
final program = runState.currentProgram;
if (program != null) {
runNotifier.reset();
runNotifier.start(program);
context.go('/');
}
},
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 成功图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.successColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle,
size: 48,
color: AppTheme.successColor,
),
),
const SizedBox(height: 16),
// 标题
Text(
l10n?.programRunComplete ?? '程序运行完成',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// 提示信息
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: AppTheme.warningColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n?.sampleDropGuide ?? '请将样本滴入检测卡',
style: TextStyle(
color: AppTheme.warningColor,
fontSize: 15,
),
),
),
const SizedBox(height: 20),
// 操作示意图
_buildOperationGuide(context),
const SizedBox(height: 24),
// 按钮区域:完成(主按钮)+ 重新运行(次按钮)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 完成按钮 - 主操作,返回设备控制页
CommonButton(
text: l10n?.complete ?? '完成',
icon: Icons.check_circle,
type: ButtonType.primary,
onPressed: finishAndGoHome,
),
const SizedBox(width: 16),
// 重新运行按钮
CommonButton(
text: l10n?.runAgain ?? '重新运行',
icon: Icons.refresh,
type: ButtonType.secondary,
onPressed: () {
final program = runState.currentProgram;
if (program != null) {
runNotifier.reset();
runNotifier.start(program);
context.go('/');
} else {
finishAndGoHome();
}
},
),
],
),
],
),
),
),
),
),
@@ -133,9 +167,10 @@ class CompletePage extends ConsumerWidget {
}
/// 操作指引示意图
Widget _buildOperationGuide() {
Widget _buildOperationGuide(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundColor,
borderRadius: BorderRadius.circular(8),
@@ -144,21 +179,21 @@ class CompletePage extends ConsumerWidget {
child: Column(
children: [
Text(
'操作步骤',
l10n?.operationSteps ?? '操作步骤',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStepItem(1, '取出样本', Icons.science),
_buildStepItem(2, '滴入检测卡', Icons.water_drop),
_buildStepItem(3, '等待反应', Icons.timer),
_buildStepItem(4, '查看结果', Icons.visibility),
_buildStepItem(1, l10n?.extractSample ?? '取出样本', Icons.science),
_buildStepItem(2, l10n?.dropToTestCard ?? '滴入检测卡', Icons.water_drop),
_buildStepItem(3, l10n?.waitForReaction ?? '等待反应', Icons.timer),
_buildStepItem(4, l10n?.viewResults ?? '查看结果', Icons.visibility),
],
),
],
@@ -171,15 +206,15 @@ class CompletePage extends ConsumerWidget {
return Column(
children: [
Container(
width: 50,
height: 50,
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: AppTheme.primaryColor, size: 24),
child: Icon(icon, color: AppTheme.primaryColor, size: 22),
),
const SizedBox(height: 8),
const SizedBox(height: 6),
Text(
'$number',
style: TextStyle(
@@ -188,7 +223,7 @@ class CompletePage extends ConsumerWidget {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
text,
style: TextStyle(
@@ -199,4 +234,4 @@ class CompletePage extends ConsumerWidget {
],
);
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/database/database_service.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../device/providers/run_state_provider.dart';
import '../../programs/pages/programs_page.dart';
@@ -12,9 +13,13 @@ import '../widgets/running_control_panel.dart';
import '../widgets/run_status_monitor.dart';
/// 首页 - 设备控制面板 (暗色工业风格)
/// 布局:状态栏 + 导航标签栏 + 内容区(设备控制/程序管理/系统设置)
/// 布局:状态栏(含标签导航) + 内容区(设备控制/程序管理/系统设置)
///
/// [initialTab] 用于从子页面(如程序详情页)跳转回首页时指定要显示的 tab
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
final int initialTab;
const HomePage({super.key, this.initialTab = 0});
@override
ConsumerState<HomePage> createState() => _HomePageState();
@@ -22,17 +27,19 @@ class HomePage extends ConsumerStatefulWidget {
class _HomePageState extends ConsumerState<HomePage>
with SingleTickerProviderStateMixin {
int _currentIndex = 0;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialTab.clamp(0, 2);
DatabaseService.instance.initTestData();
}
@override
Widget build(BuildContext context) {
final runState = ref.watch(runStateProvider);
final l10n = AppLocalizations.of(context);
// 监听运行完成状态,自动跳转
ref.listen<RunState>(runStateProvider, (prev, next) {
@@ -44,19 +51,25 @@ class _HomePageState extends ConsumerState<HomePage>
}
});
final tabs = <StatusBarTab>[
StatusBarTab(icon: Icons.dashboard, label: l10n?.deviceControl ?? '设备控制'),
StatusBarTab(icon: Icons.list_alt, label: l10n?.programs ?? '程序管理'),
StatusBarTab(icon: Icons.settings, label: l10n?.settings ?? '系统设置'),
];
return Scaffold(
body: Container(
color: AppTheme.bgDeep,
child: Column(
children: [
// 状态栏
// 状态栏(内嵌标签导航)
StatusBar(
isRunning: runState.status == RunStatus.running,
tabs: tabs,
currentTabIndex: _currentIndex,
onTabChanged: (index) => setState(() => _currentIndex = index),
),
// 导航标签栏
_buildTabBar(),
// 内容区
Expanded(
child: IndexedStack(
@@ -74,85 +87,30 @@ class _HomePageState extends ConsumerState<HomePage>
);
}
/// 导航标签栏
Widget _buildTabBar() {
const tabs = [
(icon: Icons.dashboard, label: '设备控制'),
(icon: Icons.list_alt, label: '程序管理'),
(icon: Icons.settings, label: '系统设置'),
];
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: List.generate(tabs.length, (index) {
final tab = tabs[index];
final isSelected = _currentIndex == index;
return GestureDetector(
onTap: () => setState(() => _currentIndex = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: isSelected ? AppTheme.accentPrimary : AppTheme.cardBg,
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
border: Border.all(
color: isSelected
? AppTheme.accentPrimary
: AppTheme.borderSubtle,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
tab.icon,
size: 18,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
const SizedBox(width: 8),
Text(
tab.label,
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontSize: 14,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
);
}),
),
);
}
/// 设备控制页面内容
Widget _buildDeviceControlPage(RunState runState) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
padding: const EdgeInsets.all(20),
child: Row(
children: [
// 左侧:程序列表(运行时锁定)
Opacity(
opacity: runState.status == RunStatus.idle ? 1.0 : 0.6,
child: IgnorePointer(
ignoring: runState.status != RunStatus.idle,
child: const ProgramList(),
// 左侧:程序列表(运行时锁定),占 2/5 宽度
Expanded(
flex: 2,
child: Opacity(
opacity: runState.status == RunStatus.idle ? 1.0 : 0.6,
child: IgnorePointer(
ignoring: runState.status != RunStatus.idle,
child: const ProgramList(),
),
),
),
const SizedBox(width: 20),
// 右侧:运行控制区域
// 右侧:运行控制区域,占 3/5 宽度
Expanded(
flex: 3,
child: Column(
children: [
const Expanded(child: RunningControlPanel()),
const RunningControlPanel(),
if (runState.status != RunStatus.idle) ...[
const SizedBox(height: 16),
const Expanded(child: RunStatusMonitor()),

View File

@@ -18,7 +18,6 @@ class ProgramList extends ConsumerWidget {
final programsNotifier = ref.read(programsProvider.notifier);
return Container(
width: 380,
decoration: BoxDecoration(
color: AppTheme.cardBg,
borderRadius: BorderRadius.circular(8),
@@ -103,6 +102,7 @@ class _ProgramCard extends StatelessWidget {
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
final createdAt = _parseDate(program.createdAt);
final l10n = AppLocalizations.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
@@ -169,7 +169,9 @@ class _ProgramCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
),
child: Text(
program.status == 1 ? '启用' : '停用',
program.status == 1
? (l10n?.enabled ?? '启用')
: (l10n?.disabled ?? '停用'),
style: TextStyle(
color: program.status == 1
? AppTheme.statusRunning

View File

@@ -25,73 +25,81 @@ class RunStatusMonitor extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderSubtle, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题 + 程序名
Row(
children: [
Text(
l10n?.runningMonitor ?? '运行状态监控',
style: const TextStyle(
color: AppTheme.textHeading,
fontSize: 15,
fontWeight: FontWeight.w600,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题 + 程序名
Row(
children: [
Text(
l10n?.runningMonitor ?? '运行状态监控',
style: const TextStyle(
color: AppTheme.textHeading,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
const Spacer(),
Text(
runState.currentProgram?.name ?? '',
style: const TextStyle(
color: AppTheme.accentPrimary,
fontSize: 18,
fontWeight: FontWeight.w700,
const Spacer(),
Flexible(
child: Text(
runState.currentProgram?.name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppTheme.accentPrimary,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
],
),
const SizedBox(height: 14),
const SizedBox(height: 14),
// 进度信息横排 (孔位 / 步骤 / 剩余时间)
Row(
children: [
// 当前孔位
_buildInfoBlock(
label: l10n?.currentHole ?? '当前孔位',
value: runState.currentWell ?? '--',
valueColor: AppTheme.textHeading,
),
const SizedBox(width: 20),
// 当前步骤
_buildInfoBlock(
label: l10n?.currentStep ?? '当前步骤',
value: '${l10n?.stepNo ?? '步骤'} ${runState.currentStepIndex + 1}',
subValue: runState.currentStep?.name ?? '--',
valueColor: AppTheme.accentInfo,
),
const SizedBox(width: 20),
// 剩余时间
_buildInfoBlock(
label: l10n?.remainingTime ?? '剩余时间',
value: runState.formattedRemainingTime,
valueColor: AppTheme.textHeading,
valueSize: 20,
),
],
),
// 进度信息横排 (孔位 / 步骤 / 剩余时间)
Row(
children: [
// 当前孔位
_buildInfoBlock(
label: l10n?.currentHole ?? '当前孔位',
value: runState.currentWell ?? '--',
valueColor: AppTheme.textHeading,
),
const SizedBox(width: 20),
// 当前步骤
Flexible(
child: _buildInfoBlock(
label: l10n?.currentStep ?? '当前步骤',
value: '${l10n?.stepNo ?? '步骤'} ${runState.currentStepIndex + 1}',
subValue: runState.currentStep?.name ?? '--',
valueColor: AppTheme.accentInfo,
),
),
const SizedBox(width: 20),
// 剩余时间
_buildInfoBlock(
label: l10n?.remainingTime ?? '剩余时间',
value: runState.formattedRemainingTime,
valueColor: AppTheme.textHeading,
valueSize: 20,
),
],
),
const SizedBox(height: 14),
const SizedBox(height: 14),
// 总进度条
_buildProgressBar(l10n, runState),
// 总进度条
_buildProgressBar(l10n, runState),
const SizedBox(height: 14),
const SizedBox(height: 14),
// 步骤参数
if (runState.currentStep != null)
_buildStepParams(l10n, runState.currentStep!),
],
// 步骤参数
if (runState.currentStep != null)
_buildStepParams(l10n, runState.currentStep!),
],
),
),
);
}
@@ -190,53 +198,26 @@ class RunStatusMonitor extends ConsumerWidget {
fontSize: 11,
),
),
const SizedBox(height: 2),
if (step.mixTime > 0)
_buildParamRow(
l10n?.speed ?? '转速',
'${step.mixSpeed}',
),
if (step.magnetTime > 0)
_buildParamRow(
l10n?.temperature ?? '温度',
'65.0 °C',
),
_buildParamRow(
l10n?.duration ?? '持续时间',
step.mixTime > 0 ? '${step.mixTime} min' : '--',
),
_buildParamRow(
l10n?.sampleVolume ?? '样品体积',
'10.0 mL',
const SizedBox(height: 8),
Row(
children: [
_buildInfoBlock(
label: l10n?.speed ?? '速度',
value: '${step.speed} ${l10n?.speedLevel ?? ''}',
),
const SizedBox(width: 20),
_buildInfoBlock(
label: l10n?.duration ?? '持续时间',
value: '${step.mixTime} s',
),
const SizedBox(width: 20),
_buildInfoBlock(
label: l10n?.sampleVolume ?? '样品体积',
value: '${step.volume} μL',
),
],
),
],
);
}
/// 参数行
Widget _buildParamRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Text(
label,
style: const TextStyle(
color: AppTheme.textTertiary,
fontSize: 11,
),
),
const Spacer(),
Text(
value,
style: const TextStyle(
color: AppTheme.textPrimary,
fontSize: 11,
fontFamily: 'monospace',
),
),
],
),
);
}
}

View File

@@ -4,10 +4,11 @@ import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/widgets/common_button.dart';
import '../../device/providers/run_state_provider.dart';
import '../../programs/models/program.dart';
import '../../programs/providers/programs_provider.dart';
/// 运行控制面板 - 暗色工业风格
/// 显示当前程序信息、瓷套棒状态和运行控制按钮
/// 显示当前程序信息和运行控制按钮
class RunningControlPanel extends ConsumerWidget {
const RunningControlPanel({super.key});
@@ -41,6 +42,7 @@ class RunningControlPanel extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 当前选中程序显示
@@ -96,40 +98,6 @@ class RunningControlPanel extends ConsumerWidget {
const SizedBox(height: 12),
// 瓷套棒确认提示
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.cardBg,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.borderSubtle, width: 1),
),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.statusStopped,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n?.ceramicNotInstalled ?? '瓷套棒: 未安装 — 禁止启动',
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 12),
// 控制按钮
Row(
children: [
@@ -144,26 +112,12 @@ class RunningControlPanel extends ConsumerWidget {
type: ButtonType.primary,
enabled: selectedProgram != null,
onPressed: selectedProgram != null
? () => runNotifier.start(selectedProgram)
? () => _confirmAndStart(context, runNotifier, selectedProgram, l10n)
: null,
),
),
),
const SizedBox(width: 12),
// 暂停/继续按钮(待机态禁用)
Expanded(
child: SizedBox(
height: 48,
child: CommonButton(
text: l10n?.pause ?? '暂停',
icon: Icons.pause,
type: ButtonType.secondary,
enabled: false,
onPressed: null,
),
),
),
const SizedBox(width: 12),
// 停止按钮(待机态禁用)
Expanded(
child: SizedBox(
@@ -196,6 +150,7 @@ class RunningControlPanel extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 当前程序名称
@@ -237,7 +192,7 @@ class RunningControlPanel extends ConsumerWidget {
// 控制按钮
Row(
children: [
// 开始/继续按钮
// 暂停/继续按钮(运行中切换)
Expanded(
flex: 2,
child: SizedBox(
@@ -245,27 +200,18 @@ class RunningControlPanel extends ConsumerWidget {
child: CommonButton(
text: runState.status == RunStatus.paused
? (l10n?.continue_ ?? '继续')
: (l10n?.run ?? '运行'),
: (l10n?.pause ?? '暂停'),
icon: runState.status == RunStatus.paused
? Icons.play_arrow
: Icons.play_arrow,
: Icons.pause,
type: ButtonType.primary,
onPressed: () => runNotifier.resume(),
),
),
),
const SizedBox(width: 12),
// 暂停按钮
Expanded(
child: SizedBox(
height: 48,
child: CommonButton(
text: l10n?.pause ?? '暂停',
icon: Icons.pause,
type: ButtonType.warning,
onPressed: runState.status == RunStatus.paused
? null
: () => runNotifier.pause(),
onPressed: () {
if (runState.status == RunStatus.paused) {
runNotifier.resume();
} else {
runNotifier.pause();
}
},
),
),
),
@@ -284,37 +230,48 @@ class RunningControlPanel extends ConsumerWidget {
),
],
),
],
),
);
}
const SizedBox(height: 12),
// 状态指示
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: runState.status == RunStatus.paused
? AppTheme.accentWarning
: AppTheme.statusRunning,
),
),
const SizedBox(width: 8),
Text(
runState.status == RunStatus.paused
? (l10n?.paused ?? '已暂停')
: (l10n?.running ?? '运行中'),
style: TextStyle(
color: runState.status == RunStatus.paused
? AppTheme.accentWarning
: AppTheme.statusRunning,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
],
/// 显示瓷套棒放置确认对话框,确认后启动程序
void _confirmAndStart(
BuildContext context,
RunStateNotifier runNotifier,
Program program,
AppLocalizations? l10n,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppTheme.cardBg,
title: Text(
l10n?.ceramicSleeveConfirm ?? '运行前请确认已安装瓷套棒',
style: const TextStyle(color: AppTheme.textHeading),
),
content: Text(
l10n?.ceramicSleeveConfirmMessage ?? '请确认已放置瓷套棒后再启动程序。',
style: const TextStyle(color: AppTheme.textPrimary),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
l10n?.cancel ?? '取消',
style: const TextStyle(color: AppTheme.textSecondary),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentPrimary,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(context).pop();
runNotifier.start(program);
},
child: Text(l10n?.confirm ?? '确认'),
),
],
),

View File

@@ -5,17 +5,35 @@ import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/widgets/status_indicator.dart';
import '../../device/providers/device_info_provider.dart';
import '../../device/providers/serial_provider.dart';
import '../../device/services/serial_port_service.dart';
/// 状态栏标签项数据
class StatusBarTab {
final IconData icon;
final String label;
const StatusBarTab({required this.icon, required this.label});
}
/// 状态栏组件 - 明亮工业风格
/// 显示设备名称、实时时钟、系统状态、照明控制
/// 显示: 设备名称 | 导航标签 | 灯按钮 | 状态指示器 | 实时时钟
///
/// [tabs] 不为空时,会在设备名右侧渲染胶囊样式的导航标签按钮组,
/// 由父组件通过 [currentTabIndex] 和 [onTabChanged] 控制切换。
class StatusBar extends ConsumerStatefulWidget {
final bool isRunning;
final VoidCallback? onLightToggle;
final List<StatusBarTab> tabs;
final int currentTabIndex;
final ValueChanged<int>? onTabChanged;
const StatusBar({
super.key,
this.isRunning = false,
this.onLightToggle,
this.tabs = const [],
this.currentTabIndex = 0,
this.onTabChanged,
});
@override
@@ -39,6 +57,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
super.dispose();
}
/// 更新当前时间显示
void _updateTime() {
final now = DateTime.now();
_currentTime =
@@ -49,6 +68,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
String _twoDigits(int n) => n.toString().padLeft(2, '0');
/// 切换照明开关
Future<void> _onLightTap() async {
widget.onLightToggle?.call();
await ref.read(deviceInfoProvider.notifier).toggleLight();
@@ -58,6 +78,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final deviceInfo = ref.watch(deviceInfoProvider);
final serialState = ref.watch(serialConnectionStateProvider);
return Container(
height: 56,
@@ -74,27 +95,19 @@ class _StatusBarState extends ConsumerState<StatusBar> {
),
child: Row(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.precision_manufacturing, color: Colors.white, size: 22),
const SizedBox(width: 10),
Text(
l10n?.deviceName ?? '污水毒品前处理一体机',
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
],
),
const Icon(Icons.precision_manufacturing, color: Colors.white, size: 22),
if (widget.tabs.isNotEmpty) ...[
const SizedBox(width: 32),
_buildNavTabs(),
],
const Spacer(),
_LightToggleButton(
isOn: deviceInfo.lightingOn,
onTap: _onLightTap,
),
const SizedBox(width: 20),
_SerialConnectionIndicator(state: serialState),
const SizedBox(width: 20),
StatusIndicator(
text: widget.isRunning
? (l10n?.running ?? '运行中')
@@ -117,6 +130,92 @@ class _StatusBarState extends ConsumerState<StatusBar> {
),
);
}
/// 构建标题栏内嵌的导航标签按钮组(胶囊样式)
Widget _buildNavTabs() {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(widget.tabs.length, (index) {
final tab = widget.tabs[index];
return Padding(
padding: EdgeInsets.only(right: index == widget.tabs.length - 1 ? 0 : 6),
child: _NavTabButton(
icon: tab.icon,
label: tab.label,
selected: index == widget.currentTabIndex,
onTap: () => widget.onTabChanged?.call(index),
),
);
}),
);
}
}
/// 标题栏内嵌的导航标签按钮(胶囊样式)
/// 选中时使用半透明白色背景突出,未选中时仅显示文字
class _NavTabButton extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback? onTap;
const _NavTabButton({
required this.icon,
required this.label,
required this.selected,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.22)
: Colors.transparent,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected
? Colors.white.withValues(alpha: 0.35)
: Colors.transparent,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: selected
? Colors.white
: Colors.white.withValues(alpha: 0.78),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: selected
? Colors.white
: Colors.white.withValues(alpha: 0.85),
fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _LightToggleButton extends StatelessWidget {
@@ -154,3 +253,50 @@ class _LightToggleButton extends StatelessWidget {
);
}
}
/// 串口连接状态指示器
///
/// 位于「设备运行状态」之前,反映当前 USB 串口的连接情况。
class _SerialConnectionIndicator extends StatelessWidget {
final SerialConnectionState state;
const _SerialConnectionIndicator({required this.state});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final connected = state == SerialConnectionState.connected;
final connecting = state == SerialConnectionState.connecting;
final text = connected
? (l10n?.serialConnected ?? '已连接')
: connecting
? (l10n?.serialConnecting ?? '连接中')
: (l10n?.serialDisconnected ?? '未连接');
final color = connected
? AppTheme.statusRunning
: connecting
? AppTheme.statusPaused
: AppTheme.statusStopped;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
),
const SizedBox(width: 6),
Text(
text,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
);
}
}

View File

@@ -7,10 +7,12 @@ import '../../../shared/widgets/common_button.dart';
import '../providers/steps_provider.dart';
import '../widgets/step_list.dart';
import '../widgets/step_form.dart';
import '../../home/widgets/status_bar.dart';
import '../../device/providers/run_state_provider.dart';
import '../../programs/providers/programs_provider.dart';
/// 程序详情页面
/// 左侧步骤列表 + 右侧参数表单
/// 布局:顶部状态栏(含导航tab) + 子工具栏(返回/程序名/保存) + 左侧步骤列表 + 右侧参数表单
class ProgramDetailPage extends ConsumerStatefulWidget {
final String programId;
@@ -35,61 +37,31 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
final programsState = ref.watch(programsProvider);
final program = programsState.programs.where((p) => p.id == _programIdInt).firstOrNull;
final stepsState = ref.watch(stepsProvider(_programIdInt));
final runState = ref.watch(runStateProvider);
// 详情页从程序管理进入,高亮"程序管理"tab(index=1)
final tabs = <StatusBarTab>[
StatusBarTab(icon: Icons.dashboard, label: l10n?.deviceControl ?? '设备控制'),
StatusBarTab(icon: Icons.list_alt, label: l10n?.programs ?? '程序管理'),
StatusBarTab(icon: Icons.settings, label: l10n?.settings ?? '系统设置'),
];
return Scaffold(
body: Container(
color: AppTheme.backgroundColor,
child: Column(
children: [
// 顶部导航栏
Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 返回按钮
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/programs'),
),
const SizedBox(width: 16),
// 程序名称
Text(
program?.name ?? (l10n?.detail ?? '程序详情'),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// 保存按钮
CommonButton(
text: l10n?.save ?? '保存',
icon: Icons.save,
type: ButtonType.primary,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已保存'),
backgroundColor: AppTheme.successColor,
),
);
},
),
],
),
// 顶部状态栏(含导航tab),tab点击跳回首页对应 tab
StatusBar(
isRunning: runState.status == RunStatus.running,
tabs: tabs,
currentTabIndex: 1,
onTabChanged: (index) => context.go('/?tab=$index'),
),
// 子工具栏:返回按钮 + 程序名 + 保存按钮
_buildSubToolbar(context, l10n, program?.name),
// 主内容区域
Expanded(
child: stepsState.isLoading
@@ -135,7 +107,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('步骤已更新'),
content: Text(l10n?.stepUpdated ?? '步骤已更新'),
backgroundColor: AppTheme.successColor,
),
);
@@ -153,7 +125,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
),
const SizedBox(height: 16),
Text(
'请选择或添加步骤',
l10n?.selectStepFirst ?? '请选择或添加步骤',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 16,
@@ -172,6 +144,57 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
);
}
/// 子工具栏:返回按钮 + 程序名 + 保存按钮
/// 位于状态栏下方,提供详情页特有的操作入口
Widget _buildSubToolbar(BuildContext context, AppLocalizations? l10n, String? programName) {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: l10n?.back ?? '返回',
onPressed: () => context.go('/?tab=1'),
),
const SizedBox(width: 8),
Text(
programName ?? (l10n?.detail ?? '程序详情'),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textHeading,
),
),
const Spacer(),
CommonButton(
text: l10n?.save ?? '保存',
icon: Icons.save,
type: ButtonType.primary,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n?.saved ?? '已保存'),
backgroundColor: AppTheme.successColor,
),
);
},
),
],
),
);
}
/// 显示添加步骤对话框
void _showAddStepDialog(BuildContext context, WidgetRef ref) {
showDialog(

View File

@@ -34,9 +34,7 @@ class _StepFormState extends State<StepForm> {
late TextEditingController _blowTimeController;
String _position = 'A1';
String _mixSpeed = '中速';
String _blowSpeed = '中速';
int _needleSpeed = 5;
int _speed = 5;
@override
void initState() {
@@ -48,9 +46,7 @@ class _StepFormState extends State<StepForm> {
_blowTimeController = TextEditingController(text: '${widget.step?.blowTime ?? 0}');
_position = widget.step?.position ?? 'A1';
_mixSpeed = widget.step?.mixSpeed ?? '中速';
_blowSpeed = widget.step?.blowSpeed ?? '中速';
_needleSpeed = widget.step?.needleSpeed ?? 5;
_speed = widget.step?.speed ?? 5;
}
@override
@@ -76,7 +72,7 @@ class _StepFormState extends State<StepForm> {
children: [
// 标题
Text(
widget.isNew ? '添加步骤' : '编辑步骤',
widget.isNew ? (l10n?.addStep ?? '添加步骤') : (l10n?.editStep ?? '编辑步骤'),
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 18,
@@ -90,12 +86,12 @@ class _StepFormState extends State<StepForm> {
controller: _nameController,
decoration: InputDecoration(
labelText: l10n?.stepName ?? '步骤名称',
hintText: '例如: 混合、吸磁、吹气',
hintText: l10n?.hintStepName ?? '例如: 混合、吸磁、吹气',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入步骤名称';
return l10n?.enterStepName ?? '请输入步骤名称';
}
return null;
},
@@ -179,53 +175,22 @@ class _StepFormState extends State<StepForm> {
),
const SizedBox(height: 16),
// 速度选择
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: _mixSpeed,
decoration: InputDecoration(
labelText: l10n?.mixSpeed ?? '混合速度',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: Constants.speedOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (value) {
if (value != null) setState(() => _mixSpeed = value);
},
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<String>(
value: _blowSpeed,
decoration: InputDecoration(
labelText: l10n?.blowSpeed ?? '吹气速度',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: Constants.speedOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (value) {
if (value != null) setState(() => _blowSpeed = value);
},
),
),
],
),
const SizedBox(height: 16),
// 下针速度滑块
// 速度滑块
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${l10n?.needleSpeed ?? '下针速度'}: $_needleSpeed', style: TextStyle(color: AppTheme.textPrimary)),
Text(
'${l10n?.speed ?? '速度'}: ${l10n?.speedLevelValue(_speed) ?? '$_speed'}',
style: TextStyle(color: AppTheme.textPrimary),
),
Slider(
value: _needleSpeed.toDouble(),
value: _speed.toDouble(),
min: 1,
max: 10,
divisions: 9,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() => _needleSpeed = value.round());
setState(() => _speed = value.round());
},
),
],
@@ -259,10 +224,8 @@ class _StepFormState extends State<StepForm> {
mixTime: int.tryParse(_mixTimeController.text) ?? 0,
magnetTime: int.tryParse(_magnetTimeController.text) ?? 0,
volume: int.tryParse(_volumeController.text) ?? 0,
mixSpeed: _mixSpeed,
blowSpeed: _blowSpeed,
blowTime: int.tryParse(_blowTimeController.text) ?? 0,
needleSpeed: _needleSpeed,
speed: _speed,
);
widget.onSave(step);

View File

@@ -53,7 +53,7 @@ class _StepListState extends State<StepList> {
Icon(Icons.list, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 12),
Text(
'步骤列表',
l10n?.stepList ?? '步骤列表',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
@@ -61,7 +61,7 @@ class _StepListState extends State<StepList> {
),
const Spacer(),
Text(
'${widget.steps.length}',
l10n?.stepsCountLabel(widget.steps.length) ?? '${widget.steps.length}',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
@@ -114,7 +114,7 @@ class _StepListState extends State<StepList> {
children: [
Icon(Icons.add_circle_outline, size: 48, color: AppTheme.idleColor),
const SizedBox(height: 12),
Text('暂无步骤', style: TextStyle(color: AppTheme.textSecondary)),
Text(l10n?.noSteps ?? '暂无步骤', style: TextStyle(color: AppTheme.textSecondary)),
],
),
)
@@ -149,7 +149,7 @@ class _StepListState extends State<StepList> {
children: [
// 添加按钮
CommonButton(
text: '添加',
text: l10n?.add ?? '添加',
icon: Icons.add,
type: ButtonType.primary,
onPressed: widget.onAddStep,
@@ -158,7 +158,7 @@ class _StepListState extends State<StepList> {
// 删除按钮
if (_selectedIds.isNotEmpty)
CommonButton(
text: '删除',
text: l10n?.delete ?? '删除',
icon: Icons.delete,
type: ButtonType.danger,
onPressed: () => _showDeleteConfirmDialog(context),
@@ -241,8 +241,8 @@ class _StepListState extends State<StepList> {
title: Text(l10n?.confirm ?? '确认'),
content: Text(
_selectedIds.length == 1
? '确定要删除此步骤吗?'
: '确定要删除选中的 ${_selectedIds.length} 个步骤吗?',
? l10n?.deleteStepConfirmSingle ?? '确定要删除此步骤吗?'
: l10n?.deleteStepConfirmMultiple(_selectedIds.length) ?? '确定要删除选中的 ${_selectedIds.length} 个步骤吗?',
),
actions: [
TextButton(

View File

@@ -5,6 +5,8 @@ class Program {
final String name;
final String createdAt;
final int status; // 1: 启用, 0: 停用
final int temperature;
final int airflowTime;
Program({
this.id,
@@ -12,6 +14,8 @@ class Program {
required this.name,
required this.createdAt,
this.status = 1,
this.temperature = 50,
this.airflowTime = 60,
});
Map<String, dynamic> toMap() {
@@ -21,6 +25,8 @@ class Program {
'name': name,
'created_at': createdAt,
'status': status,
'temperature': temperature,
'airflow_time': airflowTime,
};
}
@@ -31,6 +37,8 @@ class Program {
name: map['name'] as String,
createdAt: map['created_at'] as String,
status: map['status'] as int? ?? 1,
temperature: map['temperature'] as int? ?? 50,
airflowTime: map['airflow_time'] as int? ?? 60,
);
}
@@ -40,6 +48,8 @@ class Program {
String? name,
String? createdAt,
int? status,
int? temperature,
int? airflowTime,
}) {
return Program(
id: id ?? this.id,
@@ -47,6 +57,8 @@ class Program {
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
status: status ?? this.status,
temperature: temperature ?? this.temperature,
airflowTime: airflowTime ?? this.airflowTime,
);
}
}
}

View File

@@ -8,10 +8,8 @@ class Step {
final int mixTime;
final int magnetTime;
final int volume;
final String mixSpeed;
final String blowSpeed;
final int blowTime;
final int needleSpeed;
final int speed;
Step({
this.id,
@@ -22,10 +20,8 @@ class Step {
this.mixTime = 0,
this.magnetTime = 0,
this.volume = 0,
this.mixSpeed = '中速',
this.blowSpeed = '中速',
this.blowTime = 0,
this.needleSpeed = 5,
this.speed = 5,
});
Map<String, dynamic> toMap() {
@@ -38,10 +34,8 @@ class Step {
'mix_time': mixTime,
'magnet_time': magnetTime,
'volume': volume,
'mix_speed': mixSpeed,
'blow_speed': blowSpeed,
'blow_time': blowTime,
'needle_speed': needleSpeed,
'speed': speed,
};
}
@@ -55,10 +49,8 @@ class Step {
mixTime: map['mix_time'] as int? ?? 0,
magnetTime: map['magnet_time'] as int? ?? 0,
volume: map['volume'] as int? ?? 0,
mixSpeed: map['mix_speed'] as String? ?? '中速',
blowSpeed: map['blow_speed'] as String? ?? '中速',
blowTime: map['blow_time'] as int? ?? 0,
needleSpeed: map['needle_speed'] as int? ?? 5,
speed: map['speed'] as int? ?? 5,
);
}
@@ -71,10 +63,8 @@ class Step {
int? mixTime,
int? magnetTime,
int? volume,
String? mixSpeed,
String? blowSpeed,
int? blowTime,
int? needleSpeed,
int? speed,
}) {
return Step(
id: id ?? this.id,
@@ -85,10 +75,8 @@ class Step {
mixTime: mixTime ?? this.mixTime,
magnetTime: magnetTime ?? this.magnetTime,
volume: volume ?? this.volume,
mixSpeed: mixSpeed ?? this.mixSpeed,
blowSpeed: blowSpeed ?? this.blowSpeed,
blowTime: blowTime ?? this.blowTime,
needleSpeed: needleSpeed ?? this.needleSpeed,
speed: speed ?? this.speed,
);
}
}
}

View File

@@ -5,11 +5,13 @@ import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/services/toast_service.dart';
import '../../../shared/widgets/common_button.dart';
import '../models/program.dart';
import '../providers/programs_provider.dart';
import '../widgets/program_form_dialog.dart';
import '../services/program_import_service.dart';
import '../services/excel_import_service.dart';
import '../services/excel_template_service.dart';
/// 程序管理页面
class ProgramsPage extends ConsumerStatefulWidget {
@@ -48,12 +50,6 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
),
child: Row(
children: [
// 返回按钮
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
const SizedBox(width: 16),
Text(
l10n?.programs ?? '程序管理',
style: const TextStyle(
@@ -70,6 +66,14 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
onPressed: () => _showAddDialog(context, ref),
),
const SizedBox(width: 12),
// 下载模板按钮
CommonButton(
text: l10n?.downloadTemplate ?? '下载模板',
icon: Icons.file_download,
type: ButtonType.secondary,
onPressed: () => _downloadTemplate(context),
),
const SizedBox(width: 12),
// 导入按钮
CommonButton(
text: l10n?.importProgram ?? '导入',
@@ -255,7 +259,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
SizedBox(
width: 80,
child: Text(
'状态',
l10n?.status ?? '状态',
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
@@ -354,7 +358,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
program.status == 1 ? '启用' : '停用',
program.status == 1 ? (l10n?.enabled ?? '启用') : (l10n?.disabled ?? '停用'),
style: TextStyle(
color: program.status == 1
? AppTheme.successColor
@@ -409,11 +413,12 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
/// 导入程序
Future<void> _importPrograms(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context);
try {
// 选择文件
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
allowedExtensions: ['xlsx'],
allowMultiple: false,
);
@@ -423,38 +428,40 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
final file = result.files.first;
if (file.path == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('无法读取文件'),
backgroundColor: AppTheme.errorColor,
),
);
if (!context.mounted) return;
ToastService.showError(context, l10n?.cannotReadFile ?? '无法读取文件');
return;
}
// 读取文件内容
final jsonContent = await File(file.path!).readAsString();
// 导入程序
final importedCount = await ProgramImportService.instance.importFromJson(jsonContent);
// 解析并写入数据库
final importedCount =
await ExcelImportService.instance.importFromExcel(File(file.path!));
// 刷新程序列表
ref.read(programsProvider.notifier).loadPrograms();
// 显示结果
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('成功导入 $importedCount 个程序'),
backgroundColor: importedCount > 0 ? AppTheme.successColor : AppTheme.warningColor,
),
);
if (!context.mounted) return;
if (importedCount > 0) {
ToastService.showSuccess(context, l10n?.processedPrograms(importedCount) ?? '成功处理 $importedCount 个程序');
} else {
ToastService.showWarning(context, l10n?.noValidProgramData ?? 'Excel 中无有效程序数据');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('导入失败: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
),
);
if (!context.mounted) return;
ToastService.showError(context, '${l10n?.importFailed ?? '导入失败'}: ${e.toString()}');
}
}
/// 下载 Excel 模板
Future<void> _downloadTemplate(BuildContext context) async {
final l10n = AppLocalizations.of(context);
try {
final path = await ExcelTemplateService.instance.generateTemplate();
if (!context.mounted) return;
ToastService.showSuccess(context, '${l10n?.templateSaved ?? '模板已保存'}: $path');
} catch (e) {
if (!context.mounted) return;
ToastService.showError(context, '${l10n?.generateTemplateFailed ?? '生成模板失败'}: ${e.toString()}');
}
}
@@ -479,8 +486,8 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
title: Text(l10n?.confirm ?? '确认'),
content: Text(
ids.length == 1
? '确定要删除此程序吗?'
: '确定要删除选中的 ${ids.length} 个程序吗?',
? (l10n?.deleteConfirmSingle ?? '确定要删除此程序吗?')
: (l10n?.deleteConfirmMultiple(ids.length) ?? '确定要删除选中的 ${ids.length} 个程序吗?'),
),
actions: [
TextButton(

View File

@@ -0,0 +1,237 @@
import 'dart:io';
import 'package:excel/excel.dart';
import '../models/program.dart';
import '../models/step.dart';
import '../services/program_service.dart';
import 'excel_template_service.dart';
/// Excel 导入服务
///
/// 解析用户填好的 .xlsx 并通过 [ProgramService] 写入数据库。
/// 模板结构与 [ExcelTemplateService] 一致Programs + Steps 双表,
/// 通过 program_code 关联。
class ExcelImportService {
static final ExcelImportService instance = ExcelImportService._internal();
final ProgramService _programService = ProgramService.instance;
ExcelImportService._internal();
/// 从 .xlsx 文件导入程序,返回成功处理的程序数量(新建 + 覆盖)
///
/// 行为:
/// - code 不存在:新建程序 + 写入步骤
/// - code 已存在:全量覆盖程序字段,并删除旧步骤后写入新步骤
Future<int> importFromExcel(File file) async {
final bytes = await file.readAsBytes();
final excel = Excel.decodeBytes(bytes);
final programsSheet = _findSheet(excel, ExcelTemplateService.sheetPrograms);
if (programsSheet == null) {
throw const ExcelImportException('缺少 Programs 工作表');
}
final programs = _parsePrograms(programsSheet);
if (programs.isEmpty) {
throw const ExcelImportException('Programs 表无有效数据');
}
// 已有 code → 完整 Program用于覆盖时取 id
final existing = await _programService.getAllPrograms();
final existingByCode = <String, Program>{
for (final p in existing) p.code: p,
};
// 解析步骤
final stepsSheet = _findSheet(excel, ExcelTemplateService.sheetSteps);
final stepsByCode = stepsSheet == null
? <String, List<_RawStep>>{}
: _parseSteps(stepsSheet);
int processedCount = 0;
for (final program in programs) {
try {
final existingProgram = existingByCode[program.code];
final int programId;
if (existingProgram == null) {
programId = await _programService.addProgram(program);
} else {
// 全量覆盖:保留 id其余字段取 Excel
programId = existingProgram.id!;
await _programService.updateProgram(
existingProgram.copyWith(
name: program.name,
temperature: program.temperature,
airflowTime: program.airflowTime,
status: program.status,
createdAt: program.createdAt,
),
);
// 清空旧步骤
final oldSteps =
await _programService.getStepsByProgramId(programId);
if (oldSteps.isNotEmpty) {
await _programService.deleteSteps(
oldSteps.map((s) => s.id!).toList(),
);
}
}
// 写入新步骤(按 step_no 排序后重新编号为 1..N
final rawSteps = stepsByCode[program.code] ?? const <_RawStep>[];
rawSteps.sort((a, b) => a.stepNo.compareTo(b.stepNo));
for (var i = 0; i < rawSteps.length; i++) {
final raw = rawSteps[i];
final step = Step(
programId: programId,
stepNo: i + 1,
position: raw.position,
name: raw.name,
mixTime: raw.mixTime,
magnetTime: raw.magnetTime,
volume: raw.volume,
blowTime: raw.blowTime,
speed: raw.speed,
);
await _programService.addStep(step);
}
processedCount++;
} catch (_) {
// 单条失败不影响其他程序
continue;
}
}
return processedCount;
}
Sheet? _findSheet(Excel excel, String name) {
for (final entry in excel.tables.entries) {
if (entry.key == name) return entry.value;
}
return null;
}
/// 解析 Programs 表
/// 返回不含 id 的 Program 列表createdAt 由调用方填充)
List<Program> _parsePrograms(Sheet sheet) {
final rows = _dataRows(sheet);
final results = <Program>[];
for (final cells in rows) {
final code = _readString(cells, 0);
final name = _readString(cells, 1);
if (code.isEmpty || name.isEmpty) continue;
results.add(
Program(
code: code,
name: name,
createdAt: DateTime.now().toString().split('.').first,
status: _readInt(cells, 4, 1) == 0 ? 0 : 1,
temperature: _readInt(cells, 2, 50),
airflowTime: _readInt(cells, 3, 60),
),
);
}
return results;
}
/// 解析 Steps 表
/// 以 program_code 为 key 分组
Map<String, List<_RawStep>> _parseSteps(Sheet sheet) {
final rows = _dataRows(sheet);
final map = <String, List<_RawStep>>{};
for (final cells in rows) {
final programCode = _readString(cells, 0);
if (programCode.isEmpty) continue;
final position = _readString(cells, 2, 'A1');
final name = _readString(cells, 3);
if (name.isEmpty) continue;
final raw = _RawStep(
stepNo: _readInt(cells, 1, 0),
position: position,
name: name,
mixTime: _readInt(cells, 4, 0),
magnetTime: _readInt(cells, 5, 0),
volume: _readInt(cells, 6, 0),
blowTime: _readInt(cells, 7, 0),
speed: _readInt(cells, 8, 5),
);
map.putIfAbsent(programCode, () => []).add(raw);
}
return map;
}
/// 跳过表头和说明行(首行表头,后续以"说明"开头的视为说明)
List<List<Object?>> _dataRows(Sheet sheet) {
final result = <List<Object?>>[];
for (var i = 1; i < sheet.rows.length; i++) {
final row = sheet.rows[i];
// 跳过空行
if (row.every((c) => c == null || c.value == null)) continue;
final firstCell = _asTrimmed(row.first?.value);
if (firstCell.startsWith('说明')) continue;
result.add(row.map((c) => c?.value).toList(growable: false));
}
return result;
}
String _readString(List<Object?> row, int index, [String fallback = '']) {
if (index >= row.length) return fallback;
final v = row[index];
if (v == null) return fallback;
final s = v.toString().trim();
return s.isEmpty ? fallback : s;
}
int _readInt(List<Object?> row, int index, int fallback) {
if (index >= row.length) return fallback;
final v = row[index];
if (v == null) return fallback;
if (v is int) return v;
if (v is double) return v.toInt();
final parsed = int.tryParse(v.toString().trim());
return parsed ?? fallback;
}
String _asTrimmed(Object? v) {
if (v == null) return '';
return v.toString().trim();
}
}
class _RawStep {
final int stepNo;
final String position;
final String name;
final int mixTime;
final int magnetTime;
final int volume;
final int blowTime;
final int speed;
_RawStep({
required this.stepNo,
required this.position,
required this.name,
required this.mixTime,
required this.magnetTime,
required this.volume,
required this.blowTime,
required this.speed,
});
}
/// Excel 导入异常
class ExcelImportException implements Exception {
final String message;
const ExcelImportException(this.message);
@override
String toString() => message;
}

View File

@@ -0,0 +1,161 @@
import 'dart:io';
import 'package:excel/excel.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
/// Excel 模板服务
///
/// 生成 .xlsx 模板用于程序/步骤批量导入。
/// 模板包含两张表:
/// - Programs程序基础信息
/// - Steps步骤参数通过 program_code 与 Programs 关联)
class ExcelTemplateService {
static final ExcelTemplateService instance = ExcelTemplateService._internal();
ExcelTemplateService._internal();
/// 工作表名称
static const String sheetPrograms = 'Programs';
static const String sheetSteps = 'Steps';
/// 模板文件名
static const String templateFilename = 'program_template.xlsx';
/// Android 端写入公共 Downloads 的 MethodChannel对应 MainActivity.kt
static const MethodChannel _downloadsChannel =
MethodChannel('com.xiarui.kuaishai2/downloads');
/// 生成模板并保存到下载目录,返回可显示的路径字符串
///
/// - Android通过 MediaStore 写入公共 Downloads (`/storage/emulated/0/Download/`)
/// - 其它平台:使用 [getDownloadsDirectory],不可用时回退到应用文档目录
Future<String> generateTemplate() async {
final excel = Excel.createExcel();
final bytes = _buildTemplateBytes(excel);
if (Platform.isAndroid) {
final saved = await _downloadsChannel.invokeMethod<String>(
'saveToDownloads',
<String, dynamic>{
'filename': templateFilename,
'bytes': bytes,
},
);
if (saved == null) {
throw StateError('保存到 Downloads 失败:未返回路径');
}
return saved;
}
final dir =
await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$templateFilename');
await file.writeAsBytes(bytes, flush: true);
return file.path;
}
/// 构建模板字节流(可在测试中直接调用)
List<int> _buildTemplateBytes(Excel excel) {
// 默认创建的第一个 sheet 重命名
final defaultSheet = excel.getDefaultSheet();
if (defaultSheet != null && defaultSheet != sheetPrograms) {
excel.rename(defaultSheet, sheetPrograms);
}
_writeProgramsSheet(excel);
_writeStepsSheet(excel);
return excel.encode()!;
}
void _writeProgramsSheet(Excel excel) {
final sheet = excel[sheetPrograms];
final headerStyle = CellStyle(
bold: true,
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
horizontalAlign: HorizontalAlign.Center,
);
// 表头
final headers = ['code', 'name', 'temperature', 'airflowTime', 'status'];
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
for (var i = 0; i < headers.length; i++) {
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
.cellStyle = headerStyle;
}
// 示例行
final sample1 = ['P001', '示例程序-标准流程', 50, 60, 1];
final sample2 = ['P002', '示例程序-快速流程', 45, 30, 1];
sheet.appendRow(sample1.map((v) => TextCellValue(v.toString())).toList());
sheet.appendRow(sample2.map((v) => TextCellValue(v.toString())).toList());
// 说明行(用前缀标记,避免被解析)
final note = '说明code 必填且唯一name 必填temperature/airflowTime 整数status:1启用 0停用';
sheet.appendRow([TextCellValue(note)]);
// 列宽
sheet.setColumnWidth(0, 14);
sheet.setColumnWidth(1, 26);
sheet.setColumnWidth(2, 14);
sheet.setColumnWidth(3, 14);
sheet.setColumnWidth(4, 10);
}
void _writeStepsSheet(Excel excel) {
final sheet = excel[sheetSteps];
final headerStyle = CellStyle(
bold: true,
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
horizontalAlign: HorizontalAlign.Center,
);
final headers = [
'program_code',
'step_no',
'position',
'name',
'mixTime',
'magnetTime',
'volume',
'blowTime',
'speed',
];
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
for (var i = 0; i < headers.length; i++) {
sheet
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
.cellStyle = headerStyle;
}
// 示例行P001 / P002 各两步
final samples = <List<Object>>[
['P001', 1, 'A1', '加样', 30, 0, 500, 0, 5],
['P001', 2, 'A2', '混合', 60, 10, 300, 30, 5],
['P002', 1, 'B1', '混合', 20, 5, 400, 0, 4],
];
for (final row in samples) {
sheet.appendRow(row.map((v) {
if (v is num) return IntCellValue(v.toInt());
return TextCellValue(v.toString());
}).toList());
}
// 说明行
final note =
'说明program_code 对应 Programs.codestep_no 整数从 1 开始position 形如 A1/B2mixTime/magnetTime/volume/blowTime 单位秒或微升speed 1-10';
sheet.appendRow([TextCellValue(note)]);
// 列宽
sheet.setColumnWidth(0, 14);
sheet.setColumnWidth(1, 10);
sheet.setColumnWidth(2, 12);
sheet.setColumnWidth(3, 14);
sheet.setColumnWidth(4, 10);
sheet.setColumnWidth(5, 12);
sheet.setColumnWidth(6, 10);
sheet.setColumnWidth(7, 10);
sheet.setColumnWidth(8, 8);
}
}

View File

@@ -1,126 +0,0 @@
import 'dart:convert';
import '../../programs/models/program.dart';
import '../../programs/models/step.dart';
import '../../programs/services/program_service.dart';
/// 程序导入服务
class ProgramImportService {
static final ProgramImportService instance = ProgramImportService._internal();
final ProgramService _programService = ProgramService.instance;
ProgramImportService._internal();
/// 从 JSON 字符串导入程序
/// 返回导入的程序数量
Future<int> importFromJson(String jsonContent) async {
final data = jsonDecode(jsonContent);
// 支持单个程序或程序数组
final List<dynamic> programsData;
if (data is List) {
programsData = data;
} else if (data is Map && data.containsKey('programs')) {
programsData = data['programs'] as List;
} else {
programsData = [data];
}
int importedCount = 0;
for (final programData in programsData) {
try {
// 验证必填字段
if (!_validateProgramData(programData)) {
continue;
}
// 检查编号是否已存在
final existingPrograms = await _programService.getAllPrograms();
final code = programData['code'] as String;
if (existingPrograms.any((p) => p.code == code)) {
// 编号已存在,跳过或使用新编号
continue;
}
// 创建程序
final program = Program(
code: code,
name: programData['name'] as String,
createdAt: programData['createdAt'] ?? DateTime.now().toString().split('.')[0],
status: programData['status'] ?? 1,
);
final programId = await _programService.addProgram(program);
// 导入步骤
final stepsData = programData['steps'] as List?;
if (stepsData != null) {
for (int i = 0; i < stepsData.length; i++) {
final stepData = stepsData[i];
final step = Step(
programId: programId,
stepNo: i + 1,
position: stepData['position'] as String? ?? 'A1',
name: stepData['name'] as String? ?? '步骤${i + 1}',
mixTime: stepData['mixTime'] as int? ?? 0,
magnetTime: stepData['magnetTime'] as int? ?? 0,
volume: stepData['volume'] as int? ?? 0,
mixSpeed: stepData['mixSpeed'] as String? ?? '中速',
blowSpeed: stepData['blowSpeed'] as String? ?? '中速',
blowTime: stepData['blowTime'] as int? ?? 0,
needleSpeed: stepData['needleSpeed'] as int? ?? 5,
);
await _programService.addStep(step);
}
}
importedCount++;
} catch (e) {
// 忽略单个程序导入错误
continue;
}
}
return importedCount;
}
/// 验证程序数据
bool _validateProgramData(Map<String, dynamic> data) {
return data.containsKey('code') &&
data.containsKey('name') &&
data['code'] is String &&
data['name'] is String;
}
/// 导出程序为 JSON
Future<String> exportToJson(List<int> programIds) async {
final programs = [];
for (final id in programIds) {
final program = await _programService.getProgramById(id);
if (program == null) continue;
final steps = await _programService.getStepsByProgramId(id);
programs.add({
'code': program.code,
'name': program.name,
'createdAt': program.createdAt,
'status': program.status,
'steps': steps.map((s) => {
'position': s.position,
'name': s.name,
'mixTime': s.mixTime,
'magnetTime': s.magnetTime,
'volume': s.volume,
'mixSpeed': s.mixSpeed,
'blowSpeed': s.blowSpeed,
'blowTime': s.blowTime,
'needleSpeed': s.needleSpeed,
}).toList(),
});
}
return jsonEncode({'programs': programs});
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/utils/constants.dart';
import '../../../shared/widgets/common_button.dart';
import '../models/program.dart';
import '../providers/programs_provider.dart';
@@ -21,6 +22,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _codeController;
late TextEditingController _nameController;
late TextEditingController _temperatureController;
late TextEditingController _airflowTimeController;
bool _isEnabled = true;
bool _isSaving = false;
@@ -29,6 +32,10 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
super.initState();
_codeController = TextEditingController(text: widget.program?.code ?? '');
_nameController = TextEditingController(text: widget.program?.name ?? '');
_temperatureController =
TextEditingController(text: '${widget.program?.temperature ?? 50}');
_airflowTimeController =
TextEditingController(text: '${widget.program?.airflowTime ?? 60}');
_isEnabled = widget.program?.status == 1;
}
@@ -36,6 +43,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
void dispose() {
_codeController.dispose();
_nameController.dispose();
_temperatureController.dispose();
_airflowTimeController.dispose();
super.dispose();
}
@@ -62,14 +71,14 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
controller: _codeController,
decoration: InputDecoration(
labelText: l10n?.programCode ?? '编号',
hintText: '例如: P001',
hintText: l10n?.hintCode ?? '例如: P001',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入编号';
return l10n?.enterCode ?? '请输入编号';
}
return null;
},
@@ -81,25 +90,57 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
controller: _nameController,
decoration: InputDecoration(
labelText: l10n?.programName ?? '名称',
hintText: '请输入程序名称',
hintText: l10n?.hintProgramName ?? '请输入程序名称',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入名称';
return l10n?.enterName ?? '请输入名称';
}
return null;
},
),
const SizedBox(height: 16),
// 温度和吹气时间
Row(
children: [
Expanded(
child: TextFormField(
controller: _temperatureController,
decoration: InputDecoration(
labelText: '${l10n?.temperature ?? '温度'} (°C)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _airflowTimeController,
decoration: InputDecoration(
labelText: '${l10n?.airflowTime ?? '吹气时间'} (${Constants.timeUnitSeconds})',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.number,
),
),
],
),
const SizedBox(height: 16),
// 状态开关
Row(
children: [
Text(
'状态',
l10n?.status ?? '状态',
style: TextStyle(color: AppTheme.textPrimary),
),
const Spacer(),
@@ -113,7 +154,7 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
activeColor: AppTheme.successColor,
),
Text(
_isEnabled ? '启用' : '停用',
_isEnabled ? (l10n?.enabled ?? '启用') : (l10n?.disabled ?? '停用'),
style: TextStyle(
color: _isEnabled ? AppTheme.successColor : AppTheme.idleColor,
),
@@ -161,6 +202,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
name: _nameController.text.trim(),
createdAt: widget.program?.createdAt ?? now,
status: _isEnabled ? 1 : 0,
temperature: int.tryParse(_temperatureController.text) ?? 50,
airflowTime: int.tryParse(_airflowTimeController.text) ?? 60,
);
bool success;
@@ -179,7 +222,7 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败,请检查编号是否重复'),
content: Text(l10n?.saveFailed ?? '保存失败,请检查编号是否重复'),
backgroundColor: AppTheme.errorColor,
),
);

View File

@@ -5,11 +5,9 @@ import '../../../core/theme/app_theme.dart';
import '../../../shared/widgets/common_button.dart';
import '../widgets/language_panel.dart';
import '../widgets/password_panel.dart';
import '../widgets/serial_config_panel.dart';
import '../widgets/usb_import_panel.dart';
/// 设置页菜单
enum _SettingsMenu { upgrade, language, password, usbImport, serialConfig }
enum _SettingsMenu { upgrade, language, password }
/// 系统设置页面
class SettingsPage extends ConsumerStatefulWidget {
@@ -70,14 +68,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
onTap: () => setState(
() => _currentMenu = _SettingsMenu.upgrade),
),
// 串口配置
_buildMenuItem(
icon: Icons.settings_input_hdmi,
title: '串口配置',
selected: _currentMenu == _SettingsMenu.serialConfig,
onTap: () => setState(
() => _currentMenu = _SettingsMenu.serialConfig),
),
// 语言设置
_buildMenuItem(
icon: Icons.language,
@@ -94,14 +84,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
onTap: () => setState(
() => _currentMenu = _SettingsMenu.password),
),
// U盘导入
_buildMenuItem(
icon: Icons.usb,
title: l10n?.usbImport ?? 'U盘导入',
selected: _currentMenu == _SettingsMenu.usbImport,
onTap: () => setState(
() => _currentMenu = _SettingsMenu.usbImport),
),
],
),
),
@@ -127,20 +109,19 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
Widget _buildContent() {
return switch (_currentMenu) {
_SettingsMenu.serialConfig => const SerialConfigPanel(),
_SettingsMenu.language => const LanguagePanel(),
_SettingsMenu.password => const PasswordPanel(),
_SettingsMenu.usbImport => const UsbImportPanel(),
_SettingsMenu.upgrade => _buildUpgradeContent(),
};
}
Widget _buildUpgradeContent() {
final l10n = AppLocalizations.of(context);
return ListView(
padding: EdgeInsets.zero,
children: [
Text(
'软件升级',
l10n?.upgrade ?? '软件升级',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 18,
@@ -159,7 +140,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
Icon(Icons.info_outline, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Text(
'当前版本: $_currentVersion',
'${l10n?.currentVersion ?? '当前版本'}: $_currentVersion',
style: TextStyle(color: AppTheme.textPrimary),
),
],
@@ -167,13 +148,13 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
const SizedBox(height: 24),
CommonButton(
text: '检查更新',
text: l10n?.checkUpdate ?? '检查更新',
icon: Icons.refresh,
type: ButtonType.primary,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已是最新版本'),
content: Text(l10n?.latestVersion ?? '已是最新版本'),
backgroundColor: AppTheme.successColor,
),
);

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/localization/locale_provider.dart';
import '../../../core/theme/app_theme.dart';
@@ -13,6 +14,7 @@ class LanguagePanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final locale = ref.watch(localeProvider);
final currentLang = locale.languageCode;
@@ -20,7 +22,7 @@ class LanguagePanel extends ConsumerWidget {
padding: EdgeInsets.zero,
children: [
_SectionCard(
title: '语言设置',
title: l10n?.languageSettings ?? '语言设置',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -46,7 +48,7 @@ class LanguagePanel extends ConsumerWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'切换语言后立即生效',
l10n?.switchLanguageEffect ?? '切换语言后立即生效',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/services/toast_service.dart';
import '../services/settings_service.dart';
@@ -33,20 +34,21 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
Future<void> _submit() async {
if (_submitting) return;
final l10n = AppLocalizations.of(context);
final oldPwd = _oldCtrl.text.trim();
final newPwd = _newCtrl.text.trim();
final confirmPwd = _confirmCtrl.text.trim();
if (oldPwd.isEmpty || newPwd.isEmpty || confirmPwd.isEmpty) {
setState(() => _error = '请填写所有字段');
setState(() => _error = l10n?.fillAllFields ?? '请填写所有字段');
return;
}
if (newPwd.length < 6) {
setState(() => _error = '新密码至少6位字符');
setState(() => _error = l10n?.newPwdMinLength ?? '新密码至少6位字符');
return;
}
if (newPwd != confirmPwd) {
setState(() => _error = '两次输入的新密码不一致');
setState(() => _error = l10n?.passwordMismatch ?? '两次输入的新密码不一致');
return;
}
@@ -60,7 +62,7 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
if (!mounted) return;
setState(() {
_submitting = false;
_error = '原密码错误';
_error = l10n?.oldPasswordError ?? '原密码错误';
});
return;
}
@@ -73,14 +75,15 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
_oldCtrl.clear();
_newCtrl.clear();
_confirmCtrl.clear();
ToastService.showSuccess(context, '密码已修改');
ToastService.showSuccess(context, l10n?.passwordChanged ?? '密码已修改');
} else {
ToastService.showError(context, '密码修改失败');
ToastService.showError(context, l10n?.passwordChangeFailed ?? '密码修改失败');
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ListView(
padding: EdgeInsets.zero,
children: [
@@ -95,7 +98,7 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'密码修改',
l10n?.password ?? '密码修改',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
@@ -105,20 +108,20 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
const Divider(),
_field(
controller: _oldCtrl,
label: '原密码',
hint: '请输入当前密码',
label: l10n?.oldPassword ?? '原密码',
hint: l10n?.enterCurrentPassword ?? '请输入当前密码',
),
const SizedBox(height: 16),
_field(
controller: _newCtrl,
label: '新密码',
hint: '至少6位字符',
label: l10n?.newPassword ?? '新密码',
hint: l10n?.passwordMinLength ?? '至少6位字符',
),
const SizedBox(height: 16),
_field(
controller: _confirmCtrl,
label: '确认新密码',
hint: '再次输入新密码',
label: l10n?.confirmNewPassword ?? '确认新密码',
hint: l10n?.enterNewPassword ?? '再次输入新密码',
),
if (_error != null) ...[
const SizedBox(height: 12),
@@ -140,7 +143,7 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
_confirmCtrl.clear();
setState(() => _error = null);
},
child: const Text('重置'),
child: Text(l10n?.reset ?? '重置'),
),
const SizedBox(width: 12),
ElevatedButton.icon(
@@ -155,7 +158,7 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
),
)
: const Icon(Icons.check, size: 18),
label: const Text('确认'),
label: Text(l10n?.confirm ?? '确认'),
),
],
),
@@ -166,7 +169,7 @@ class _PasswordPanelState extends ConsumerState<PasswordPanel> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'默认密码为 123456',
l10n?.defaultPassword ?? '默认密码为 123456',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
),

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:usb_serial/usb_serial.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../device/models/serial_config.dart';
import '../../device/providers/serial_provider.dart';
@@ -48,14 +49,14 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
} catch (e) {
if (!mounted) return;
setState(() => _loadingDevices = false);
_showSnack('扫描设备失败: $e', AppTheme.errorColor);
_showSnack('${AppLocalizations.of(context)?.scanFailed ?? '扫描设备失败'}: $e', AppTheme.errorColor);
}
}
Future<void> _connect() async {
final device = _selectedDevice;
if (device == null) {
_showSnack('请先选择串口设备', AppTheme.warningColor);
_showSnack(AppLocalizations.of(context)?.selectSerialFirst ?? '请先选择串口设备', AppTheme.warningColor);
return;
}
setState(() => _operating = true);
@@ -65,7 +66,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
if (!mounted) return;
setState(() => _operating = false);
_showSnack(
ok ? '连接成功' : '连接失败: ${service.lastError ?? "未知错误"}',
ok ? (AppLocalizations.of(context)?.connectSuccess ?? '连接成功') : ('${AppLocalizations.of(context)?.connectFailed ?? '连接失败'}: ${service.lastError ?? (AppLocalizations.of(context)?.unknownError ?? '未知错误')}'),
ok ? AppTheme.successColor : AppTheme.errorColor,
);
}
@@ -76,13 +77,13 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
await service.disconnect();
if (!mounted) return;
setState(() => _operating = false);
_showSnack('已断开串口', AppTheme.infoColor);
_showSnack(AppLocalizations.of(context)?.disconnected ?? '已断开串口', AppTheme.infoColor);
}
Future<void> _testConnection() async {
final service = ref.read(serialPortServiceProvider);
if (!service.isConnected) {
_showSnack('请先连接串口', AppTheme.warningColor);
_showSnack(AppLocalizations.of(context)?.connectFirst ?? '请先连接串口', AppTheme.warningColor);
return;
}
final msgService = ref.read(deviceMessageServiceProvider);
@@ -94,7 +95,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
));
if (!mounted) return;
_showSnack(
ok ? '已发送 device_info 查询' : '发送失败',
ok ? (AppLocalizations.of(context)?.sendTestFrame ?? '已发送测试查询') : (AppLocalizations.of(context)?.connectFailed ?? '发送失败'),
ok ? AppTheme.successColor : AppTheme.errorColor,
);
}
@@ -107,25 +108,26 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final config = ref.watch(serialConfigProvider);
final state = ref.watch(serialPortServiceProvider).state;
return ListView(
padding: const EdgeInsets.all(0),
children: [
_buildStatusCard(state),
_buildStatusCard(state, l10n),
const SizedBox(height: 16),
_buildDeviceCard(),
_buildDeviceCard(l10n),
const SizedBox(height: 16),
_buildParamCard(config),
_buildParamCard(config, l10n),
const SizedBox(height: 16),
_buildActionRow(),
_buildActionRow(l10n),
],
);
}
// -- 状态卡 ----------------------------------------------------------
Widget _buildStatusCard(SerialConnectionState state) {
Widget _buildStatusCard(SerialConnectionState state, AppLocalizations? l10n) {
final color = switch (state) {
SerialConnectionState.connected => AppTheme.successColor,
SerialConnectionState.connecting => AppTheme.warningColor,
@@ -133,10 +135,10 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
SerialConnectionState.disconnected => AppTheme.idleColor,
};
final text = switch (state) {
SerialConnectionState.connected => '已连接',
SerialConnectionState.connecting => '连接中...',
SerialConnectionState.error => '错误',
SerialConnectionState.disconnected => '未连接',
SerialConnectionState.connected => l10n?.serialConnected ?? '已连接',
SerialConnectionState.connecting => l10n?.serialConnecting ?? '连接中...',
SerialConnectionState.error => l10n?.serialError ?? '错误',
SerialConnectionState.disconnected => l10n?.serialDisconnected ?? '未连接',
};
return Container(
padding: const EdgeInsets.all(16),
@@ -153,7 +155,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 12),
Text('串口状态: $text',
Text('${l10n?.serialStatus ?? '串口状态'}: $text',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
@@ -166,13 +168,13 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
// -- 设备列表 --------------------------------------------------------
Widget _buildDeviceCard() {
Widget _buildDeviceCard(AppLocalizations? l10n) {
return _SectionCard(
title: '可用串口设备',
title: l10n?.availableSerialDevices ?? '可用串口设备',
trailing: TextButton.icon(
onPressed: _loadingDevices ? null : _refreshDevices,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('刷新'),
label: Text(l10n?.refresh ?? '刷新'),
),
child: _loadingDevices
? const Padding(
@@ -190,16 +192,17 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
}
Widget _buildEmptyDevice() {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 8),
child: Column(
children: [
Icon(Icons.usb_off, size: 40, color: AppTheme.idleColor),
const SizedBox(height: 8),
Text('未检测到 USB 串口设备',
Text(l10n?.noSerialDevice ?? '未检测到 USB 串口设备',
style: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 4),
Text('请确认下位机已上电并通过 USB 接入设备',
Text(l10n?.serialDeviceHint ?? '请确认下位机已上电并通过 USB 接入设备',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
],
),
@@ -255,45 +258,45 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
// -- 参数配置 --------------------------------------------------------
Widget _buildParamCard(SerialConfig config) {
Widget _buildParamCard(SerialConfig config, AppLocalizations? l10n) {
return _SectionCard(
title: '串口参数',
title: l10n?.serialParams ?? '串口参数',
child: Column(
children: [
_baudRateRow(config),
_baudRateRow(config, l10n),
_dropdownRow<int>(
label: '数据位',
label: l10n?.dataBits ?? '数据位',
value: config.dataBits,
options: const [5, 6, 7, 8],
display: (v) => '$v',
onChanged: (v) => _updateConfig((c) => c.copyWith(dataBits: v)),
),
_dropdownRow<int>(
label: '停止位',
label: l10n?.stopBits ?? '停止位',
value: config.stopBits,
options: const [1, 2],
display: (v) => '$v',
onChanged: (v) => _updateConfig((c) => c.copyWith(stopBits: v)),
),
_dropdownRow<SerialParity>(
label: '校验位',
label: l10n?.parity ?? '校验位',
value: config.parity,
options: SerialParity.values,
display: (v) => switch (v) {
SerialParity.none => '',
SerialParity.odd => '',
SerialParity.even => '',
SerialParity.mark => '标记',
SerialParity.space => '',
SerialParity.none => l10n?.parityNone ?? '',
SerialParity.odd => l10n?.parityOdd ?? '',
SerialParity.even => l10n?.parityEven ?? '',
SerialParity.mark => l10n?.parityMark ?? '标记',
SerialParity.space => l10n?.paritySpace ?? '',
},
onChanged: (v) => _updateConfig((c) => c.copyWith(parity: v)),
),
_dropdownRow<SerialFlowControl>(
label: '流控',
label: l10n?.flowControl ?? '流控',
value: config.flowControl,
options: SerialFlowControl.values,
display: (v) => switch (v) {
SerialFlowControl.none => '',
SerialFlowControl.none => l10n?.parityNone ?? '',
SerialFlowControl.rtsCts => 'RTS/CTS',
SerialFlowControl.xonXoff => 'XON/XOFF',
SerialFlowControl.dtrDsr => 'DTR/DSR',
@@ -304,7 +307,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
Align(
alignment: Alignment.centerRight,
child: Text(
'参数修改后自动保存',
l10n?.autoSaveParams ?? '参数修改后自动保存',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
),
@@ -313,7 +316,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
);
}
Widget _baudRateRow(SerialConfig config) {
Widget _baudRateRow(SerialConfig config, AppLocalizations? l10n) {
final ctrl = TextEditingController(text: config.baudRate.toString());
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -322,7 +325,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
SizedBox(
width: 80,
child:
Text('波特率', style: TextStyle(color: AppTheme.textPrimary))),
Text(l10n?.baudRate ?? '波特率', style: TextStyle(color: AppTheme.textPrimary))),
const SizedBox(width: 12),
SizedBox(
width: 140,
@@ -425,7 +428,7 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
// -- 操作按钮 --------------------------------------------------------
Widget _buildActionRow() {
Widget _buildActionRow(AppLocalizations? l10n) {
return Wrap(
spacing: 12,
runSpacing: 12,
@@ -433,19 +436,19 @@ class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
ElevatedButton.icon(
onPressed: _operating ? null : _connect,
icon: const Icon(Icons.link),
label: const Text('连接'),
label: Text(l10n?.connect ?? '连接'),
),
ElevatedButton.icon(
onPressed:
_operating ? null : _disconnect,
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.errorColor),
icon: const Icon(Icons.link_off),
label: const Text('断开'),
label: Text(l10n?.disconnect ?? '断开'),
),
OutlinedButton.icon(
onPressed: _operating ? null : _testConnection,
icon: const Icon(Icons.send),
label: const Text('发送测试帧'),
label: Text(l10n?.sendTestFrame ?? '发送测试帧'),
),
],
);

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/services/toast_service.dart';
import '../services/usb_detection_service.dart';
@@ -21,18 +22,20 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
Future<void> _detect() async {
if (_detecting) return;
setState(() => _detecting = true);
final l10n = AppLocalizations.of(context);
final connected = await UsbDetectionService.instance.detectUsb();
if (!mounted) return;
setState(() => _detecting = false);
if (connected) {
ToastService.showInfo(context, '正在检测U盘...');
ToastService.showInfo(context, l10n?.detectingUsb ?? '正在检测U盘...');
} else {
ToastService.showWarning(context, '未检测到U盘');
ToastService.showWarning(context, l10n?.usbNotDetected ?? '未检测到U盘');
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = ref.watch(_usbStateProvider);
final connected = state.maybeWhen(
data: (s) => s.isConnected,
@@ -67,7 +70,7 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
const SizedBox(width: 12),
Expanded(
child: Text(
connected ? 'U盘已连接' : '未检测到U盘',
connected ? (l10n?.usbConnected ?? 'U盘已连接') : (l10n?.usbNotDetected ?? '未检测到U盘'),
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
@@ -80,8 +83,8 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
const SizedBox(height: 12),
Text(
connected
? '挂载路径: ${path ?? "未知"}'
: '请插入U盘后点击"重新检测"',
? '${l10n?.mountPath ?? '挂载路径'}: ${path ?? "?"}'
: (l10n?.insertUsb ?? '请插入U盘后重试'),
style: TextStyle(
color: AppTheme.textSecondary, fontSize: 13),
),
@@ -98,13 +101,13 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 18),
label: const Text('重新检测'),
label: Text(l10n?.reDetect ?? '重新检测'),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: connected ? () => _import(path) : null,
icon: const Icon(Icons.download, size: 18),
label: const Text('导入程序'),
label: Text(l10n?.importProgram ?? '导入程序'),
),
],
),
@@ -123,7 +126,7 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'使用说明',
l10n?.usageInstructions ?? '使用说明',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 14,
@@ -131,9 +134,9 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
),
),
const SizedBox(height: 8),
_bullet('将程序文件 (.json) 放入 U盘根目录的 programs 文件夹'),
_bullet('插入 U盘后点击"重新检测"'),
_bullet('检测成功后点击"导入程序"加载程序列表'),
_bullet(l10n?.usbUsageStep1 ?? '将程序文件 (.json) 放入 U盘根目录的 programs 文件夹'),
_bullet(l10n?.usbUsageStep2 ?? '插入 U盘后点击"重新检测"'),
_bullet(l10n?.usbUsageStep3 ?? '检测成功后点击"导入程序"加载程序列表'),
],
),
),
@@ -142,11 +145,12 @@ class _UsbImportPanelState extends ConsumerState<UsbImportPanel> {
}
void _import(String? path) {
final l10n = AppLocalizations.of(context);
if (path == null) {
ToastService.showWarning(context, 'U盘路径无效');
ToastService.showWarning(context, l10n?.usbPathInvalid ?? 'U盘路径无效');
return;
}
ToastService.showInfo(context, '正在导入程序...');
ToastService.showInfo(context, l10n?.importingPrograms ?? '正在导入程序...');
// TODO: 接入 program_import_service 实现真正的导入流程
}

View File

@@ -8,6 +8,7 @@ import 'core/theme/app_theme.dart';
import 'core/localization/app_localizations.dart';
import 'core/localization/locale_provider.dart';
import 'core/database/database_service.dart';
import 'features/device/providers/serial_provider.dart';
/// 应用入口
void main() async {
@@ -22,7 +23,17 @@ void main() async {
final db = DatabaseService.instance;
await db.database;
await db.initTestData();
runApp(const ProviderScope(child: KuaishaiApp()));
// 使用 ProviderContainer 在 runApp 之前触发启动自动连接
final container = ProviderContainer();
container.read(autoSerialConnectProvider);
runApp(
UncontrolledProviderScope(
container: container,
child: const KuaishaiApp(),
),
);
}
/// 应用主体

View File

@@ -1,10 +1,8 @@
/// 常量定义
class Constants {
// 速度选项
static const List<String> speedOptions = ['低速', '中速', '高速'];
// 下针速度档位
static const List<int> needleSpeedLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// 速度档位
static const int minSpeed = 1;
static const int maxSpeed = 10;
// 孔位列表
static const List<String> positions = [

View File

@@ -7,8 +7,8 @@ class CommonDialog {
required BuildContext context,
required String title,
required String content,
String confirmText = '确认',
String cancelText = '取消',
String confirmText = 'Confirm',
String cancelText = 'Cancel',
bool isDestructive = false,
}) {
return showDialog<bool>(
@@ -38,7 +38,7 @@ class CommonDialog {
required BuildContext context,
required String title,
required String content,
String confirmText = '确认',
String confirmText = 'Confirm',
}) {
return showDialog(
context: context,
@@ -61,8 +61,8 @@ class CommonDialog {
required String title,
String? hintText,
String? initialValue,
String confirmText = '确认',
String cancelText = '取消',
String confirmText = 'Confirm',
String cancelText = 'Cancel',
}) {
final controller = TextEditingController(text: initialValue);

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-12

View File

@@ -0,0 +1,103 @@
## Context
项目已使用手写的 `AppLocalizations` 类(`lib/core/localization/app_localizations.dart`),通过 `_localizedValues` 嵌套 Map 存储中英文翻译,并由 `LocaleProvider` 控制当前语言。基础设施完备,但实际 UI 代码大量使用中文字面量,覆盖率不足。
i18n 审计结果:
- 53 个 `.dart` 文件包含中文字符
- 约 456 处硬编码字符串
- 现有 `AppLocalizations` 已定义约 100 个键,但许多 UI 文本(如 SnackBar、Dialog、表单标签、占位符、错误消息仍未通过翻译函数获取
## Goals / Non-Goals
**Goals:**
- 用户可见的所有 UI 文本均通过 `AppLocalizations` 获取
- 切换语言后所有页面即时刷新
- 翻译键命名统一、易于维护
- 保留现有手写翻译方案,避免引入新依赖(如 `intl_utils``slang`
**Non-Goals:**
- 不改造日志/调试输出中的中文(仅面向开发者)
- 不本地化数据库种子数据(属于业务数据,由用户编辑)
- 不引入 `.arb` 文件 + 代码生成方案
- 不新增除中英文之外的第三种语言(架构需支持,但本次不实现)
## Decisions
### 决策 1沿用手写 `AppLocalizations`,不迁移到 `flutter gen-l10n`
**选择**:保留现有 `Map<String, Map<String, String>>` 结构。
**理由**
- 项目已具备完整的代理(`Delegate`)和 `LocaleProvider` 设置,迁移成本高
- 翻译规模有限(预估最终 250 个左右键),手写可维护
- 避免引入 `build_runner` 代码生成步骤
**已考虑替代方案**
- `flutter_localizations + .arb` 文件:标准方案但需引入代码生成,对小项目过度
- `slang` 包:类型安全但需新增依赖和构建步骤
### 决策 2参数化文本统一使用方法形式
**选择**将带参数的翻译从「getter + 拼接」改为「方法返回完整字符串」。
示例:
```dart
// 旧(错误)
String get importSuccess => '成功导入';
String get programsImported => '个程序';
// 调用:'${l.importSuccess} $count ${l.programsImported}'
// 新(正确)
String importedPrograms(int count) =>
locale.languageCode == 'en'
? 'Successfully imported $count programs'
: '成功导入 $count 个程序';
```
**理由**:英文语序与中文不同,拼接会产生不自然的文本(如 "Successfully imported 5 programs" 才正确,而非 "Successfully imported 5 programs")。
### 决策 3分模块按 feature 推进
将替换工作按 feature 拆分,每个 feature 一个原子提交:
1. `core` 模块router、theme
2. `features/home`
3. `features/programs`
4. `features/program_detail`
5. `features/device`(仅 UI 层service 层错误消息走错误码)
6. `features/settings`
7. `shared/widgets`
每个 feature 完成后运行 `flutter analyze` + 手动切换语言验证。
### 决策 4Service 层错误使用错误码而非翻译
`SerialPortService``DeviceMessageService` 等返回 `Exception` 时使用稳定的英文错误码或自定义枚举,由 UI 层调用 `AppLocalizations` 的方法转换为本地化消息。
**理由**Service 层无法持有 `BuildContext`,强行注入 `Locale` 会污染领域层职责。
### 决策 5新增翻译键的命名约定
- 采用 `camelCase`
- 按模块前缀分组:`home.*``program.*``step.*``device.*``settings.*``common.*`
- 但为了兼容现有键,新增键先采用相同的平铺命名风格,待整体替换完成后视情况重构
## Risks / Trade-offs
- **[风险] 漏改字符串**456 处替换易遗漏 → **缓解**:完成后用 grep `[一-鿿]` 全量扫描 `lib/features/**/*.dart``lib/shared/**/*.dart`,确保 UI 层无中文字面量
- **[风险] 英文翻译质量参差不齐**:开发者非母语翻译 → **缓解**保持术语表统一device、program、step、position、volume 等),由审阅时统一校对
- **[风险] 现有 Dialog/Snackbar 使用了拼接字符串**:英文语序错乱 → **缓解**:通过参数化方法重构(决策 2
- **[风险] 修改面广,潜在 UI 回归**:可能误改逻辑代码 → **缓解**:严格只替换字符串字面量,每个 feature 完成后人工运行验证
- **[权衡] 不引入代码生成**:可读性 vs. 类型安全 → 接受手写翻译表的维护成本以换取零构建步骤
## Migration Plan
1. **准备**:扩充 `AppLocalizations` 翻译表,补齐所有需要的新键(中英文)
2. **逐模块替换**:按决策 3 顺序,每个 feature 一次提交
3. **回归验证**:每次提交后启动应用,切换中英文,确认无遗漏文本
4. **最终扫描**grep 全量检查 `lib/features/``lib/shared/``lib/core/router/``lib/core/theme/` 中是否仍有中文字面量(排除注释)
5. **回滚策略**:每个 feature 独立提交,可单独 revert
## Open Questions
- Service 层抛出的 `Exception` 是否需要定义统一的错误码枚举?(待实施时根据现有错误类型决定)
- 程序名称(用户在数据库中输入的中文)是否需要支持英文模式下显示英文别名?(**默认否**,用户数据原样展示)

View File

@@ -0,0 +1,26 @@
## Why
当前应用中大量中文字符串直接硬编码在 Dart 文件中(约 456 处,跨 53 个文件导致1无法切换为英文界面2新增语言时需要逐个文件查找修改3维护成本高且容易遗漏。项目虽已有 `AppLocalizations` 基础设施,但覆盖率不足,多数 UI 文本仍为字面量中文。
## What Changes
- 扫描 `lib/` 下所有硬编码的中文字符串,逐一替换为 `AppLocalizations.of(context)` 调用
- 补充所有缺失的翻译键到 `AppLocalizations`(含中英文值)
- 优化 `AppLocalizations` 类的调用方式,消除 `BuildContext` 强依赖场景(如 Snackbar、Dialog
- 确保运行时可动态切换语言,无需重启应用
- **不涉及**:数据库种子数据、日志输出中的中文(不影响 UI 国际化)
## Capabilities
### New Capabilities
- `i18n`: 完整覆盖应用所有 UI 文本的国际化能力。支持中文(默认)和英文两种语言,所有用户可见文本均通过 `AppLocalizations.of(context)` 获取。
### Modified Capabilities
- (无现有 spec 需要修改)
## Impact
- **代码修改**:约 50+ 个 Dart 文件中的 UI 部分,将硬编码字符串替换为翻译函数的调用
- **新增翻译键**:预估新增约 100-150 个翻译键到 `AppLocalizations`
- **无外部依赖变更**:沿用现有手写翻译系统,不引入 `intl``slang` 等第三方包
- **无破坏性变更**:所有替换为纯文本替换,不改变 UI 结构和逻辑

View File

@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: 完整的 UI 文本国际化覆盖
应用 SHALL 通过 `AppLocalizations` 提供所有面向用户的 UI 文本,禁止在 widget 构建逻辑中使用字面量中文字符串。
#### Scenario: 所有可见文本通过翻译函数获取
- **WHEN** 开发者在任何 widget 的 `build()` 方法、`AppBar` 标题、`SnackBar` 内容、`Dialog` 文本或按钮文案中显示文本
- **THEN** 文本必须来自 `AppLocalizations.of(context)` 的 getter 或方法调用,不得直接写中文字面量
#### Scenario: 静态分析检测硬编码中文
- **WHEN** 运行 `flutter analyze` 或代码评审检查 `lib/` 目录下任意 `.dart` 文件中 widget 渲染部分
- **THEN** 不应出现包含中文字符(`[一-鿿]`)的字面量字符串
### Requirement: 中英双语翻译完整性
`AppLocalizations` SHALL 为所有翻译键同时提供中文(`zh`)和英文(`en`)两种语言的取值,避免运行时回退到键名或硬编码默认值。
#### Scenario: 翻译键同时存在于两种语言
- **WHEN** 新增任意翻译键到 `_localizedValues['zh']`
- **THEN** `_localizedValues['en']` 必须包含同名键,且值为对应的英文翻译
#### Scenario: 切换语言后所有界面文本即时更新
- **WHEN** 用户在系统设置中将语言从中文切换到英文
- **THEN** 应用所有页面(首页、程序管理、程序详情、运行控制、系统设置、完成页)的 UI 文本应立即变为英文,无需重启应用
### Requirement: 非 BuildContext 场景的本地化支持
`AppLocalizations` SHALL 提供在没有 `BuildContext` 的场景(如 Provider/Service 层抛出错误消息、日志)中获取当前语言文本的方式,或明确将这些场景排除在国际化范围之外。
#### Scenario: Service 层错误消息的本地化
- **WHEN** 设备运行 Service 需要向 UI 反馈错误(如串口连接失败)
- **THEN** Service 层应返回稳定的错误码或英文键名,由 UI 层通过 `AppLocalizations` 转换为本地化文本展示给用户
#### Scenario: 调试日志保留原始语言
- **WHEN** 代码通过 `dart:developer``log()` 或类似 API 输出调试日志
- **THEN** 允许保留中文日志内容,无需国际化(仅面向开发者)
### Requirement: 单复数与参数化文本
`AppLocalizations` SHALL 支持包含动态参数(如数量、程序名称)的翻译,禁止通过字符串拼接构造可见文本。
#### Scenario: 带参数的翻译方法
- **WHEN** 显示形如「成功导入 5 个程序」的消息
- **THEN** `AppLocalizations` 应提供方法形式(如 `programsImportedCount(int n)`)返回完整本地化字符串,而不是要求调用方拼接 `importSuccess + n + programsImported`
#### Scenario: 替换现有拼接式调用
- **WHEN** 重构现有代码中通过 `'${l.importSuccess} $count ${l.programsImported}'` 形式拼接的字符串
- **THEN** 应替换为单个参数化方法调用,确保英文语序自然(如 `'Successfully imported $count programs'`

View File

@@ -0,0 +1,66 @@
## 1. 准备阶段:扩充翻译表
- [x] 1.1 审计每个 feature 模块,整理所有需要新增的翻译键(中文 + 英文)清单
- [x] 1.2 在 `lib/core/localization/app_localizations.dart` 中新增所有缺失的翻译键 getter中英文
- [x] 1.3 将拼接式调用(如 `importSuccess + count + programsImported`)改为参数化方法(如 `importedPrograms(int count)`
- [x] 1.4 运行 `flutter analyze` 确保 `AppLocalizations` 改动无报错
## 2. core 模块替换
- [x] 2.1 替换 `lib/core/router/app_router.dart` 中的硬编码中文(路由错误页等)
- [x] 2.2 替换 `lib/core/theme/app_theme.dart` 中的硬编码中文(若有)
- [x] 2.3 验证grep 该模块剩余中文字面量为零
## 3. shared 模块替换
- [x] 3.1 替换 `lib/shared/widgets/` 下所有通用组件的中文CommonButton、CommonCard、StatusIndicator 等)
- [x] 3.2 替换 `lib/shared/utils/constants.dart` 中面向 UI 暴露的常量(仅 UI 文本,业务常量保留)
- [x] 3.3 验证grep 该模块剩余中文字面量为零
## 4. features/home 模块替换
- [x] 4.1 替换 `lib/features/home/pages/home_page.dart`
- [x] 4.2 替换 `lib/features/home/pages/complete_page.dart`
- [x] 4.3 替换 `lib/features/home/widgets/status_bar.dart`
- [x] 4.4 替换 `lib/features/home/widgets/program_list.dart`
- [x] 4.5 替换 `lib/features/home/widgets/running_control_panel.dart`
- [x] 4.6 替换 `lib/features/home/widgets/run_status_monitor.dart`
- [x] 4.7 验证grep 该模块剩余中文字面量为零
## 5. features/programs 模块替换
- [x] 5.1 替换 `lib/features/programs/pages/programs_page.dart`
- [x] 5.2 替换 `lib/features/programs/providers/programs_provider.dart`(仅 UI 反馈消息)
- [x] 5.3 替换 `lib/features/programs/services/excel_import_service.dart`错误提示通过错误码返回UI 层翻译)
- [x] 5.4 验证grep 该模块剩余中文字面量为零models 中的字段名注释除外)
## 6. features/program_detail 模块替换
- [x] 6.1 替换 `lib/features/program_detail/` 下所有 page 与 widget 中的中文
- [x] 6.2 验证grep 该模块剩余中文字面量为零
## 7. features/device 模块替换(仅 UI 层)
- [x] 7.1 替换 `lib/features/device/` 下所有 page 与 widget 中的中文
- [x] 7.2 Service 层抛出的 Exception 改为携带错误码(保留中文日志)
- [x] 7.3 UI 层调用 Service 时捕获错误码并通过 `AppLocalizations` 转为本地化消息
- [x] 7.4 验证grep `lib/features/device/` 下 UI 部分剩余中文字面量为零
## 8. features/settings 模块替换
- [x] 8.1 替换 `lib/features/settings/` 下所有 page 与 widget 中的中文
- [x] 8.2 验证grep 该模块剩余中文字面量为零
## 9. 全量回归与验证
- [x] 9.1 全量扫描:`grep -rn "[一-鿿]" lib/ --include="*.dart"` 并人工审阅每条结果,确认仅为日志/注释/数据库种子数据
- [x] 9.2 运行 `flutter analyze`,确保零报错零警告
- [x] 9.3 运行 `flutter test`,确保现有测试全部通过
- [ ] 9.4 启动应用,在中文模式下访问首页、程序管理、程序详情、运行控制、设置、完成页所有路径
- [ ] 9.5 在设置中切换为英文,重复 9.4 的全部路径检查,确认所有 UI 文本变为英文且无遗漏
- [ ] 9.6 切回中文,确认应用正常恢复
## 10. 收尾
- [x] 10.1 更新 `CLAUDE.md`「当前实现状态」段落,记录 i18n 完整覆盖已完成
- [ ] 10.2 在 commit message 中按 feature 分组列出修改

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

View File

@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -89,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -169,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
excel:
dependency: "direct main"
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
fake_async:
dependency: transitive
description:
@@ -277,6 +309,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
hotreloader:
dependency: transitive
description:
@@ -293,6 +333,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation:
dependency: transitive
description:
@@ -365,6 +421,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -381,6 +445,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -405,6 +493,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -437,6 +533,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
@@ -650,8 +754,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
usb_serial:
dependency: "direct main"
description:
name: usb_serial
sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07
url: "https://pub.dev"
source: hosted
version: "0.5.2"
uuid:
dependency: transitive
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
@@ -706,6 +818,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
@@ -716,4 +836,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.38.0"
flutter: ">=3.38.4"

View File

@@ -31,9 +31,21 @@ dependencies:
# 文件选择器
file_picker: ^8.1.7
# 路径解析(保存导入/导出文件)
path_provider: ^2.1.5
# Excel 读写(生成导入模板 + 解析用户填好的 .xlsx
excel: ^4.0.2
# 国际化
intl: ^0.20.2
# USB 串口通信Android USB Host 模式下的 CH340/FTDI/CP210x/PL2303 等芯片)
usb_serial: ^0.5.0
# 串口消息 ID 生成UUID v4
uuid: ^4.5.1
dev_dependencies:
flutter_test:
sdk: flutter