ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

161
lib/utils/app_update.dart Normal file
View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/api_client.dart';
import '../services/update_service.dart';
import '../utils/i10n.dart';
/// 全局「已弹过更新提示」标记,避免会话内重复弹窗。
bool _promptedThisSession = false;
/// 全局当前 [PackageInfo] 缓存(首次读取后复用)。
PackageInfo? _cachedPackageInfo;
/// 取得当前 App 包信息(带缓存)。
Future<PackageInfo> get currentPackageInfo async =>
_cachedPackageInfo ??= await PackageInfo.fromPlatform();
/// 检测更新并在有新版时弹窗提示。
///
/// - [silentIfNoUpdate]=true(如启动时):无更新或检测失败均静默;
/// - [silentIfNoUpdate]=false(如手动「检查更新」):无更新/失败给出明确反馈。
/// - [orgId] 当前登录用户所属组织 ID;传空时与旧行为一致。
Future<void> checkAndPromptUpdate(
BuildContext context, {
bool silentIfNoUpdate = true,
String? orgId,
}) async {
final t = AppLocalizations.of(context);
try {
final pkg = await currentPackageInfo;
final service = UpdateService(packageName: pkg.packageName);
final currentCode = int.tryParse(pkg.buildNumber) ?? 0;
final result = await service.checkForUpdate(currentCode, orgId: orgId);
if (!context.mounted) return;
if (result.hasUpdate && result.latest != null) {
if (!_promptedThisSession) {
_promptedThisSession = true;
showUpdateDialog(context, result, pkg);
}
return;
}
if (!silentIfNoUpdate && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.updateNoNew)),
);
}
} catch (e) {
// 任何异常均被捕获,不抛给调用方;仅非静默模式下给用户一句友好提示。
if (!silentIfNoUpdate && context.mounted) {
final detail = formatApiError(e, fallback: '网络异常,请稍后重试');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('检测更新失败:$detail')),
);
}
}
}
/// 版本更新提示对话框:展示当前 / 最新版本,并提供「立即更新」跳转下载。
void showUpdateDialog(
BuildContext context,
UpdateCheckResult result,
PackageInfo pkg,
) {
final t = AppLocalizations.of(context);
final latest = result.latest!;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Row(
children: [
const Icon(Icons.system_update_alt, color: Colors.green),
const SizedBox(width: 8),
Text(t.updateAvailable),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(label: t.currentVersion, value: '${pkg.version} (${pkg.buildNumber})'),
const SizedBox(height: 6),
_InfoRow(
label: t.latestVersion,
value: '${latest.versionName} (${latest.versionCode})',
),
if (latest.apkUrl.isNotEmpty) ...[
const SizedBox(height: 6),
_InfoRow(label: t.downloadUrl, value: latest.apkUrl),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.updateLater),
),
FilledButton(
onPressed: () async {
Navigator.pop(ctx);
final url = latest.apkUrl;
if (url.isEmpty) return;
final uri = Uri.parse(url);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.updateOpenFailed)),
);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.updateOpenFailed)),
);
}
}
},
child: Text(t.updateNow),
),
],
),
);
}
/// 对话框内的「标签:值」单行。
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(
value,
style: theme.textTheme.bodyMedium,
),
),
],
);
}
}

30
lib/utils/constants.dart Normal file
View File

@@ -0,0 +1,30 @@
/// 全局常量定义。
class AppConstants {
AppConstants._();
/// 应用名称。
static const String appName = '云定制主页运营助手';
/// 是否使用 mock 数据。
///
/// - `false`(默认):对接真实「云定制后台」(PHP/CodeIgniter);
/// - `true`:由 [MockInterceptor] 本地返回示例数据,供无后端时演示。
static const bool useMock = false;
/// 接口统一前缀。
///
/// 后端 origin 与环境相关配置已迁移至 `lib/config/*`
/// ([AppEnvironment] / [AppConfig] / [TestConfig] / [StagingConfig]),
/// 默认锁定测试环境,禁止任何生产切换。
static const String apiPrefix = '/api';
/// mock 登录账号(仅 useMock=true 时演示用)。
static const String mockAccount = 'admin';
static const String mockPassword = '123456';
/// 请求超时时间。
static const Duration apiTimeout = Duration(seconds: 10);
/// 设备列表分页默认每页条数。
static const int deviceListPageSize = 100;
}

View File

@@ -0,0 +1,134 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 本地凭据持久化(基于 SharedPreferences,跨平台可靠持久化)。
///
/// 存储内容:
/// - 账号 / 密码(仅当用户勾选「记住密码」时保存)
/// - token / refreshToken / 用户信息(登录成功后保存,用于免登录恢复)
///
/// 历史坑:早期版本使用 dart:io 文件存储 + `Directory.current`,但 Android 上
/// `Directory.current` 指向不可写路径,会静默退化为「仅内存」存储,导致 App 重启后
/// 凭据与登录态全部丢失(表现即「记住账号密码不生效 / 每次都要重新登录」)。
/// 现统一改用 SharedPreferences(应用私有、沙箱隔离、必定可写、跨重启持久)。
class CredentialStorage {
CredentialStorage._();
/// 单例。
static final CredentialStorage instance = CredentialStorage._();
/// SharedPreferences 实例;[init] 完成后非 null(除非极端初始化失败)。
SharedPreferences? _prefs;
/// 是否已尝试初始化(无论成功失败都置 true,避免重复 await 同一 Future)。
bool _initialized = false;
/// 异步预初始化:必须在 runApp 前 await,使下方同步 getter 可读到持久化数据。
static Future<void> init() async {
await instance._init();
}
Future<void> _init() async {
if (_initialized) return;
try {
_prefs = await SharedPreferences.getInstance();
} catch (_) {
// 极端情况下 SharedPreferences 不可用:退化为内存态,不影响启动与登录。
_prefs = null;
} finally {
_initialized = true;
}
}
// ── 账号密码 ──────────────────────────────────────────
/// 是否记住密码。
bool get rememberPassword => _prefs?.getBool(_kRemember) ?? false;
/// 保存的账号(rememberPassword=true 时有值)。
String? get savedAccount => _prefs?.getString(_kAccount);
/// 保存的密码(rememberPassword=true 时有值)。
String? get savedPassword => _prefs?.getString(_kPassword);
/// 保存账号密码(用户勾选「记住密码」并登录成功后调用)。
Future<void> saveCredentials({
required String account,
required String password,
}) async {
final prefs = _prefs;
if (prefs == null) return;
await prefs.setString(_kAccount, account);
await prefs.setString(_kPassword, password);
await prefs.setBool(_kRemember, true);
}
/// 清除保存的账号密码(用户未勾选「记住密码」时调用)。
Future<void> clearCredentials() async {
final prefs = _prefs;
if (prefs == null) return;
await prefs.remove(_kAccount);
await prefs.remove(_kPassword);
await prefs.setBool(_kRemember, false);
}
// ── Token & 用户信息(用于免登录恢复) ──────────────────
/// 保存的 token。
String? get token => _prefs?.getString(_kToken);
/// 保存的 refreshToken。
String? get refreshToken => _prefs?.getString(_kRefreshToken);
/// 保存的用户信息 JSON 字符串。
String? get userJson => _prefs?.getString(_kUserJson);
/// 保存登录态(token + refreshToken + 用户信息)。
Future<void> saveSession({
required String token,
String? refreshToken,
String? userJson,
}) async {
final prefs = _prefs;
if (prefs == null) return;
await prefs.setString(_kToken, token);
if (refreshToken != null && refreshToken.isNotEmpty) {
await prefs.setString(_kRefreshToken, refreshToken);
}
if (userJson != null && userJson.isNotEmpty) {
await prefs.setString(_kUserJson, userJson);
}
}
/// 清除登录态(登出或会话失效时调用)。
Future<void> clearSession() async {
final prefs = _prefs;
if (prefs == null) return;
await prefs.remove(_kToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUserJson);
}
/// 是否有已保存的登录态。
bool get hasSession => (token ?? '').isNotEmpty;
/// 从 JSON 恢复用户信息 Map。
Map<String, dynamic>? get savedUserMap {
final json = userJson;
if (json == null || json.isEmpty) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
// key 常量(加前缀避免与 StoreLocalCache 等业务 key 冲突)。
static const String _kRemember = 'cred_remember';
static const String _kAccount = 'cred_account';
static const String _kPassword = 'cred_password';
static const String _kToken = 'cred_token';
static const String _kRefreshToken = 'cred_refreshToken';
static const String _kUserJson = 'cred_userJson';
}

59
lib/utils/formatters.dart Normal file
View File

@@ -0,0 +1,59 @@
import 'package:intl/intl.dart';
/// 通用格式化工具。
class Formatters {
Formatters._();
static final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
static final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
/// 格式化日期时间。
static String formatDateTime(DateTime value) {
return _dateTimeFormat.format(value);
}
/// 仅格式化日期(YYYY-MM-DD),用于门店到期时间等只需日期的场景。
static String formatDate(DateTime value) {
return _dateFormat.format(value);
}
/// 将百分比值格式化为带一位小数的字符串,如 `72.5%`。
static String formatPercent(double ratio) {
return '${(ratio * 100).toStringAsFixed(1)}%';
}
/// 将字节数格式化为可读单位(KB/MB/GB/TB)。
static String formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
}
const units = ['KB', 'MB', 'GB', 'TB'];
double value = bytes.toDouble();
int unitIndex = -1;
do {
value /= 1024;
unitIndex++;
} while (value >= 1024 && unitIndex < units.length - 1);
return '${value.toStringAsFixed(1)} ${units[unitIndex]}';
}
/// 将秒数格式化为运行时长字符串,如 `3天 5小时 12分`。
static String formatUptime(int seconds) {
final duration = Duration(seconds: seconds);
final days = duration.inDays;
final hours = duration.inHours % 24;
final minutes = duration.inMinutes % 60;
final parts = <String>[];
if (days > 0) {
parts.add('$days天');
}
if (hours > 0) {
parts.add('$hours小时');
}
if (minutes > 0 || parts.isEmpty) {
parts.add('$minutes分');
}
return parts.join(' ');
}
}

