Compare commits
2 Commits
5d65744618
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57badf213a | ||
|
|
3d849bd468 |
@@ -131,7 +131,7 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
|
||||
- 程序详情:步骤列表、步骤参数编辑
|
||||
- 运行控制:启动/暂停/停止、进度监控(使用 MockRunner 模拟)
|
||||
- 系统设置:基础页面框架
|
||||
- 国际化:中/英文翻译配置
|
||||
- 国际化:中/英文翻译配置,UI 文本已全部通过 `AppLocalizations` 覆盖(含参数化方法)
|
||||
|
||||
待完善:设备通信对接、实际硬件控制、U盘导入功能。
|
||||
|
||||
@@ -163,4 +163,4 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
|
||||
## 其他说明
|
||||
|
||||
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设计必须支持此尺寸
|
||||
@@ -130,6 +130,141 @@ 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': '污水毒品前处理一体机',
|
||||
@@ -233,6 +368,87 @@ 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',
|
||||
@@ -336,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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
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) {
|
||||
// 支持 ?tab=N 查询参数,用于从其他页面跳回首页并切换到指定 tab
|
||||
final tabParam = state.uri.queryParameters['tab'];
|
||||
final initialTab = int.tryParse(tabParam ?? '') ?? 0;
|
||||
return HomePage(initialTab: initialTab);
|
||||
@@ -48,3 +79,8 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/// 桥接 Riverpod 到 GoRouter 的 refreshListenable
|
||||
class AuthRefreshStream extends ChangeNotifier {
|
||||
void notify() => notifyListeners();
|
||||
}
|
||||
|
||||
191
lib/features/auth/pages/login_page.dart
Normal file
191
lib/features/auth/pages/login_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/features/auth/providers/auth_provider.dart
Normal file
98
lib/features/auth/providers/auth_provider.dart
Normal 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();
|
||||
});
|
||||
@@ -103,7 +103,7 @@ class RunStateNotifier extends StateNotifier<RunState> {
|
||||
if (steps.isEmpty) {
|
||||
state = state.copyWith(
|
||||
status: RunStatus.error,
|
||||
errorMessage: '程序步骤为空',
|
||||
errorMessage: 'PROGRAM_STEPS_EMPTY',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class CompletePage extends ConsumerWidget {
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
l10n?.runComplete ?? '程序运行完成',
|
||||
l10n?.programRunComplete ?? '程序运行完成',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 24,
|
||||
@@ -121,7 +121,7 @@ class CompletePage extends ConsumerWidget {
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 操作示意图
|
||||
_buildOperationGuide(),
|
||||
_buildOperationGuide(context),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -167,7 +167,8 @@ class CompletePage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 操作指引示意图
|
||||
Widget _buildOperationGuide() {
|
||||
Widget _buildOperationGuide(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -178,7 +179,7 @@ class CompletePage extends ConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'操作步骤',
|
||||
l10n?.operationSteps ?? '操作步骤',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 14,
|
||||
@@ -189,10 +190,10 @@ class CompletePage extends ConsumerWidget {
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -102,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(
|
||||
@@ -168,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
|
||||
|
||||
@@ -263,13 +263,14 @@ class _SerialConnectionIndicator extends StatelessWidget {
|
||||
|
||||
@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
|
||||
|
||||
@@ -107,7 +107,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('步骤已更新'),
|
||||
content: Text(l10n?.stepUpdated ?? '步骤已更新'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
@@ -125,7 +125,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'请选择或添加步骤',
|
||||
l10n?.selectStepFirst ?? '请选择或添加步骤',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 16,
|
||||
@@ -164,7 +164,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: l10n?.backToHome ?? '返回',
|
||||
tooltip: l10n?.back ?? '返回',
|
||||
onPressed: () => context.go('/?tab=1'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -184,7 +184,7 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已保存'),
|
||||
content: Text(l10n?.saved ?? '已保存'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -72,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,
|
||||
@@ -86,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;
|
||||
},
|
||||
@@ -180,7 +180,7 @@ class _StepFormState extends State<StepForm> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n?.speed ?? '速度'}: $_speed 档',
|
||||
'${l10n?.speed ?? '速度'}: ${l10n?.speedLevelValue(_speed) ?? '$_speed 档'}',
|
||||
style: TextStyle(color: AppTheme.textPrimary),
|
||||
),
|
||||
Slider(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -259,7 +259,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
'状态',
|
||||
l10n?.status ?? '状态',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
@@ -358,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
|
||||
@@ -413,6 +413,7 @@ 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(
|
||||
@@ -428,7 +429,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
final file = result.files.first;
|
||||
if (file.path == null) {
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '无法读取文件');
|
||||
ToastService.showError(context, l10n?.cannotReadFile ?? '无法读取文件');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,25 +442,26 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (importedCount > 0) {
|
||||
ToastService.showSuccess(context, '成功处理 $importedCount 个程序');
|
||||
ToastService.showSuccess(context, l10n?.processedPrograms(importedCount) ?? '成功处理 $importedCount 个程序');
|
||||
} else {
|
||||
ToastService.showWarning(context, 'Excel 中无有效程序数据');
|
||||
ToastService.showWarning(context, l10n?.noValidProgramData ?? 'Excel 中无有效程序数据');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '导入失败: ${e.toString()}');
|
||||
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, '模板已保存: $path');
|
||||
ToastService.showSuccess(context, '${l10n?.templateSaved ?? '模板已保存'}: $path');
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '生成模板失败: ${e.toString()}');
|
||||
ToastService.showError(context, '${l10n?.generateTemplateFailed ?? '生成模板失败'}: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,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(
|
||||
|
||||
@@ -71,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;
|
||||
},
|
||||
@@ -90,14 +90,14 @@ 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;
|
||||
},
|
||||
@@ -140,7 +140,7 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'状态',
|
||||
l10n?.status ?? '状态',
|
||||
style: TextStyle(color: AppTheme.textPrimary),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -154,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,
|
||||
),
|
||||
@@ -222,7 +222,7 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('保存失败,请检查编号是否重复'),
|
||||
content: Text(l10n?.saveFailed ?? '保存失败,请检查编号是否重复'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -116,11 +116,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
}
|
||||
|
||||
Widget _buildUpgradeContent() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
Text(
|
||||
'软件升级',
|
||||
l10n?.upgrade ?? '软件升级',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 18,
|
||||
@@ -139,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),
|
||||
),
|
||||
],
|
||||
@@ -147,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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 ?? '发送测试帧'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 实现真正的导入流程
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-12
|
||||
103
openspec/changes/fix-hardcoded-chinese-i18n/design.md
Normal file
103
openspec/changes/fix-hardcoded-chinese-i18n/design.md
Normal 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` + 手动切换语言验证。
|
||||
|
||||
### 决策 4:Service 层错误使用错误码而非翻译
|
||||
|
||||
`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` 是否需要定义统一的错误码枚举?(待实施时根据现有错误类型决定)
|
||||
- 程序名称(用户在数据库中输入的中文)是否需要支持英文模式下显示英文别名?(**默认否**,用户数据原样展示)
|
||||
26
openspec/changes/fix-hardcoded-chinese-i18n/proposal.md
Normal file
26
openspec/changes/fix-hardcoded-chinese-i18n/proposal.md
Normal 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 结构和逻辑
|
||||
@@ -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'`)
|
||||
66
openspec/changes/fix-hardcoded-chinese-i18n/tasks.md
Normal file
66
openspec/changes/fix-hardcoded-chinese-i18n/tasks.md
Normal 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
20
openspec/config.yaml
Normal 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
|
||||
Reference in New Issue
Block a user