Compare commits

...

2 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
26 changed files with 1038 additions and 130 deletions

View File

@@ -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设计必须支持此尺寸

View File

@@ -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',
},
};
}

View File

@@ -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();
}

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

@@ -103,7 +103,7 @@ class RunStateNotifier extends StateNotifier<RunState> {
if (steps.isEmpty) {
state = state.copyWith(
status: RunStatus.error,
errorMessage: '程序步骤为空',
errorMessage: 'PROGRAM_STEPS_EMPTY',
);
return;
}

View File

@@ -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),
],
),
],

View File

@@ -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

View File

@@ -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

View File

@@ -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,
),
);

View File

@@ -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(

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

@@ -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(

View File

@@ -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,
),
);

View File

@@ -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,
),
);

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

@@ -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