666
lib/utils/i10n.dart Normal file
View File

@@ -0,0 +1,666 @@
import 'package:flutter/widgets.dart';
import '../providers/locale_provider.dart';
/// 轻量级国际化助手。
///
/// 覆盖底部导航、首页、设备、门店、管理、设置、激活码、用户、公司介绍等
/// 高频界面文案。后续业务模块继续在此补充。
///
/// 用法:
/// ```dart
/// final t = AppLocalizations.of(context);
/// Text(t.home);
/// ```
class AppLocalizations {
AppLocalizations(this._locale);
final AppLocale _locale;
static AppLocalizations of(BuildContext context) {
final locale = Localizations.of<AppLocale>(context, AppLocale);
return AppLocalizations(locale ?? AppLocale.zh);
}
String _pick(String zh, String en) => _locale == AppLocale.en ? en : zh;
/// 当前是否为英文模式。
bool get isEnglish => _locale == AppLocale.en;
// ============ 底部导航 ============
String get home => _pick('首页', 'Home');
String get devices => _pick('设备', 'Devices');
String get stores => _pick('门店', 'Stores');
String get admin => _pick('管理', 'Admin');
String get profile => _pick('我的', 'Profile');
// ============ 设置 ============
String get orientationMode => _pick('显示方向', 'Display Orientation');
String get orientationSystem => _pick('跟随系统', 'System');
String get orientationPortrait => _pick('竖屏', 'Portrait');
String get orientationLandscape => _pick('横屏', 'Landscape');
// ============ 首页 ============
/// AppBar / 顶部栏大标题。
String get appTitle => _pick('云定制主页运营平台', '云定制主页运营平台');
/// 首页品牌横幅 / 登录页主标题。
String get appHeroTitle => _pick('云定制主页运营助手', '云定制主页运营助手');
/// 登录页副标题。
String get appSubtitle => _pick('Mobile Management', 'Mobile Management');
String get totalDevices => _pick('设备总数', 'Total Devices');
String get totalStores => _pick('门店总数', 'Total Stores');
String get onlineDevices => _pick('在线设备', 'Online Devices');
String get offlineDevices => _pick('离线设备', 'Offline Devices');
String get quickActions => _pick('快捷操作', 'Quick Actions');
String get systemManagement => _pick('系统管理', 'System Management');
String get mediaManagement => _pick('媒资管理', 'Media Management');
// ============ 通用 ============
String get loading => _pick('加载中…', 'Loading…');
String get retry => _pick('重试', 'Retry');
String get save => _pick('保存', 'Save');
String get cancel => _pick('取消', 'Cancel');
String get confirm => _pick('确认', 'Confirm');
String get reset => _pick('重置', 'Reset');
String get edit => _pick('编辑', 'Edit');
String get delete => _pick('删除', 'Delete');
String get add => _pick('新增', 'Add');
String get search => _pick('搜索', 'Search');
String get back => _pick('返回', 'Back');
String get yes => _pick('是', 'Yes');
String get no => _pick('否', 'No');
String get unknown => _pick('未知', 'Unknown');
String get none => _pick('无', 'None');
String get notGrouped => _pick('未分组', 'Not Grouped');
String get all => _pick('全部', 'All');
String get noData => _pick('暂无数据', 'No data');
String get noMoreData => _pick('没有更多数据', 'No more data');
String get saved => _pick('保存成功', 'Saved');
String get saveFailed => _pick('保存失败', 'Save failed');
String get deleted => _pick('删除成功', 'Deleted');
String get deleteFailed => _pick('删除失败', 'Delete failed');
String get copied => _pick('已复制', 'Copied');
String get pleaseSelect => _pick('请选择', 'Please select');
// ============ 设置页 ============
String get settings => _pick('设置', 'Settings');
String get themeMode => _pick('主题模式', 'Theme');
String get language => _pick('语言', 'Language');
String get changePassword => _pick('修改密码', 'Change Password');
String get about => _pick('关于', 'About');
String get logout => _pick('退出登录', 'Log Out');
String get account => _pick('账号', 'Account');
String get themeDark => _pick('深色', 'Dark');
String get themeLight => _pick('浅色', 'Light');
String get themeSystem => _pick('跟随系统', 'System');
String get passwordDialogTitle => _pick('修改登录密码', 'Change Login Password');
String get oldPassword => _pick('原密码', 'Old Password');
String get newPassword => _pick('新密码', 'New Password');
String get confirmNewPassword => _pick('确认新密码', 'Confirm New Password');
String get pleaseFillPassword =>
_pick('请填写原密码和新密码', 'Please fill in old and new passwords');
String get passwordsDoNotMatch =>
_pick('两次输入的新密码不一致', 'New passwords do not match');
String get passwordChangeSuccess =>
_pick('密码修改成功,请重新登录', 'Password changed, please log in again');
String passwordChangeFailed(String error) =>
_pick('修改失败:$error', 'Change failed: $error');
// ============ 管理页菜单 ============
String get deviceManagement => _pick('设备管理', 'Device Management');
String get activationCode => _pick('激活码', 'Activation Code');
String get deviceLimit => _pick('设备上限', 'Device Limit');
String get units => _pick('台', 'Units');
String get storeConfig => _pick('门店配置', 'Store Config');
String get userManagement => _pick('用户管理', 'User Management');
String get companyIntro => _pick('公司介绍', 'Company Intro');
// ============ 设备管理 ============
String get deviceList => _pick('设备列表', 'Device List');
String get deviceDetails => _pick('设备详情', 'Device Details');
String get searchDeviceHint => _pick('搜索设备MAC / 房号', 'Search MAC / room number');
String get searchModelHint => _pick('型号', 'Model');
String get scanQueryDevice => _pick('扫码查询设备', 'Scan to Query');
String get scanActivateDevice => _pick('扫码激活新设备', 'Scan to Activate');
String get scanBindDevice => _pick('扫码绑定设备', 'Scan to Bind');
String get scanTitleActivate => _pick('扫码激活新设备', 'Scan to Activate Device');
String get scanTitleBind => _pick('扫码绑定设备', 'Scan to Bind Device');
String get scanTitleQuery => _pick('扫码查询设备', 'Scan to Query Device');
String get scanHintActivate => _pick('将新设备二维码放入框内以激活',
'Place the new device QR code in the frame to activate');
String get scanHintBind => _pick('将二维码放入框内以绑定设备',
'Place the QR code in the frame to bind the device');
String get scanHintQuery => _pick('将设备二维码放入框内以查询',
'Place the device QR code in the frame to query');
String get cameraPermissionDenied => _pick('未获得相机权限', 'Camera permission denied');
String get cameraPermissionDeniedHint =>
_pick('请在系统设置中开启后重试',
'Please enable camera permission in system settings and try again');
String get cameraInitializing => _pick('正在初始化相机…', 'Initializing camera…');
String get cameraUnavailable => _pick('相机不可用', 'Camera unavailable');
String cameraOpenFailed(String code) =>
_pick('相机打开失败:$code', 'Failed to open camera: $code');
String get scanConfirmActivateTitle => _pick('确认激活设备', 'Confirm Device Activation');
String get scanConfirmBindTitle => _pick('确认绑定', 'Confirm Binding');
String scanConfirmContent(String storeName, String groupId, String payload) =>
_pick(
'将设备绑定到门店「$storeName」'
'${groupId.isNotEmpty ? ' / 分组 $groupId' : ''}?\n\n'
'二维码内容:$payload',
'Bind device to store "$storeName"'
'${groupId.isNotEmpty ? ' / Group $groupId' : ''}?\n\n'
'QR code content: $payload',
);
String get scanActivate => _pick('激活', 'Activate');
String get scanBind => _pick('绑定', 'Bind');
String get scanDeviceNotFound => _pick('未找到设备,请确认二维码内容',
'Device not found, please check the QR code content');
String get scanPayloadUnrecognized => _pick(
'无法识别该二维码,请扫描设备二维码或确认二维码内容',
'Unrecognized QR code. Please scan a device QR code');
String scanDeviceNotReady(String identifier) => _pick(
'未找到对应设备($identifier)。设备可能尚未联网或同步,请确认设备已开机联网、已激活并归属当前账号后重试',
'No matching device found ($identifier). The device may not be online or synced yet. Please confirm it is powered on, connected, activated and assigned to this account, then retry');
String scanQueryFailed(String error) =>
_pick('查询失败:$error', 'Query failed: $error');
String scanLoadStoresFailed(String error) =>
_pick('加载门店失败:$error', 'Failed to load stores: $error');
String get scanNoStores => _pick('暂无可选门店,请先在门店管理中创建',
'No stores available. Please create one in Store Management first');
String get scanSelectStoreTitle => _pick('选择目标门店', 'Select Target Store');
String get scanActivateSuccess => _pick('设备已激活', 'Device activated');
String get scanBindSuccess => _pick('设备已绑定', 'Device bound');
String get scanPayloadNoMac =>
_pick('未能从二维码中识别到 MAC 地址,请检查二维码内容',
'Could not find MAC address in QR code');
String scanLoadGroupsFailed(String error) =>
_pick('加载分组失败:$error', 'Failed to load groups: $error');
String scanNoGroups(String storeName) =>
_pick('门店「$storeName」暂无设备分组,请先在设备分组中创建',
'Store "$storeName" has no device groups. Please create one first');
String get scanSelectGroupTitle => _pick('选择目标分组', 'Select Target Group');
String scanBindFailed(String error) =>
_pick('绑定失败:$error', 'Binding failed: $error');
// ============ 扫码绑定表单(页面内表单) ============
String get scanGroupLabel => _pick('门店分组', 'Store Group');
String get scanStoreRequired => _pick('请先选择门店', 'Please select a store first');
String get scanGroupRequired => _pick('请选择门店分组', 'Please select a store group');
String get scanRoomRequired => _pick('请输入房号', 'Please enter room number');
String get scanRoomInvalid => _pick(
'房号格式不正确(仅限中英文、数字、连字符、斜杠,长度 1-20)',
'Invalid room number (letters/digits/hyphen/slash only, 1-20 chars)');
// 房号改为选填:标签与提示都标明「选填」,避免用户误以为必填。
String get roomNoOptional => _pick('房号(选填)', 'Room Number (optional)');
String get scanRoomHint => _pick('选填,如 801、A101、3楼', 'Optional, e.g. 801, A101, 3rd floor');
String get scanRescan => _pick('重新扫码', 'Scan Again');
String get scanFormInvalid =>
_pick('请完善门店与门店分组后再提交', 'Please complete store and store group');
String get scanFormDeviceTitle => _pick('已识别设备', 'Recognized Device');
String get sendCommand => _pick('下发指令', 'Send Command');
String get editGroup => _pick('修改分组', 'Edit Group');
String get macAddress => _pick('MAC 地址', 'MAC Address');
String get ipAddress => _pick('IP 地址', 'IP Address');
String get belongStore => _pick('所属门店', 'Store');
String get belongGroup => _pick('所属分组', 'Group');
String get roomNo => _pick('房号', 'Room No.');
String deviceModel(String model) => _pick('型号:$model', 'Model: $model');
String deviceSubtitleStore(String name) => _pick('门店:$name', 'Store: $name');
String deviceSubtitleMac(String mac) => _pick('MAC:$mac', 'MAC: $mac');
String deviceSubtitleRoom(String room) => _pick('房号:$room', 'Room: $room');
String get deviceSubtitleNotSet => _pick('未设置', 'Not set');
String get location => _pick('位置', 'Location');
String get operatingSystem => _pick('操作系统', 'OS');
String get uptime => _pick('运行时长', 'Uptime');
// ============ 设备详情区块 ============
String get basicInfo => _pick('基本信息', 'Basic Info');
String get configParams => _pick('配置参数', 'Config Params');
String get networkInfo => _pick('网络信息', 'Network Info');
String get runtimeData => _pick('运行时数据', 'Runtime Data');
String get deviceName => _pick('设备名称', 'Device Name');
String get deviceId => _pick('设备 ID', 'Device ID');
String get modelLabel => _pick('型号', 'Model');
String get deviceSnLabel => _pick('设备序列号', 'Device SN');
String get deviceUidLabel => _pick('设备 UID', 'Device UID');
String get lastActiveTime => _pick('最后活跃时间', 'Last Active Time');
String get minutesAgo => _pick('分钟前', 'min ago');
String get cpuUsage => _pick('CPU 使用率', 'CPU Usage');
String get memoryUsage => _pick('内存使用率', 'Memory Usage');
String get diskUsage => _pick('磁盘使用率', 'Disk Usage');
String get networkRxTx => _pick('网络(收/发)', 'Network (Rx/Tx)');
String get showRuntimeMetrics => _pick('显示运行指标', 'Show Runtime Metrics');
String get showRuntimeMetricsHint =>
_pick('开启后在设备详情页展示 CPU、内存、磁盘、网络指标',
'Show CPU, memory, disk and network metrics on device detail');
String deviceListLoadFailed(String error) =>
_pick('设备列表加载失败:$error', 'Failed to load device list: $error');
String get noMatchedDevices => _pick('没有匹配的设备', 'No matched devices');
String get allStores => _pick('全部门店', 'All Stores');
String get selectStore => _pick('选择门店', 'Select Store');
String get groupId => _pick('所属分组 ID', 'Group ID');
String get groupIdHint => _pick('填写设备分组标识,如 g-core',
'Enter device group identifier, e.g. g-core');
String get saveSuccess => _pick('已保存', 'Saved successfully');
String get groupListLoading => _pick('加载分组列表…', 'Loading groups…');
// ============ 设备状态 ============
String get statusOnline => _pick('在线', 'Online');
String get statusOffline => _pick('离线', 'Offline');
String get statusWarning => _pick('告警', 'Warning');
String get statusNormal => _pick('正常', 'Normal');
String get statusDisabled => _pick('禁用', 'Disabled');
String deviceStatusLabel(DeviceStatusKey status) {
switch (status) {
case DeviceStatusKey.online:
return statusOnline;
case DeviceStatusKey.offline:
return statusOffline;
case DeviceStatusKey.warning:
return statusWarning;
}
}
// ============ 指令下发 ============
String get sendCommandTitle => _pick('下发指令', 'Send Command');
String get sendOrgCommandTitle => _pick('下发门店指令', 'Send Store Command');
String get commandType => _pick('指令类型', 'Command Type');
String get commandConfirmTitle => _pick('确认下发', 'Confirm Send');
String commandConfirmContent(String label) =>
_pick('确定要向该设备下发「$label」指令吗?',
'Are you sure you want to send the "$label" command?');
String commandSent(String target, String label) =>
_pick('已向$target下发「$label」指令(后端已接收,不代表设备已执行)',
'Command "$label" sent to $target (accepted by server, not yet confirmed by device)');
String get commandSendFailed => _pick('指令下发失败', 'Failed to send command');
String get pleaseInput => _pick('请输入', 'Please enter');
String get volumeRangeHint => _pick('请输入 0~100 的整数音量',
'Please enter an integer volume between 0 and 100');
String get maxVolume => _pick('最大音量', 'Max Volume');
String get commandOfflineWarning =>
_pick('该设备当前离线(或心跳已超时),指令可能不会立即生效;设备重新上报心跳后才会执行。',
'This device is offline (or its heartbeat timed out). The command may not take effect immediately; it will run after the device reconnects.');
String get commandOfflineConfirm => _pick('仍然下发', 'Send Anyway');
String get commandMacMissing =>
_pick('该设备缺少 MAC 地址,无法按 MAC 下发指令,请改用门店批量下发。',
'This device has no MAC address, so a MAC-targeted command cannot be sent. Use store-level delivery instead.');
String deviceCommandLabel(String cmd) {
return _pick(
const {
'powerOff': '关机',
'reboot': '重启',
'screenshot': '截屏',
'themeUpdate': '主题更新',
'versionUpdate': '版本更新',
'marqueeUpdate': '滚动字幕更新',
'weatherUpdate': '更新天气信息',
'mediaZoneVol': '媒体区域音量',
'defaultVol': '默认音量',
'themeClear': '清除主题缓存',
'cacheClear': '清除缓存',
'storeExpire': '门店到期',
'storeDisable': '门店禁用',
'passwordUpdate': '修改设置密码',
'worldAd': '获取世界广告',
}[cmd] ??
cmd,
const {
'powerOff': 'Power Off',
'reboot': 'Reboot',
'screenshot': 'Screenshot',
'themeUpdate': 'Theme Update',
'versionUpdate': 'Version Update',
'marqueeUpdate': 'Marquee Update',
'weatherUpdate': 'Update Weather',
'mediaZoneVol': 'Media Zone Volume',
'defaultVol': 'Default Volume',
'themeClear': 'Clear Theme Cache',
'cacheClear': 'Clear Cache',
'storeExpire': 'Store Expired',
'storeDisable': 'Store Disabled',
'passwordUpdate': 'Change Settings Password',
'worldAd': 'Fetch World Ads',
}[cmd] ??
cmd,
);
}
String deviceCommandInputLabel(String cmd) {
if (cmd == 'passwordUpdate') return _pick('新密码', 'New Password');
return _pick('音量(0-100)', 'Volume (0-100)');
}
// ============ 门店管理 ============
String get storeManagement => _pick('门店管理', 'Store Management');
String get storeCommands => _pick('门店指令', 'Store Commands');
String get searchStoreHint => _pick('搜索门店名称', 'Search store name');
String get storeDeleteConfirmTitle => _pick('删除门店', 'Delete Store');
String storeDeleteConfirmContent(String name) =>
_pick('确定要删除门店「$name」吗?', 'Are you sure you want to delete store "$name"?');
String get storeDeleted => _pick('门店已删除', 'Store deleted');
String get loadFailed => _pick('加载失败', 'Load failed');
String get storeId => _pick('门店ID', 'Store ID');
String get address => _pick('地址', 'Address');
String get contactPhone => _pick('联系电话', 'Contact Phone');
String get expireTime => _pick('到期时间', 'Expire Time');
String get expired => _pick('已过期', 'Expired');
String get noExpireTime => _pick('无到期时间', 'No expire date');
String get createTime => _pick('创建时间', 'Create Time');
String get storeName => _pick('门店名称', 'Store Name');
String get storeNameRequired => _pick('请输入门店名称', 'Please enter store name');
String get region => _pick('所在地区', 'Region');
String get regionHint => _pick('请选择省 / 市 / 区', 'Please select province / city / district');
String get addressDetail => _pick('详细地址', 'Detailed Address');
String get addressDetailHint => _pick('街道 / 门牌号等', 'Street / house number');
String get interfaceConfig => _pick('界面配置', 'Interface Config');
String get themeSkin => _pick('主题皮肤', 'Theme Skin');
String get themeCurrent => _pick('当前', 'Current');
String get themeUnnamed => _pick('未命名主题', 'Unnamed Theme');
String get noInterfaceConfig => _pick('当前门店暂无设备分组主题',
'No device group themes for this store');
String get editStore => _pick('编辑门店', 'Edit Store');
String get addStore => _pick('新增门店', 'Add Store');
String get create => _pick('创建', 'Create');
String get sampleStore => _pick('示例门店', 'Sample Store');
String get parentOrg => _pick('所属组织', 'Parent Organization');
String get parentOrgRequired => _pick('请选择所属组织', 'Please select parent organization');
String get regionRequired => _pick('请选择所在地区', 'Please select region');
String get storeUpdatedPlaceholder => _pick('门店已更新(本地占位)',
'Store updated (local placeholder)');
String get storeCreatedPlaceholder => _pick('门店已创建(本地占位)',
'Store created (local placeholder)');
// ============ 激活码管理 ============
String get activationCodeManagement => _pick('激活码管理', 'Activation Code Management');
String get noStoreAvailable => _pick('暂无可选门店', 'No stores available');
String get pleaseSelectStoreFirst => _pick('请先选择门店', 'Please select a store first');
String get currentActivationCode => _pick('当前激活码', 'Current Activation Code');
String get noActivationCode => _pick('暂无激活码信息', 'No activation code info');
String get generateActivationCode => _pick('生成激活码', 'Generate Activation Code');
String get resetActivationCode => _pick('重置激活码', 'Reset Activation Code');
String get resetActivationCodeConfirm => _pick('重置后原激活码将失效,确定要继续吗?',
'The original code will become invalid after reset. Continue?');
String get activationCodeGenerated => _pick('激活码已生成', 'Activation code generated');
String get activationCodeReset => _pick('激活码已重置', 'Activation code reset');
String get generateFailed => _pick('生成失败', 'Generate failed');
String get resetFailed => _pick('重置失败', 'Reset failed');
String get activationCodeResetSuccess =>
_pick('激活码已重置', 'Activation code reset');
String activationCodeResetFailed(String error) =>
_pick('激活码重置失败:$error', 'Activation code reset failed: $error');
String get maxActivateCount => _pick('最大激活数', 'Max Activations');
String get activatedCount => _pick('已激活数', 'Activated Count');
String get copy => _pick('复制', 'Copy');
String get generate => _pick('生成', 'Generate');
String get activationCodeCopied => _pick('激活码已复制', 'Activation code copied');
String get generateActivationCodeHint => _pick(
'将为当前门店生成新的激活码,原有激活码将被替换。',
'A new activation code will be generated for this store, replacing the existing one.');
String activationCodeLoadFailed(String error) =>
_pick('激活码加载失败:$error', 'Failed to load activation code: $error');
String generateFailedWithError(String error) =>
_pick('生成失败:$error', 'Generate failed: $error');
String get activationCodeNoCodeHint => _pick('无激活码,请点击「生成激活码」',
'No activation code, please tap "Generate Activation Code"');
String get maxActivationCountLabel => _pick('最大激活次数', 'Max Activation Count');
String get maxActivationCountHint => _pick('该激活码最多可激活的设备数量',
'Maximum number of devices this code can activate');
String get positiveIntegerRequired => _pick('请输入大于 0 的整数',
'Please enter an integer greater than 0');
// 门店表单中的激活码/终端数
String get terminalCountLabel => _pick('终端数', 'Terminal Count');
String get terminalCountHint => _pick('该门店允许激活的终端数量', 'Number of terminals allowed');
String get maxTerminalCount => _pick('最大终端数', 'Max Terminals');
String get usedTerminalCount => _pick('已用终端数', 'Used Terminals');
String get createActivationCode => _pick('创建激活码', 'Create Activation Code');
String get activationCodeWillAutoGenerate => _pick('保存门店后将自动生成激活码',
'Activation code will be auto-generated after saving the store');
String get activeActivationCode => _pick('当前激活码', 'Current Activation Code');
String get noActiveActivationCode => _pick('暂无正在使用的激活码',
'No activation code currently in use');
String get activationCodeGenerateHint => _pick('每次点击仅生成一个对应激活码',
'Each tap generates one activation code');
String storeSavedActivationFailed(String error) =>
_pick('门店已保存,但激活码生成失败:$error',
'Store saved, but activation code generation failed: $error');
String storeListLoadFailed(String error) =>
_pick('门店列表加载失败:$error', 'Failed to load store list: $error');
// ============ 用户管理 ============
String get username => _pick('用户名', 'Username');
String get usernameRequired => _pick('请输入用户名', 'Please enter username');
String get email => _pick('邮箱', 'Email');
String get mobile => _pick('手机号', 'Mobile');
String get password => _pick('密码', 'Password');
String get passwordEditHint => _pick('不修改请留空', 'Leave blank to keep unchanged');
String get passwordRequired => _pick('请输入密码', 'Please enter password');
String get confirmPassword => _pick('确认密码', 'Confirm Password');
String get confirmPasswordHint => _pick('请再次输入密码', 'Please enter the password again');
String get confirmPasswordRequired => _pick('请输入确认密码', 'Please enter confirm password');
String get role => _pick('角色', 'Role');
String get roleRequired => _pick('请选择角色', 'Please select a role');
String get org => _pick('所属组织', 'Organization');
String get selectOrg => _pick('请选择所属组织', 'Please select an organization');
String get noOrgHint => _pick('暂无可选组织,保留原归属',
'No organizations available, keep original');
String get status => _pick('状态', 'Status');
String get unassigned => _pick('未分配', 'Unassigned');
String get searchUsernameHint => _pick('搜索用户名', 'Search username');
String get searchOrgHint => _pick('搜索组织名称', 'Search organization name');
String get noMatchingOrg => _pick('未找到匹配组织', 'No matching organization');
String get roleLabel => _pick('角色', 'Role');
String get orgLabel => _pick('所属组织', 'Organization');
String get currentAccountInfo => _pick('当前账号信息', 'Current Account Info');
String get passwordNotSaved => _pick('未保存', 'Not saved');
String get noSubordinateAccount =>
_pick('当前账号暂无下级账号', 'No subordinate accounts');
String get showPassword => _pick('显示密码', 'Show password');
String get hidePassword => _pick('隐藏密码', 'Hide password');
String get editUser => _pick('编辑用户', 'Edit User');
String get addUser => _pick('新增用户', 'Add User');
String get deleteUserConfirmTitle => _pick('确认删除', 'Confirm Delete');
String deleteUserConfirmContent(String name) =>
_pick('确定要删除用户「$name」吗?', 'Are you sure you want to delete user "$name"?');
String get rolesLoadFailed => _pick('角色列表加载失败', 'Failed to load roles');
// ============ 滚动字幕管理 ============
String get marqueeManagement => _pick('滚动字幕管理', 'Scrolling Subtitle Management');
String get addMarquee => _pick('新增滚动字幕', 'Add Scrolling Subtitle');
String get editMarquee => _pick('编辑滚动字幕', 'Edit Scrolling Subtitle');
String get marqueeContent => _pick('字幕内容', 'Subtitle Content');
String get marqueeContentRequired => _pick('请输入字幕内容', 'Please enter subtitle content');
String get marqueeSpeed => _pick('滚动速度', 'Scroll Speed');
String get marqueeSpeedHint => _pick('像素/秒,建议 30~120', 'pixels/sec, recommended 30-120');
String get marqueeSpeedInvalid => _pick('请选择滚动速度', 'Please select scroll speed');
String get marqueeFontSizeInvalid => _pick('请输入大于 0 的字体大小', 'Please enter a font size greater than 0');
String get marqueeSpeedSlow => _pick('慢', 'Slow');
String get marqueeSpeedSlower => _pick('较慢', 'Slower');
String get marqueeSpeedMedium => _pick('中', 'Medium');
String get marqueeSpeedFaster => _pick('较快', 'Faster');
String get marqueeSpeedFast => _pick('快', 'Fast');
String get marqueeGradeDanger => _pick('危险', 'Danger');
String get marqueeGradeUrgent => _pick('紧急', 'Urgent');
String get marqueeGradeNormal => _pick('普通', 'Normal');
String get targetStore => _pick('目标门店', 'Target Store');
String get filterByStore => _pick('按门店筛选', 'Filter by Store');
String get searchMarqueeHint => _pick('搜索字幕内容或门店', 'Search subtitle content or store');
String get noMarqueeData => _pick('暂无滚动字幕', 'No scrolling subtitles');
String get marqueeStatus => _pick('状态', 'Status');
String get marqueeStatusEnabled => _pick('启用', 'Enabled');
String get marqueeStatusDisabled => _pick('禁用', 'Disabled');
String get marqueeStatusPending => _pick('待下发', 'Pending');
String get marqueeStatusDispatched => _pick('已下发', 'Dispatched');
String get dispatch => _pick('下发', 'Dispatch');
String get cancelDispatch => _pick('取消下发', 'Cancel Dispatch');
String get marqueeDispatched => _pick('字幕已下发', 'Subtitle dispatched');
String get marqueeDispatchFailed => _pick('字幕下发失败', 'Failed to dispatch subtitle');
String get marqueeDispatchCancelled => _pick('已取消下发', 'Dispatch cancelled');
String get marqueeCancelFailed => _pick('取消下发失败', 'Failed to cancel dispatch');
String get marqueeFontColor => _pick('字体颜色', 'Font Color');
String get marqueeBackgroundColor => _pick('背景颜色', 'Background Color');
String get marqueeFontSize => _pick('字体大小', 'Font Size');
String get marqueeLevel => _pick('级别', 'Level');
String get marqueeStartTime => _pick('开始时间', 'Start Time');
String get marqueeEndTime => _pick('结束时间', 'End Time');
String get marqueeHour => _pick('时', 'Hour');
String get marqueeMinute => _pick('分', 'Minute');
String get marqueeSecond => _pick('秒', 'Second');
String get marqueeContentPreview => _pick('字幕内容', 'Subtitle Content');
// ============ 公司介绍 ============
String get introCategoryTitle => _pick('公司介绍', 'Company Intro');
String get introStoreFilter => _pick('门店名称', 'Store Name');
String get introAllStores => _pick('全部门店', 'All Stores');
String get introCategoryName => _pick('名称', 'Name');
String get introCategoryTypeCode => _pick('类型编码', 'Type Code');
String get introCategoryEdit => _pick('编辑大类', 'Edit Category');
String get introCategoryAdd => _pick('新增大类', 'Add Category');
String get introSubcategoryEdit => _pick('编辑小类', 'Edit Subcategory');
String get introSubcategoryAdd => _pick('新增小类', 'Add Subcategory');
String get introDetailEdit => _pick('编辑内容', 'Edit Content');
String get introDetailAdd => _pick('新增内容', 'Add Content');
String get introTitle => _pick('标题', 'Title');
String get introTitleRequired => _pick('请输入标题', 'Please enter the title');
String get introDescription => _pick('描述', 'Description');
String get introMediaType => _pick('媒体类型', 'Media Type');
String get introMediaUrl => _pick('媒体地址', 'Media URL');
String get introMediaUrlRequired =>
_pick('请上传或填写媒体地址', 'Please upload or enter the media URL');
String get introImage => _pick('图片', 'Image');
String get introVideo => _pick('视频', 'Video');
String get introTypeLabel => _pick('类型', 'Type');
String get introStoreLabel => _pick('门店', 'Store');
String get introFilterStores => _pick('筛选门店', 'Filter Stores');
String get introSelectStoresHint => _pick('可多选门店', 'Select multiple stores');
String get introCategoryStore => _pick('所属门店', 'Store');
String get introStoreRequired => _pick('请选择所属门店', 'Please select a store');
String get introCategoryNameRequired =>
_pick('请输入名称', 'Please enter the name');
String get introCategoryTypeRequired =>
_pick('请输入类型编码', 'Please enter the type code');
String get introCategoryTypeNoChinese =>
_pick('类型编码禁止包含中文', 'Type code must not contain Chinese characters');
String get introStoreLoadFailed =>
_pick('门店列表加载失败,请下拉刷新后重试', 'Failed to load stores, please refresh and retry');
String get introSortOrder => _pick('排序号', 'Sort Order');
String get introEnabled => _pick('启用', 'Enabled');
String get introDisabled => _pick('禁用', 'Disabled');
String get introUploadImage => _pick('上传图片', 'Upload Image');
String get introPickImage => _pick('选择图片', 'Pick Image');
String get introImagePreview => _pick('图片预览', 'Image Preview');
String get introUploading => _pick('上传中…', 'Uploading…');
String get introUploadFailed => _pick('图片上传失败', 'Image upload failed');
String get introInsertImage => _pick('插入图片', 'Insert Image');
String get introSubImageHint =>
_pick('图片不超过 300KB,建议分辨率 1920×1080', 'Image ≤ 300KB, suggested 1920×1080');
String get introDetailImageHint => _pick('图片不超过 3MB', 'Image ≤ 3MB');
String get introDetailVideoHint => _pick('视频不超过 30MB', 'Video ≤ 30MB');
String get introImageTypeInvalid =>
_pick('请上传 jpg、png、gif 或 webp 图片', 'Please upload jpg, png, gif or webp image');
String get introVideoTypeInvalid =>
_pick('请上传 mp4、webm、mov 或 m4v 视频', 'Please upload mp4, webm, mov or m4v video');
String get introFileTooLarge => _pick('文件大小超过限制', 'File exceeds the size limit');
// ============ 地区选择器 ============
String get selectProvince => _pick('选择省份', 'Select Province');
String get selectCity => _pick('选择城市', 'Select City');
String get selectDistrict => _pick('选择区县', 'Select District');
String get selectRegion => _pick('选择地区', 'Select Region');
String get previousLevel => _pick('上一级', 'Previous');
// ============ 运行时长 ============
String formatUptime(int seconds) {
final duration = Duration(seconds: seconds);
final days = duration.inDays;
final hours = duration.inHours % 24;
final minutes = duration.inMinutes % 60;
final parts = <String>[];
if (days > 0) parts.add('$days${_pick('天', 'd')}');
if (hours > 0) parts.add('$hours${_pick('小时', 'h')}');
if (minutes > 0 || parts.isEmpty) {
parts.add('$minutes${_pick('分', 'm')}');
}
return parts.join(' ');
}
// ============ 通用状态/操作(后置兼容) ============
String get noOrgId => _pick('该门店缺少组织 ID,无法下发指令',
'This store lacks an organization ID and cannot receive commands');
// ============ 通用删除确认 ============
String get confirmDelete => _pick('确认删除', 'Confirm Delete');
String deleteConfirmContent(String name) =>
_pick('确定要删除「$name」吗?', 'Are you sure you want to delete "$name"?');
// ============ 渠道商管理 ============
String get channelManagement => _pick('渠道商管理', 'Channel Management');
String get addChannel => _pick('新增渠道', 'Add Channel');
String get editChannel => _pick('编辑渠道商', 'Edit Channel');
String get addSubChannel => _pick('新增子级', 'Add Child');
String get channelName => _pick('名称', 'Name');
String get channelType => _pick('类型', 'Type');
String get channelSort => _pick('排序', 'Sort');
String get channelStatus => _pick('状态', 'Status');
String get channelContactPhone => _pick('联系电话', 'Contact Phone');
String get channelAddress => _pick('地址', 'Address');
String get channelOrgCode => _pick('组织编码', 'Org Code');
String get channelTypeChannel => _pick('渠道', 'Channel');
String get channelTypeAgent => _pick('代理', 'Agent');
String get channelEnabled => _pick('启用', 'Enabled');
String get channelDisabled => _pick('禁用', 'Disabled');
String get channelParent => _pick('上级', 'Parent');
String get channelNameRequired => _pick('请输入名称', 'Please enter a name');
String get searchChannelHint => _pick('搜索渠道/代理商名称', 'Search channel/agent name');
String get channelDetail => _pick('渠道详情', 'Channel Detail');
String get channelExpireTime => _pick('到期时间', 'Expire Time');
String get channelCannotDeleteWithChildren =>
_pick('存在子集时不允许删除', 'Cannot delete a node that has children');
String get channelFilterStatus => _pick('状态', 'Status');
String get channelSwipeToDelete => _pick('左滑删除', 'Swipe left to delete');
// ============ 渠道版本管理 ============
/// 渠道/代理节点下钻的 App 版本管理入口。
String get channelVersionManage => _pick('版本管理', 'Version');
String get versionName => _pick('版本名', 'Version Name');
String get versionCode => _pick('版本号', 'Version Code');
String get downloadUrlHint => _pick('下载地址', 'Download URL');
String get forceUpdate => _pick('强制更新', 'Force Update');
String get showPopup => _pick('弹窗提示', 'Popup');
String get forceUpdateYes => _pick('是', 'Yes');
String get forceUpdateNo => _pick('否', 'No');
String get addVersion => _pick('新增版本', 'Add Version');
String get editVersion => _pick('编辑版本', 'Edit Version');
String get versionNameRequired => _pick('请输入版本名', 'Please enter a version name');
String get versionCodeRequired =>
_pick('请输入正整数版本号', 'Please enter a positive integer version code');
String get versionUrlRequired => _pick('请输入下载地址', 'Please enter a download URL');
String get versionUrlInvalid =>
_pick('请输入以 http(s) 开头的下载地址', 'URL must start with http(s)');
String get versionDeleteConfirm => _pick(
'确定删除该版本配置?删除后可重新新增。',
'Delete this version config? You can add it again afterward.');
String get versionDeleted => _pick('版本已删除', 'Version deleted');
String get uploadFile => _pick('上传文件', 'Upload');
String get uploading => _pick('上传中…', 'Uploading…');
String get uploadSuccess => _pick('文件上传成功', 'Uploaded');
String get uploadFailed => _pick('文件上传失败', 'Upload failed');
String get pleaseSelectApk => _pick('请选择 .apk 文件', 'Please select an .apk file');
// ============ 版本更新 ============
String get checkUpdate => _pick('检查更新', 'Check Update');
String get updateAvailable => _pick('发现新版本', 'Update Available');
String get currentVersion => _pick('当前版本', 'Current');
String get latestVersion => _pick('最新版本', 'Latest');
String get downloadUrl => _pick('下载地址', 'Download');
String get updateNow => _pick('立即更新', 'Update Now');
String get updateLater => _pick('稍后', 'Later');
String get updateNoNew => _pick('已是最新版本', 'Already up to date');
String get updateOpenFailed => _pick('无法打开下载地址', 'Failed to open download link');
}
/// 设备状态键(避免在模型层引入 BuildContext)。
enum DeviceStatusKey { online, offline, warning }

View File

@@ -0,0 +1,69 @@
// 账号类别与功能模块权限判定。
//
// 作为菜单可见性与路由可达性的**单一来源**:AdminHubPage 据此决定模块是否
// 显示在界面,router.dart 的 redirect 据此决定是否拦截深链/返回键,二者保持
// 完全一致,避免「界面看不到却能路由进入」或「点进去被弹回」的不一致。
//
// 判定依据:登录用户 [User] 的 [User.superAdmin] / [User.orgId] / [User.orgType]。
// - superAdmin=true 或 orgId 为空 → 管理员(可见全部模块);
// - orgType=1(渠道)→ 渠道商;orgType=2(代理商)→ 代理商;
// - 受限但类型未知 → 按渠道商兜底,确保「渠道商管理」对渠道商账号可开放
// (fail-open,符合「渠道商账号点击渠道商管理应正常打开」的修复目标)。
//
// 模块可见性规则:
// - 用户管理:所有已登录账号可见(管理员/渠道商/代理商各看自己范围内用户);
// - 渠道商管理:管理员 + 渠道商可见,代理商不可见;
// - 其余模块(设备/激活码/公司介绍/滚动字幕):所有账号可见。
import '../models/user.dart';
enum AccountCategory {
/// 管理员(超级管理员 / 无组织归属)。
admin,
/// 渠道商(orgType=1)。
channelMerchant,
/// 代理商(orgType=2)。
agent,
/// 未知(理论上不会在 UI 中出现,作为兜底占位)。
unknown,
}
/// 功能模块权限判定工具。
class SessionPermissions {
SessionPermissions._();
/// 依据当前登录用户推导账号类别。
static AccountCategory category(User? user) {
if (user == null) return AccountCategory.unknown;
if (user.superAdmin) return AccountCategory.admin;
if (user.orgId.isEmpty) return AccountCategory.admin;
switch (user.orgType) {
case 0:
return AccountCategory.admin; // 平台
case 1:
return AccountCategory.channelMerchant; // 渠道
case 2:
return AccountCategory.agent; // 代理商
default:
// 受限但类型未知:按渠道商兜底,开放「渠道商管理」。
return AccountCategory.channelMerchant;
}
}
/// 是否管理员(可访问系统管理 / 用户管理等全部模块)。
static bool isAdmin(User? user) => category(user) == AccountCategory.admin;
/// 是否可见 / 可访问「用户管理」(所有已登录账号)。
///
/// 管理员看全量,渠道商/代理商看自己组织范围内的用户;具体数据范围由
/// [UserPage] 调用后端时按会话权限收敛,不再在菜单层一刀切隐藏。
static bool canViewUserManagement(User? user) => user != null;
/// 是否可见 / 可访问「渠道商管理」(管理员 + 渠道商;代理商不可)。
static bool canViewChannelManagement(User? user) {
final c = category(user);
return c == AccountCategory.admin || c == AccountCategory.channelMerchant;
}
}

55
lib/utils/responsive.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// 响应式断点工具:用 [Size.shortestSide] 而非 width 判定窗口尺寸,
/// 使手机横屏(1600×900 → shortSide=900)正确落入 medium 档。
///
/// 详见 docs/手机端UI优化方案-v2.md §7.4。
enum AppWindowSize { compact, medium, expanded }
class AppResponsive {
AppResponsive(this.size, this.orientation);
final Size size;
final Orientation orientation;
factory AppResponsive.of(BuildContext context) => AppResponsive(
MediaQuery.sizeOf(context),
MediaQuery.orientationOf(context),
);
bool get isLandscape => orientation == Orientation.landscape;
AppWindowSize get windowSize => switch (size.shortestSide) {
< 600 => AppWindowSize.compact,
< 840 => AppWindowSize.medium, // ★ 手机横屏 900 落这里
_ => AppWindowSize.expanded,
};
/// 横屏且宽度够时启用主从(双栏)布局。
bool get useMasterDetail => size.width >= 840 && isLandscape;
/// 自适应网格列数(列表 / 卡片网格)。
int gridCount({double minTileWidth = 320}) =>
(size.width / minTileWidth).floor().clamp(2, 6);
/// 内容最大宽度(限宽居中,避免横屏拉爆)。
double get contentMaxWidth => switch (windowSize) {
AppWindowSize.compact => double.infinity,
AppWindowSize.medium => 560,
AppWindowSize.expanded => 720,
};
/// 栅格间距。
double get gutter => switch (windowSize) {
AppWindowSize.compact => 12,
AppWindowSize.medium => 24,
_ => 32,
};
/// 卡片内边距(横屏更宽松)。
double get cardPadding => windowSize == AppWindowSize.compact ? 16 : 20;
}
extension AppResponsiveX on BuildContext {
AppResponsive get responsive => AppResponsive.of(this);
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
/// 当前登录用户的「数据权限范围」——其所属组织子树的根节点 ID。
///
/// 用途:在客户端对组织树 / 门店列表做二次收敛,确保非管理员用户只能看到
/// 自己所属组织及下属门店的数据,防止后端 `/sys/org/tree` 返回全量树时被
/// 越权展示其他组织的数据。
///
/// 由 [AuthNotifier] 在登录成功与会话恢复时写入 [setCurrentOrg],登出 /
/// 会话失效时 [clear]。读取方([OrgService])仅在 [enabled] 为 true 时过滤,
/// [currentOrgId] 为空(管理员 / 无法识别)时不做任何裁剪,行为与后端一致。
class SessionOrgScope {
SessionOrgScope._();
static final SessionOrgScope instance = SessionOrgScope._();
/// 当前用户所属组织 ID。空字符串表示「未限定」(管理员或无法识别),
/// 此时客户端不过滤,完全信任后端返回。
String currentOrgId = '';
/// 是否启用客户端数据权限收敛。
bool get enabled => currentOrgId.isNotEmpty;
/// 写入当前用户组织范围。
void setCurrentOrg(String? orgId) {
currentOrgId = orgId ?? '';
if (kDebugMode) {
debugPrint(
'[SessionOrgScope] currentOrgId=${currentOrgId.isEmpty ? "(未限定/管理员)" : currentOrgId}',
);
}
}
/// 清空(登出 / 会话失效)。
void clear() {
currentOrgId = '';
}
}

View File

@@ -0,0 +1,139 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 门店扩展字段本地持久化(SharedPreferences 封装)。
///
/// 用于「前端先占位」阶段:主题、品牌(启动图/Logo)、省市区归属等
/// 全部只写本地缓存,不发起真实请求。key 以 `orgId` 隔离,切换组织后互不影响。
///
/// TODO(后端): 上述字段后续由 [OrgService.setStoreTheme] /
/// [OrgService.setStoreBranding] 对接 POST /sys/store/* 覆盖,本地缓存可作为离线兜底。
class StoreLocalCache {
StoreLocalCache._();
/// 全局单例。
static final StoreLocalCache instance = StoreLocalCache._();
SharedPreferences? _prefs;
/// 懒初始化 SharedPreferences(仅在首次访问时调用)。
Future<SharedPreferences> get _instance async =>
_prefs ??= await SharedPreferences.getInstance();
/// 外部预初始化(在 [main] 中 await,使同步读取可用)。
Future<void> initialize() async {
_prefs ??= await SharedPreferences.getInstance();
}
/// 主题 key:`store_theme_$orgId`。
String _themeKey(String orgId) => 'store_theme_$orgId';
/// 品牌 key:`store_branding_$orgId`。
String _brandingKey(String orgId) => 'store_branding_$orgId';
/// 省市区归属 key:`store_region_$orgId`。
String _regionKey(String orgId) => 'store_region_$orgId';
/// 设备绑定关系 key:`device_binding_$deviceId`。
String _bindingKey(String deviceId) => 'device_binding_$deviceId';
/// 保存门店主题。
Future<void> saveTheme(String orgId, String themeId) async {
final prefs = await _instance;
await prefs.setString(_themeKey(orgId), themeId);
}
/// 读取门店主题 id;未设置返回 null。
Future<String?> getTheme(String orgId) async {
final prefs = await _instance;
return prefs.getString(_themeKey(orgId));
}
/// 保存门店品牌(启动图 + Logo 本地路径或远程 URL)。
Future<void> saveBranding(
String orgId,
String splashUrl,
String logoUrl,
) async {
final prefs = await _instance;
await prefs.setStringList(
_brandingKey(orgId),
[splashUrl, logoUrl],
);
}
/// 读取门店品牌;返回 `[splashUrl, logoUrl]`,未设置时元素为空字符串。
Future<List<String>> getBranding(String orgId) async {
final prefs = await _instance;
final raw = prefs.getStringList(_brandingKey(orgId));
if (raw == null || raw.length < 2) {
return const ['', ''];
}
return raw;
}
/// 保存门店省市区归属与街道详情。
Future<void> saveRegion(
String orgId,
String provinceCode,
String cityCode,
String districtCode,
String addressDetail,
) async {
final prefs = await _instance;
await prefs.setStringList(
_regionKey(orgId),
[provinceCode, cityCode, districtCode, addressDetail],
);
}
/// 读取门店省市区归属;返回 `[provinceCode, cityCode, districtCode, addressDetail]`
/// 未设置时元素为空字符串。
Future<List<String>> getRegion(String orgId) async {
final prefs = await _instance;
final raw = prefs.getStringList(_regionKey(orgId));
if (raw == null || raw.length < 4) {
return const ['', '', '', ''];
}
return raw;
}
/// 保存设备绑定关系(扫码绑定占位)。
Future<void> saveDeviceBinding(
String deviceId,
String storeId,
String storeName,
String? groupId,
) async {
final prefs = await _instance;
await prefs.setStringList(
_bindingKey(deviceId),
[storeId, storeName, groupId ?? ''],
);
}
/// 读取设备绑定关系;返回 `[storeId, storeName, groupId]`,
/// 未设置时元素为空字符串。
Future<List<String>> getDeviceBinding(String deviceId) async {
final prefs = await _instance;
final raw = prefs.getStringList(_bindingKey(deviceId));
if (raw == null || raw.length < 3) {
return const ['', '', ''];
}
return raw;
}
// ================ 通用 String 存取(用于语言、主题等非门店隔离配置) ================
/// 保存字符串。
Future<void> setString(String key, String value) async {
final prefs = await _instance;
await prefs.setString(key, value);
}
/// 读取字符串;不存在返回 null。
String? getString(String key) {
final prefs = _prefs;
if (prefs == null) return null;
return prefs.getString(key);
}
}

17
lib/utils/uuid.dart Normal file
View File

@@ -0,0 +1,17 @@
import 'dart:math';
/// 生成与 web 前端一致的 UUID v4 字符串。
///
/// 模板:`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`
String generateUuid() {
final random = Random.secure();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAllMapped(
RegExp('[xy]'),
(match) {
final r = random.nextInt(16);
final c = match.group(0)!;
final v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toRadixString(16);
},
);
}