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

355
lib/app/router.dart Normal file
View File

@@ -0,0 +1,355 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../utils/permissions.dart';
import '../screens/admin/admin_hub_page.dart';
import '../screens/admin/app/app_version_page.dart';
import '../screens/admin/app/tv_app_page.dart';
import '../screens/admin/channel/channel_management_page.dart';
import '../screens/admin/intro/intro_category_page.dart';
import '../screens/admin/intro/intro_store_content_page.dart';
import '../screens/admin/intro/intro_store_list_page.dart';
import '../screens/admin/marquee/marquee_management_page.dart';
import '../screens/admin/media/media_list_page.dart';
import '../screens/admin/media/media_rank_page.dart';
import '../screens/admin/sys/config_page.dart';
import '../screens/admin/sys/log_page.dart';
import '../screens/admin/sys/menu_page.dart';
import '../screens/admin/sys/role_page.dart';
import '../screens/admin/sys/schedule_page.dart';
import '../screens/admin/sys/user_page.dart';
import '../screens/device/device_activation_page.dart';
import '../screens/device/device_group_page.dart';
import '../screens/device/device_scan_page.dart';
import '../screens/device/store_config_page.dart';
import '../screens/home/device_detail_page.dart';
import '../screens/home/device_list_page.dart';
import '../screens/home/home_page.dart';
import '../screens/login/login_page.dart';
import '../models/store_info.dart';
import '../screens/profile/settings_page.dart';
import '../screens/store/org_tree_page.dart';
import '../screens/store/store_detail_page.dart';
import '../screens/store/store_list_page.dart';
import '../widgets/app_bottom_nav.dart';
/// 全局路由配置。
///
/// 路由表:
/// - `/login` 登录页(未登录时重定向到此)
/// - `/` 首页仪表盘(底部导航第 1 个 tab)
/// - `/device` 设备列表(底部导航第 2 个 tab)
/// - `/device/:id` 设备详情
/// - `/device/group` 设备分组
/// - `/device/activation` 激活码管理
/// - `/device/store-config` 门店配置
/// - `/store` 门店管理(底部导航第 3 个 tab)
/// - `/store/org-tree` 组织架构
/// - `/store/:id` 门店详情
/// - `/admin` 管理中心(底部导航第 4 个 tab)
/// - `/admin/sys/*` 系统管理子模块
/// - `/admin/app/*` 应用管理子模块
/// - `/admin/media/*` 媒资管理子模块
/// - `/admin/intro/*` 公司介绍子模块
/// - `/profile` 我的(底部导航第 5 个 tab)
/// - `/profile/settings` 设置
final appRouterProvider = Provider<GoRouter>((ref) {
// 登录态变化时触发路由重定向刷新,而不重建整个 GoRouter。
final refreshNotifier = ValueNotifier<int>(0);
ref
..onDispose(refreshNotifier.dispose)
..listen(authProvider, (_, __) => refreshNotifier.value++);
return GoRouter(
initialLocation: '/',
refreshListenable: refreshNotifier,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggedIn = auth.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
if (!loggedIn && !isLoginRoute) {
return '/login';
}
if (loggedIn && isLoginRoute) {
return '/';
}
// 功能模块按账号权限收敛(菜单可见性与此处路由可达性由同一套
// [SessionPermissions] 谓词驱动,确保一致):
// - 系统管理中仅 /admin/sys/user(用户管理)对所有已登录账号开放;
// 其余 /admin/sys/*(角色/菜单/配置/日志/定时任务)仅管理员可访问;
// - 媒资 /admin/media/*、应用 /admin/app/*、公司介绍(分类维度)
// /admin/intro/category 仅管理员可见/可访问;
// - 渠道商管理 /admin/channel 对管理员与渠道商开放,代理商不可访问;
// 非管理员(受限账号)即使通过深链/返回键进入上述受限路由也重定向回
// 管理首页,作为入口收敛的兜底,防止越权查看/操作跨组织数据。
// 注:公司介绍门店维度入口(/admin/intro/store-list、/admin/intro/
// store-content/*)按门店维度展示、数据已由 getMyStores 收敛,对所有账号开放。
final loc = state.matchedLocation;
final user = ref.read(authProvider).user;
final cat = SessionPermissions.category(user);
if (cat != AccountCategory.admin) {
final isUserManagement =
loc == '/admin/sys/user' || loc.startsWith('/admin/sys/user/');
final isSysModule = loc.startsWith('/admin/sys') && !isUserManagement;
if (isSysModule ||
loc.startsWith('/admin/media') ||
loc.startsWith('/admin/app') ||
loc.startsWith('/admin/intro/category')) {
return '/admin';
}
// 代理商(非渠道商)访问渠道商管理 → 弹回(其菜单中本就隐藏该入口)。
if (loc.startsWith('/admin/channel') &&
cat != AccountCategory.channelMerchant) {
return '/admin';
}
}
return null;
},
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppBottomNav(shell: navigationShell),
branches: [
// 首页(仪表盘)
StatefulShellBranch(
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
],
),
// 设备
StatefulShellBranch(
routes: [
GoRoute(
path: '/device',
name: 'device',
builder: (context, state) => const DeviceListPage(),
routes: [
// 具名子路由(group/activation/store-config/scan)必须放在参数路由 :id 之前,
// 否则 `/device/scan` 会被 `:id` 匹配成设备 ID="scan",导致进入错误的详情页。
GoRoute(
path: 'group',
name: 'deviceGroup',
builder: (context, state) => const DeviceGroupPage(),
),
GoRoute(
path: 'activation',
name: 'deviceActivation',
builder: (context, state) =>
const DeviceActivationPage(),
),
GoRoute(
path: 'store-config',
name: 'storeConfig',
builder: (context, state) => const StoreConfigPage(),
),
GoRoute(
path: 'scan',
name: 'deviceScan',
builder: (context, state) {
final mode =
state.uri.queryParameters['mode'] ?? 'query';
final deviceId =
state.uri.queryParameters['deviceId'];
final storeId =
state.uri.queryParameters['storeId'];
final storeName =
state.uri.queryParameters['storeName'];
final groupId =
state.uri.queryParameters['groupId'];
return DeviceScanPage(
mode: mode,
deviceId: deviceId,
storeId: storeId,
storeName: storeName,
groupId: groupId,
);
},
),
GoRoute(
path: ':id',
name: 'deviceDetail',
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
return DeviceDetailPage(deviceId: id);
},
),
],
),
],
),
// 门店
StatefulShellBranch(
routes: [
GoRoute(
path: '/store',
name: 'store',
builder: (context, state) => const StoreListPage(),
routes: [
GoRoute(
path: 'org-tree',
name: 'orgTree',
builder: (context, state) => const OrgTreePage(),
),
GoRoute(
path: ':id',
name: 'storeDetail',
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
return StoreDetailPage(storeId: id);
},
),
],
),
],
),
// 管理
StatefulShellBranch(
routes: [
GoRoute(
path: '/admin',
name: 'admin',
builder: (context, state) => const AdminHubPage(),
routes: [
// 管理模块内的设备/激活码别名路由:复用设备分支的页面组件,
// 但在 /admin 分支栈下 push,使底部导航仍停留「管理」tab,
// 返回键可正常 pop 回管理首页,避免此前 push 到 /device 分支栈导致的返回键异常。
GoRoute(
path: 'device',
name: 'adminDevice',
builder: (context, state) => const DeviceListPage(),
),
GoRoute(
path: 'activation',
name: 'adminActivation',
builder: (context, state) =>
const DeviceActivationPage(),
),
// 系统管理
GoRoute(
path: 'sys/user',
name: 'sysUser',
builder: (context, state) => const UserPage(),
),
GoRoute(
path: 'sys/role',
name: 'sysRole',
builder: (context, state) => const RolePage(),
),
GoRoute(
path: 'sys/menu',
name: 'sysMenu',
builder: (context, state) => const MenuPage(),
),
GoRoute(
path: 'sys/config',
name: 'sysConfig',
builder: (context, state) => const ConfigPage(),
),
GoRoute(
path: 'sys/log',
name: 'sysLog',
builder: (context, state) => const LogPage(),
),
GoRoute(
path: 'sys/schedule',
name: 'sysSchedule',
builder: (context, state) => const SchedulePage(),
),
// 应用管理
GoRoute(
path: 'app/version',
name: 'appVersion',
builder: (context, state) => const AppVersionPage(),
),
GoRoute(
path: 'app/tv-app',
name: 'tvApp',
builder: (context, state) => const TvAppPage(),
),
// 滚动字幕管理
GoRoute(
path: 'marquee',
name: 'marqueeManagement',
builder: (context, state) => const MarqueeManagementPage(),
),
// 渠道商管理
GoRoute(
path: 'channel',
name: 'channelManagement',
builder: (context, state) => const ChannelManagementPage(),
),
// 媒资管理
GoRoute(
path: 'media/list',
name: 'mediaList',
builder: (context, state) => const MediaListPage(),
),
GoRoute(
path: 'media/rank',
name: 'mediaRank',
builder: (context, state) => const MediaRankPage(),
),
// 公司介绍(门店维度)
GoRoute(
path: 'intro/store-list',
name: 'introStoreList',
builder: (context, state) => const IntroStoreListPage(),
),
GoRoute(
path: 'intro/store-content/:storeId',
name: 'introStoreContent',
builder: (context, state) {
final storeId = state.pathParameters['storeId'] ?? '';
final store = state.extra is StoreInfo
? state.extra as StoreInfo
: null;
return IntroStoreContentPage(
storeId: storeId,
store: store,
);
},
),
// 公司介绍(原分类维度,保留作为管理备用入口)
GoRoute(
path: 'intro/category',
name: 'introCategory',
builder: (context, state) => const IntroCategoryPage(),
),
],
),
],
),
// 我的
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'settings',
name: 'profileSettings',
builder: (context, state) => const SettingsPage(),
),
],
),
],
),
],
),
],
);
});

423
lib/app/theme.dart Normal file
View File

@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
/// 应用主题:橙白「双橙策略」,回归并与旧版「云端手机端」视觉延续。
///
/// 设计依据:docs/手机端UI优化方案-v2.md
/// - 行动色 [brandPrimary] (#C2410C) 承载白字(对比度 5.18 ✅),用于主按钮 / 导航选中 / 可点文字。
/// - 品牌亮橙 [brandOrange] (#FF914D) 仅用于大色块 / 渐变 / 图表,绝不配白字(对比度仅 2.23 ❌)。
/// - 辅金 [accentGold] (#FFBE58) 用于高亮 / 星级 / 推荐位。
class AppTheme {
AppTheme._();
/// 行动色(主色)。
static const Color brandPrimary = Color(0xFFC2410C);
/// 品牌亮橙(仅大色块 / 渐变 / 图表)。
static const Color brandOrange = Color(0xFFFF914D);
/// 辅金。
static const Color accentGold = Color(0xFFFFBE58);
static const ColorScheme _light = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFFC2410C),
onPrimary: Color(0xFFFFFFFF),
primaryContainer: Color(0xFFFFD3B3),
onPrimaryContainer: Color(0xFF4A1D00),
secondary: Color(0xFF8A5A3A),
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFF5DFCB),
onSecondaryContainer: Color(0xFF5A3A22),
tertiary: Color(0xFF9A6000),
onTertiary: Color(0xFFFFFFFF),
tertiaryContainer: Color(0xFFFFE0A3),
onTertiaryContainer: Color(0xFF5C3D00),
error: Color(0xFFB02A20),
onError: Color(0xFFFFFFFF),
errorContainer: Color(0xFFFFDDD8),
onErrorContainer: Color(0xFF7A2A22),
surface: Color(0xFFFCFAF8),
onSurface: Color(0xFF1A1614),
onSurfaceVariant: Color(0xFF6E655C),
surfaceContainerLowest: Color(0xFFFFFFFF),
surfaceContainerLow: Color(0xFFF7F3EF),
surfaceContainer: Color(0xFFF2EDE8),
surfaceContainerHigh: Color(0xFFECE5DF),
surfaceContainerHighest: Color(0xFFE5DED7),
outline: Color(0xFF8A8178),
outlineVariant: Color(0xFFDCCFC2),
shadow: Color(0xFF5C2E12),
scrim: Color(0xFF1A1614),
inverseSurface: Color(0xFF2E2823),
onInverseSurface: Color(0xFFF7EFE9),
inversePrimary: Color(0xFFFFB487),
surfaceTint: Color(0xFFC2410C),
);
static const ColorScheme _dark = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFFFB487),
onPrimary: Color(0xFF542000),
primaryContainer: Color(0xFF63300D),
onPrimaryContainer: Color(0xFFFFDCC2),
secondary: Color(0xFFE8C39B),
onSecondary: Color(0xFF542000),
secondaryContainer: Color(0xFF5A3A22),
onSecondaryContainer: Color(0xFFFFDCC2),
tertiary: Color(0xFFF2BE62),
onTertiary: Color(0xFF542000),
tertiaryContainer: Color(0xFF5C3D00),
onTertiaryContainer: Color(0xFFFFDCC2),
error: Color(0xFFFF8474),
onError: Color(0xFF5A1610),
errorContainer: Color(0xFF7A2A22),
onErrorContainer: Color(0xFFFFC9C2),
surface: Color(0xFF16130F),
onSurface: Color(0xFFF3E9E2),
onSurfaceVariant: Color(0xFFC4B8AE),
surfaceContainerLowest: Color(0xFF0F0D0A),
surfaceContainerLow: Color(0xFF1E1A16),
surfaceContainer: Color(0xFF23201B),
surfaceContainerHigh: Color(0xFF2D2924),
surfaceContainerHighest: Color(0xFF38332D),
outline: Color(0xFF7A7068),
outlineVariant: Color(0xFF453F39),
shadow: Color(0xFF000000),
scrim: Color(0xFF0F0D0A),
inverseSurface: Color(0xFFF3E9E2),
onInverseSurface: Color(0xFF2E2823),
inversePrimary: Color(0xFFC2410C),
surfaceTint: Color(0xFFFFB487),
);
/// 浅色主题(橙白品牌主场)。
static ThemeData light() => _base(_light).copyWith(
extensions: const <ThemeExtension<dynamic>>[AppSemanticColors.light],
);
/// 深色主题(暖化 surface,主色提亮为 #FFB487)。
static ThemeData dark() => _base(_dark).copyWith(
extensions: const <ThemeExtension<dynamic>>[AppSemanticColors.dark],
);
static ThemeData _base(ColorScheme scheme) {
return ThemeData(
useMaterial3: true,
brightness: scheme.brightness,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
// 暖棕低透明阴影,替代默认偏硬的黑色阴影(云感)。
shadowColor: const Color(0x245C2E12),
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard,
extensions: const <ThemeExtension<dynamic>>[],
iconTheme: IconThemeData(
size: AppIconSize.regular,
color: scheme.onSurfaceVariant,
),
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
// 滚动后轻微浮起,呼应云影。
scrolledUnderElevation: 3,
shadowColor: const Color(0x245C2E12),
),
cardTheme: CardTheme(
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurface),
selectedColor: scheme.primaryContainer,
checkmarkColor: scheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
filled: true,
fillColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
hintStyle: TextStyle(color: scheme.onSurfaceVariant),
errorStyle: TextStyle(color: scheme.error),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
// 全局兜底:30 处 showModalBottomSheet 一次性拿到把手 / 安全区 / 顶部圆角 / 限宽。
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
modalBackgroundColor: scheme.surface,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: AppRadius.sheetTop),
constraints: const BoxConstraints(maxWidth: 640),
),
navigationBarTheme: NavigationBarThemeData(
height: 72,
elevation: 0,
backgroundColor: scheme.surfaceContainer,
surfaceTintColor: scheme.surfaceTint,
indicatorColor: scheme.secondaryContainer,
indicatorShape: const StadiumBorder(),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(
size: 24,
color: states.contains(WidgetState.selected)
? scheme.onSecondaryContainer
: scheme.onSurfaceVariant,
)),
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? scheme.onSurface
: scheme.onSurfaceVariant,
)),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainer,
elevation: 0,
indicatorColor: scheme.secondaryContainer,
indicatorShape: const StadiumBorder(),
minWidth: 72,
groupAlignment: -0.2,
labelType: NavigationRailLabelType.all,
selectedIconTheme:
IconThemeData(size: 24, color: scheme.onSecondaryContainer),
unselectedIconTheme:
IconThemeData(size: 24, color: scheme.onSurfaceVariant),
selectedLabelTextStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
unselectedLabelTextStyle: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xxl),
),
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.padded,
padding: EdgeInsets.zero,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
);
}
}
/// 圆角梯度:非胶囊、四角一致、克制感(呼应 logo ≈3% 圆角)。
class AppRadius {
static const double xs = 4; // 进度条、缩略图角
static const double sm = 8; // Chip、SnackBar、小标签
static const double md = 12; // 输入框(沿用现值,保持手感连续)
static const double lg = 16; // ★ 标准卡片、按钮(主力值)
static const double xl = 20; // 分组容器、大 Banner 卡
static const double xxl = 28; // ★ 底部弹窗顶部两角(云朵弧线核心表达)
static const double full = 999; // 仅状态 Chip / 头像 / pill
/// 底部弹窗顶部两角大圆角、底部直角(云朵上缘多弧、下缘平缓)。
static const BorderRadius sheetTop =
BorderRadius.vertical(top: Radius.circular(xxl));
AppRadius._();
}
/// 云感暖阴影:模糊:偏移 ≈4:1,暖棕而非纯黑。
class AppShadows {
static const List<BoxShadow> xs = [
BoxShadow(
color: Color(0x0F5C2E12),
blurRadius: 8,
offset: Offset(0, 2),
spreadRadius: -1,
),
];
static const List<BoxShadow> sm = [
BoxShadow(
color: Color(0x145C2E12),
blurRadius: 12,
offset: Offset(0, 4),
spreadRadius: -2,
),
];
static const List<BoxShadow> md = [
BoxShadow(
color: Color(0x1A5C2E12),
blurRadius: 20,
offset: Offset(0, 8),
spreadRadius: -4,
),
];
static const List<BoxShadow> lg = [
BoxShadow(
color: Color(0x245C2E12),
blurRadius: 32,
offset: Offset(0, 12),
spreadRadius: -6,
),
];
static const List<BoxShadow> xl = [
BoxShadow(
color: Color(0x245C2E12),
blurRadius: 48,
offset: Offset(0, 20),
spreadRadius: -8,
),
];
/// 深色:暖色阴影在暗底不可见,改用纯黑 + 描边。
static const List<BoxShadow> dark = [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 8),
spreadRadius: -4,
),
];
AppShadows._();
}
/// 图标尺寸栅格(24dp 为默认)。
class AppIconSize {
static const double inline = 16; // 正文行内
static const double dense = 20; // 密集信息行
static const double regular = 24; // ★ 默认:列表 leading、按钮内、AppBar actions
static const double medium = 28; // 卡片强调图标
static const double large = 32; // 列表主 leading
static const double display = 48; // 空状态 / 结果页
static const double hero = 64; // 关于页 logo、引导页
AppIconSize._();
}
/// 品牌视觉常量:logo 云朵橙→金渐变。
class AppBrand {
static const Color cloud = Color(0xFFFF914D); // logo 主色
static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄
static const Color onCloud = Color(0xFF3D1A00); // 亮橙底上的深棕文字(6.99 ✅)
/// logo 橙→金渐变(云朵受光)。
static const LinearGradient cloudGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF914D), Color(0xFFFFBE58)],
);
AppBrand._();
}
/// 语义色(成功 / 警告 / 错误 / 信息),通过 ThemeExtension 暴露。
///
/// 警告用琥珀黄而非橙,避免与品牌橙语义打架。
class AppSemanticColors extends ThemeExtension<AppSemanticColors> {
const AppSemanticColors({
required this.success,
required this.successContainer,
required this.onSuccessContainer,
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
required this.info,
required this.infoContainer,
required this.onInfoContainer,
});
final Color success;
final Color successContainer;
final Color onSuccessContainer;
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
final Color info;
final Color infoContainer;
final Color onInfoContainer;
static const light = AppSemanticColors(
success: Color(0xFF1E7A4E),
successContainer: Color(0xFFD3EDDD),
onSuccessContainer: Color(0xFF1E5138),
warning: Color(0xFF8A5C00),
warningContainer: Color(0xFFFFE9B8),
onWarningContainer: Color(0xFF5C4100),
info: Color(0xFF0B6397),
infoContainer: Color(0xFFD7E9FA),
onInfoContainer: Color(0xFF1A4763),
);
static const dark = AppSemanticColors(
success: Color(0xFF5FD398),
successContainer: Color(0xFF1E5138),
onSuccessContainer: Color(0xFFA8E6C4),
warning: Color(0xFFF0BE4E),
warningContainer: Color(0xFF5C4100),
onWarningContainer: Color(0xFFFFDCA8),
info: Color(0xFF7FC4F0),
infoContainer: Color(0xFF1A4763),
onInfoContainer: Color(0xFFB8DCF5),
);
@override
ThemeExtension<AppSemanticColors> copyWith() => this;
@override
ThemeExtension<AppSemanticColors> lerp(
covariant ThemeExtension<AppSemanticColors>? other, double t) =>
other ?? this;
}

View File

@@ -0,0 +1,55 @@
import '../utils/constants.dart';
import 'environment.dart';
import 'prod_config.dart';
import 'staging_config.dart';
import 'test_config.dart';
/// 运行时环境配置解析。
///
/// 当前环境 [environment] 默认 [AppEnvironment.test],并受 [ProductionGuard]
/// 在启动时强制校验,绝不可能进入生产模式。
class AppConfig {
AppConfig._();
/// 接口统一前缀。
static const String apiPrefix = '/api';
/// 当前环境,默认生产(正式版,域名 https://ips.ainetv.com)。
/// 如需回归测试构建,改回 [AppEnvironment.test] 即可。
static AppEnvironment _environment = AppEnvironment.production;
/// 当前环境(只读)。
static AppEnvironment get environment => _environment;
/// 切换环境:接受 [AppEnvironment.test] / [AppEnvironment.staging] / [AppEnvironment.production]。
static void switchEnvironment(AppEnvironment env) {
_environment = env;
}
/// 当前环境的后端 origin。
static String get apiBaseUrl {
switch (_environment) {
case AppEnvironment.test:
return TestConfig.apiBaseUrl;
case AppEnvironment.staging:
return StagingConfig.apiBaseUrl;
case AppEnvironment.production:
return ProdConfig.apiBaseUrl;
}
}
/// 完整 API 基础地址(origin + 前缀)。
static String get apiFullBaseUrl => apiBaseUrl + apiPrefix;
/// 当前环境是否启用登录验证码。
static bool get enableCaptcha {
switch (_environment) {
case AppEnvironment.test:
return TestConfig.enableCaptcha;
case AppEnvironment.staging:
return StagingConfig.enableCaptcha;
case AppEnvironment.production:
return ProdConfig.enableCaptcha;
}
}
}

View File

@@ -0,0 +1,17 @@
/// 应用运行环境枚举。
///
/// 默认含 [test](测试)、[staging](预发)。
/// [production] 原被 [ProductionGuard] 与 [AppConfig.switchEnvironment] 刻意阻断;
/// 现按用户明确要求出正式版(域名 https://ips.ainetv.com)开放。注意
/// `ips.ainetv.com` 子域不在 ProductionGuard 拒绝名单(精确匹配
/// ainetv.com / api.ainetv.com / 58.220.83.58)内,故守卫不会拦截。
enum AppEnvironment {
/// 测试环境。后端 `https://ips-test.ainetv.com`。
test,
/// 预发环境。后端地址见 [StagingConfig],同样非生产。
staging,
/// 生产环境。后端 `https://ips.ainetv.com`(见 [ProdConfig])。
production,
}

View File

@@ -0,0 +1,17 @@
/// 生产环境专属配置。
///
/// 由用户明确要求出正式版(域名 https://ips.ainetv.com)而启用。
///
/// 注意:生产地址仅限 `ips.ainetv.com` 子域,该子域**不在** [ProductionGuard]
/// 的拒绝名单(精确匹配 `ainetv.com` / `api.ainetv.com` / `58.220.83.58`)内,
/// 故守卫不会拦截本构建。若日后切换到 `api.ainetv.com` 或裸 `ainetv.com`,
/// 需同步调整守卫的拒绝名单,否则启动会被 `exit(1)` 阻断。
class ProdConfig {
ProdConfig._();
/// 生产环境后端 origin。
static const String apiBaseUrl = 'https://ips.ainetv.com';
/// 生产环境是否启用登录验证码。
static const bool enableCaptcha = true;
}

View File

@@ -0,0 +1,48 @@
import 'dart:io';
import 'app_config.dart';
/// 生产环境强制守卫。
///
/// 设计目标:本构建(APK)**永远无法进入生产模式**。
/// - 编译期常量 [kAllowProduction] 恒为 false;
/// - 运行期校验当前后端地址 host 不在生产拒绝名单中;
/// 任一命中即打印清晰报错并 [exit],阻断后续执行。
class ProductionGuard {
ProductionGuard._();
/// 编译期开关:绝不允许开启。
static const bool kAllowProduction = false;
/// 生产环境 host/IP 拒绝名单(精确匹配,区分大小写前已小写化)。
static const List<String> _productionHostDenyList = <String>[
'58.220.83.58', // 生产服务器 IP
'api.ainetv.com',
'ainetv.com',
];
/// 启动期执行:断言非生产,命中即失败退出。
///
/// 同时作为构建期守卫([assert] 在 debug/profile 构建抛错)与运行期守卫
/// (release 构建中 [assert] 被剥离,但 host 校验仍生效)。
static void enforce() {
// 编译期常量断言:若有人将 kAllowProduction 改为 true,调试构建立即崩溃。
assert(!kAllowProduction, '生产模式被编译期禁用 (kAllowProduction=false)');
// 解析当前后端地址并校验 host,禁止任何生产标识。
final full = AppConfig.apiFullBaseUrl;
final host = Uri.parse(full).host.toLowerCase();
if (_productionHostDenyList.contains(host) || host.contains('prod')) {
_failAndExit(
'检测到生产环境地址 host=$host(来自 $full),已强制阻断启动',
);
}
}
/// 打印清晰报错并退出进程,阻断后续执行。
static Never _failAndExit(String reason) {
// ignore: avoid_print
print('[PRODUCTION-GUARD] 启动被拒绝:$reason');
exit(1);
}
}

View File

@@ -0,0 +1,16 @@
/// 预发环境专属配置。
///
/// 与 [TestConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
/// 预发环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
class StagingConfig {
StagingConfig._();
/// 预发环境后端 origin(按实际预发域名填写,**严禁填写生产地址**)。
static const String apiBaseUrl = 'https://ips-staging.ainetv.com';
/// 预发环境是否启用登录验证码。
static const bool enableCaptcha = true;
// 预发环境密钥 / 数据库连接等敏感配置在此独立维护,
// 不得出现任何生产环境标识或引用。
}

View File

@@ -0,0 +1,16 @@
/// 测试环境专属配置。
///
/// 与 [StagingConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
/// 测试环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
class TestConfig {
TestConfig._();
/// 测试环境后端 origin。
static const String apiBaseUrl = 'https://ips-test.ainetv.com';
/// 测试环境是否启用登录验证码(该环境开放 `/api/captcha.jpg`)。
static const bool enableCaptcha = true;
// 测试环境密钥 / 数据库连接等敏感配置在此独立维护,
// 不得出现任何生产环境标识或引用。
}

145
lib/data/region_data.dart Normal file
View File

@@ -0,0 +1,145 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
/// 省市区节点(省 → 市 → 区三级树)。
///
/// 对应 `assets/data/MCityPiker/province.json` 结构:
/// `{code, name, sub:[{code, name, sub:[{code, name}]}]}`。
class RegionNode {
const RegionNode({
required this.code,
required this.name,
this.sub = const [],
});
/// 行政区划代码(省 6 位、市 6 位、区 6 位)。
final String code;
/// 行政区划名称(如「北京市」「朝阳区」)。
final String name;
/// 下一级子节点(省含市,市含区,区无 sub)。
final List<RegionNode> sub;
factory RegionNode.fromJson(Map<String, dynamic> json) {
final subRaw = json['sub'];
final List<RegionNode> children = subRaw is List
? subRaw
.whereType<Map<String, dynamic>>()
.map(RegionNode.fromJson)
.toList()
: const <RegionNode>[];
return RegionNode(
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
sub: children,
);
}
}
/// 省市区数据源。
///
/// 统一由 [RegionData.load] 懒加载 `province.json` 并缓存为
/// `List<RegionNode>` 树;组件禁止直接 `rootBundle` 读取,避免重复解析
/// (文件约 284KB)。
class RegionData {
RegionData._();
/// 全局单例。
static final RegionData instance = RegionData._();
/// 是否已加载。
bool get isLoaded => _loaded;
bool _loaded = false;
/// 省级节点树(已缓存)。
List<RegionNode> get provinces => List.unmodifiable(_tree);
final List<RegionNode> _tree = [];
/// 省份名称缓存(code → 名称),用于反查。
final Map<String, String> _nameByCode = {};
/// 懒加载省市区数据。重复调用安全(仅首次解析)。
Future<void> load() async {
if (_loaded) return;
final raw = await rootBundle
.loadString('assets/data/MCityPiker/province.json');
final decoded = jsonDecode(raw);
final list = decoded is List
? decoded.whereType<Map<String, dynamic>>().map(RegionNode.fromJson)
: const <RegionNode>[];
_tree
..clear()
..addAll(list);
_buildNameIndex(_tree);
_loaded = true;
}
/// 构建 code → 名称 反查索引(省/市/区全部登记)。
void _buildNameIndex(List<RegionNode> nodes) {
for (final node in nodes) {
if (node.code.isNotEmpty) {
_nameByCode[node.code] = node.name;
}
if (node.sub.isNotEmpty) {
_buildNameIndex(node.sub);
}
}
}
/// 获取某省下的市级节点;[provinceCode] 为空或不存在时返回空列表。
List<RegionNode> citiesOf(String provinceCode) {
for (final province in _tree) {
if (province.code == provinceCode) return province.sub;
}
return const [];
}
/// 获取某市下的区级节点;
/// [provinceCode]/[cityCode] 任一为空或不存在时返回空列表。
List<RegionNode> districtsOf(String provinceCode, String cityCode) {
for (final province in _tree) {
if (province.code != provinceCode) continue;
for (final city in province.sub) {
if (city.code == cityCode) return city.sub;
}
}
return const [];
}
/// 按 code 反查名称(省/市/区通用)。
String nameOf(String code) => _nameByCode[code] ?? '';
/// 由历历史纯文本地址尽力映射省市区。
///
/// 当前实现:遍历所有省份/城市/区名称,若 [address] 包含该名称,
/// 则按「省 → 市 → 区」优先级取最长命中,返回
/// `[provinceCode, cityCode, districtCode]`;匹配不到的维度为空字符串。
/// 由于区名可能重复,此映射仅作尽力回显,无法保证 100% 准确。
List<String> tryMapAddress(String address) {
final text = address.trim();
if (text.isEmpty) return const ['', '', ''];
String provinceCode = '';
String cityCode = '';
String districtCode = '';
for (final province in _tree) {
if (text.contains(province.name)) {
provinceCode = province.code;
for (final city in province.sub) {
if (text.contains(city.name)) {
cityCode = city.code;
for (final district in city.sub) {
if (text.contains(district.name)) {
districtCode = district.code;
}
}
}
}
}
}
return [provinceCode, cityCode, districtCode];
}
}

315
lib/main.dart Normal file
View File

@@ -0,0 +1,315 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/router.dart';
import 'app/theme.dart';
import 'providers/locale_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/display_provider.dart';
import 'utils/constants.dart';
import 'utils/credential_storage.dart';
import 'utils/i10n.dart';
import 'utils/store_local_cache.dart';
import 'config/prod_guard.dart';
/// 应用入口。
///
/// 三端(Android / iOS / HarmonyOS)共用同一套 lib/ 代码,
/// 通过 [ProviderScope] 注入全局状态,使用 go_router 进行声明式路由。
/// 根组件 key;重试时更换以强制整棵树重建。
GlobalKey _appRootKey = GlobalKey();
void main() {
// 全局 ErrorWidget:任何 widget 在 build/layout/paint 阶段抛异常时,
// 直接显示可读的红色错误页,而不是 release 模式默认的灰色错误块(静默灰屏)。
ErrorWidget.builder = (FlutterErrorDetails details) {
return _ErrorWidgetScreen(
message: details.exceptionAsString(),
stack: details.stack,
);
};
// 框架级异常兜底:记录并写入 _fatalError,供根组件在 build 时渲染错误页。
// 注意:不再调用 FlutterError.presentError,避免默认灰屏覆盖自定义错误页。
FlutterError.onError = (details) {
_reportFatal(details.exceptionAsString(), details.stack);
};
// 异步/Zone 异常兜底。
PlatformDispatcher.instance.onError = (error, stack) {
_reportFatal(error.toString(), stack);
return true;
};
runZonedGuarded<void>(
() async {
WidgetsFlutterBinding.ensureInitialized();
// 启用 Edge-to-Edge 全屏:内容延伸至状态栏与导航栏区域,
// 避免手机四周出现黑边;同时保持系统手势/状态栏可见。
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// 状态栏 / 导航栏图标亮度跟随系统明暗(修复此前硬编码 Brightness.light
// 导致浅色模式下图标不可见的 bug)。
final isDark =
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
),
);
// 预初始化凭据持久化(记住密码 / 免登录恢复依赖 SharedPreferences 同步读取)。
await CredentialStorage.init();
// 生产环境强制守卫:启动即校验当前后端非生产,命中则失败退出并阻断启动。
ProductionGuard.enforce();
// 预初始化本地缓存(语言、主题等配置需要同步读取)。
// 即便失败也用默认值兜底,不让启动崩溃。
StoreLocalCache.instance.initialize().catchError((_) {
// 忽略:SharedPreferences 不可用时退化为内存态。
});
// 应用已保存的显示方向偏好(跟随系统 / 竖屏 / 横屏),
// 确保首帧即按用户设置的方向渲染,不闪一下系统默认朝向。
final savedOrientation = await loadSavedOrientation();
applyDisplayOrientation(savedOrientation);
_launchApp();
},
(error, stack) {
_reportFatal(error.toString(), stack);
},
);
}
/// 启动根组件(重试时再次调用即可重建整棵树)。
void _launchApp() {
runApp(
ProviderScope(
key: _appRootKey,
child: const OpsConsoleApp(),
),
);
}
/// 全局致命错误信息;非空时 [OpsConsoleApp] 渲染错误页而非正常界面。
String? _fatalError;
/// 记录致命错误并写入全局变量,供 [OpsConsoleApp] 在 build 时渲染错误页。
void _reportFatal(Object error, StackTrace? stack) {
if (_fatalError == null) {
_fatalError =
'$error\n\n${stack?.toString().split('\n').take(8).join('\n') ?? ''}';
}
debugPrint('FATAL: $error');
debugPrint('STACK: $stack');
}
/// 清空致命错误并重启根组件(错误页「重试」按钮调用)。
void _retryApp() {
_fatalError = null;
_appRootKey = GlobalKey();
_launchApp();
}
/// 运维管理台根组件。
class OpsConsoleApp extends ConsumerWidget {
const OpsConsoleApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 全局致命错误:直接渲染可读错误页,避免 release 模式静默灰屏。
if (_fatalError != null) {
return MaterialApp(
title: AppConstants.appName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: _FatalErrorScreen(error: _fatalError!),
);
}
final router = ref.watch(appRouterProvider);
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
return MaterialApp.router(
title: AppConstants.appName,
// 应用级名称,与 AndroidManifest/iOS Info.plist/鸿蒙配置保持一致
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
// 默认深色主题,可在“设置”页切换
themeMode: themeMode,
locale: appLocale.locale,
supportedLocales: const [
Locale('zh', 'CN'),
Locale('en', 'US'),
],
localizationsDelegates: const [
// 必须包含 Flutter 默认的本地化委托,否则 MaterialLocalizations
// 不会被注册,所有 TextField/InputDecoration 会因取 null 而崩溃。
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
_AppLocaleDelegate(),
],
routerConfig: router,
);
}
}
/// [AppLocale] 的轻量 [LocalizationsDelegate],
/// 使 [AppLocalizations.of] 能按当前 locale 取到对应文案。
class _AppLocaleDelegate extends LocalizationsDelegate<AppLocale> {
const _AppLocaleDelegate();
@override
bool isSupported(Locale locale) =>
locale.languageCode == 'zh' || locale.languageCode == 'en';
@override
Future<AppLocale> load(Locale locale) async {
return locale.languageCode == 'en' ? AppLocale.en : AppLocale.zh;
}
@override
bool shouldReload(covariant LocalizationsDelegate<AppLocale> old) => false;
}
/// 全局致命错误展示页。
///
/// 当 [FlutterError.onError] / [PlatformDispatcher.onError] / Zone 异常捕获到
/// 未处理异常时,用可读的红色错误页替代默认的灰/白屏,便于真机直接看到根因。
class _FatalErrorScreen extends StatelessWidget {
const _FatalErrorScreen({required this.error});
final String error;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.red.shade50,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
const Text(
'应用启动时发生错误',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(height: 8),
const Text(
'请将下方错误信息反馈给开发人员:',
style: TextStyle(color: Colors.black87),
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: SelectableText(
error,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.black87,
),
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _retryApp,
child: const Text('重试'),
),
],
),
),
),
);
}
}
/// 全局渲染异常兜底页([ErrorWidget.builder] 使用)。
///
/// 与 [_FatalErrorScreen] 不同,此组件不依赖 MaterialApp/Theme(此时 app 可能
/// 尚未建立),是一个完全自包含、硬编码红色背景的可读错误页,确保任何 widget
/// 渲染异常都能暴露文字而非静默灰屏。
class _ErrorWidgetScreen extends StatelessWidget {
const _ErrorWidgetScreen({required this.message, this.stack});
final String message;
final StackTrace? stack;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.red.shade50,
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.red),
const SizedBox(height: 12),
const Text(
'渲染异常(Widget Error)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(height: 8),
const Text(
'请将下方文字反馈给开发人员:',
style: TextStyle(color: Colors.black87),
),
const SizedBox(height: 8),
SelectableText(
message,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.black87,
),
),
if (stack != null) ...[
const SizedBox(height: 8),
SelectableText(
stack.toString(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Colors.black54,
),
),
],
],
),
),
),
);
}
}

95
lib/models/alert.dart Normal file
View File

@@ -0,0 +1,95 @@
/// 告警级别枚举。
enum AlertLevel {
/// 严重。
critical,
/// 警告。
warning,
/// 提示。
info;
/// 级别中文名称。
String get label {
switch (this) {
case AlertLevel.critical:
return '严重';
case AlertLevel.warning:
return '警告';
case AlertLevel.info:
return '提示';
}
}
/// 从字符串解析级别。
static AlertLevel fromString(String value) {
return AlertLevel.values.firstWhere(
(level) => level.name == value,
orElse: () => AlertLevel.info,
);
}
}
/// 告警模型。
class Alert {
const Alert({
required this.id,
required this.deviceId,
required this.deviceName,
required this.title,
required this.message,
required this.level,
required this.occurredAt,
required this.acknowledged,
});
/// 告警唯一标识。
final String id;
/// 关联设备 ID。
final String deviceId;
/// 关联设备名称。
final String deviceName;
/// 告警标题。
final String title;
/// 告警详情。
final String message;
/// 告警级别。
final AlertLevel level;
/// 发生时间。
final DateTime occurredAt;
/// 是否已确认。
final bool acknowledged;
factory Alert.fromJson(Map<String, dynamic> json) {
return Alert(
id: json['id'] as String,
deviceId: json['deviceId'] as String,
deviceName: json['deviceName'] as String,
title: json['title'] as String,
message: json['message'] as String,
level: AlertLevel.fromString(json['level'] as String),
occurredAt: DateTime.parse(json['occurredAt'] as String),
acknowledged: json['acknowledged'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'deviceId': deviceId,
'deviceName': deviceName,
'title': title,
'message': message,
'level': level.name,
'occurredAt': occurredAt.toIso8601String(),
'acknowledged': acknowledged,
};
}
}

View File

@@ -0,0 +1,95 @@
/// APP 版本模型。
///
/// 对应后端 /device/orgGroupAppVersion/* 接口的版本实体。
class AppVersion {
const AppVersion({
required this.id,
required this.orgId,
required this.groupId,
required this.appName,
required this.packageName,
required this.versionCode,
required this.versionName,
required this.apkUrl,
required this.status,
required this.createTime,
});
/// 版本 ID。
final String id;
/// 组织 ID。
final String orgId;
/// 设备分组 ID。
final String groupId;
/// 应用名称。
final String appName;
/// 包名。
final String packageName;
/// 版本号(整数)。
final int versionCode;
/// 版本名。
final String versionName;
/// APK 下载地址。
final String apkUrl;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory AppVersion.fromJson(Map<String, dynamic> json) {
return AppVersion(
id: json['id']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
groupId: json['groupId']?.toString() ?? '',
appName: json['appName']?.toString() ?? '',
// 包名:后端可能返回 packageName / package_name(与既有 AppVersionPage 一致)。
packageName: json['packageName']?.toString() ??
json['package_name']?.toString() ??
'',
versionCode: int.tryParse(json['versionCode']?.toString() ?? '0') ?? 0,
versionName: json['versionName']?.toString() ?? '',
// 下载地址:对齐 Web 端 normalizeAppVersion(downloadUrl/download_url),
// 同时兼容移动端既有 AppVersionPage 使用的 apkUrl——后端不同部署字段名不一,
// 取首个非空值,避免「立即更新」因 url 为空而失效。
apkUrl: json['apkUrl']?.toString() ??
json['downloadUrl']?.toString() ??
json['download_url']?.toString() ??
'',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'orgId': orgId,
'groupId': groupId,
'appName': appName,
'packageName': packageName,
'versionCode': versionCode,
'versionName': versionName,
'apkUrl': apkUrl,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}

622
lib/models/device.dart Normal file
View File

@@ -0,0 +1,622 @@
/// 设备状态枚举。
enum DeviceStatus {
/// 在线。
online,
/// 离线。
offline,
/// 告警(指标异常)。
warning;
/// 状态中文名称。
String get label {
switch (this) {
case DeviceStatus.online:
return '在线';
case DeviceStatus.offline:
return '离线';
case DeviceStatus.warning:
return '告警';
}
}
/// 从后端状态字符串解析。
///
/// 规则(与「爱视后台」约定一致):`offline` → 离线、`warning` → 告警、
/// 其余(含 `online` 与未知值)→ 在线。
static DeviceStatus fromString(String? value) {
final status = (value ?? '').toLowerCase();
if (status == 'offline') {
return DeviceStatus.offline;
}
if (status == 'warning') {
return DeviceStatus.warning;
}
return DeviceStatus.online;
}
}
/// 设备指令常量(对应 /device/command/sendByMac|sendByOrg 的 command 字段)。
class DeviceCommand {
DeviceCommand._();
static const String powerOff = 'powerOff';
static const String reboot = 'reboot';
static const String screenshot = 'screenshot';
static const String themeUpdate = 'themeUpdate';
static const String versionUpdate = 'versionUpdate';
static const String marqueeUpdate = 'marqueeUpdate';
static const String weatherUpdate = 'weatherUpdate';
static const String mediaZoneVol = 'mediaZoneVol';
static const String defaultVol = 'defaultVol';
static const String themeClear = 'themeClear';
static const String cacheClear = 'cacheClear';
static const String storeExpire = 'storeExpire';
static const String storeDisable = 'storeDisable';
static const String passwordUpdate = 'passwordUpdate';
static const String worldAd = 'worldAd';
}
/// 设备运行指标(CPU / 内存 / 磁盘 / 网络)。
///
/// 注意:真实后端不提供该类指标,仅 mock 模式有值;
/// 由 [Device.hasMetrics] 标识当前数据是否包含指标。
class DeviceMetrics {
const DeviceMetrics({
required this.cpuUsage,
required this.memoryUsage,
required this.diskUsage,
required this.networkRxKbps,
required this.networkTxKbps,
});
/// 全零指标(真实模式下的占位值,配合 hasMetrics=false 使用)。
const DeviceMetrics.zero()
: cpuUsage = 0,
memoryUsage = 0,
diskUsage = 0,
networkRxKbps = 0,
networkTxKbps = 0;
/// CPU 使用率(0.0 ~ 1.0)。
final double cpuUsage;
/// 内存使用率(0.0 ~ 1.0)。
final double memoryUsage;
/// 磁盘使用率(0.0 ~ 1.0)。
final double diskUsage;
/// 网络下行速率(KB/s)。
final double networkRxKbps;
/// 网络上行速率(KB/s)。
final double networkTxKbps;
/// 从后端 JSON 解析运行指标。
///
/// 兼容嵌套 `metrics` 对象与顶层扁平字段两种形态,并对百分比做 0~100→0~1
/// 归一化;任意字段缺失时安全回退为 0,保证 UI 不崩溃。
factory DeviceMetrics.fromJson(Map<String, dynamic> json) {
return DeviceMetrics(
cpuUsage: _normalizePercent(
json['cpuUsage'] ?? json['cpu_usage'] ?? json['cpu'],
),
memoryUsage: _normalizePercent(
json['memoryUsage'] ??
json['memory_usage'] ??
json['mem'] ??
json['memUsage'] ??
json['memory'],
),
diskUsage: _normalizePercent(
json['diskUsage'] ?? json['disk_usage'] ?? json['disk'],
),
networkRxKbps: _toDouble(
json['networkRxKbps'] ?? json['rxKbps'] ?? json['networkRx'],
),
networkTxKbps: _toDouble(
json['networkTxKbps'] ?? json['txKbps'] ?? json['networkTx'],
),
);
}
Map<String, dynamic> toJson() {
return {
'cpuUsage': cpuUsage,
'memoryUsage': memoryUsage,
'diskUsage': diskUsage,
'networkRxKbps': networkRxKbps,
'networkTxKbps': networkTxKbps,
};
}
}
/// 将后端返回的百分数字段归一化为 0.0~1.0。
///
/// 兼容后端可能返回 0~100(如 87)或 0~1(如 0.87)两种形态,避免 UI 进度条
/// 与百分比文本越界。
double _normalizePercent(dynamic value) {
if (value == null) return 0;
final n = value is num
? value.toDouble()
: double.tryParse(value.toString());
if (n == null) return 0;
return n > 1 ? n / 100 : n;
}
/// 将动态值安全转换为 double,无法解析时返回 0。
double _toDouble(dynamic value) {
if (value == null) return 0;
return (value is num
? value.toDouble()
: double.tryParse(value.toString())) ??
0;
}
/// 设备模型。
///
/// 字段对齐真实后端 DeviceInfo(id / mac / name / deviceSn / deviceUid /
/// status / groupId / lastSyncTime / createTime),并保留 UI 展示兼容字段
/// (ip / location / os / uptimeSeconds / metrics / storeId),真实模式下取默认值。
class Device {
const Device({
required this.id,
required this.mac,
required this.name,
required this.model,
required this.deviceSn,
required this.deviceUid,
required this.status,
required this.groupId,
this.storeId = '',
required this.lastSyncTime,
required this.createTime,
this.ip = '',
this.location = '',
this.storeName = '',
this.os = '',
this.roomNo = '',
this.uptimeSeconds = 0,
this.hasMetrics = false,
this.metrics = const DeviceMetrics.zero(),
});
/// 设备唯一标识。
final String id;
/// 设备 MAC 地址(指令下发的目标标识)。
final String mac;
/// 设备名称(后端可能返回 MAC、SN 或自定义别名)。
final String name;
/// 设备型号(如 rk3566_r、p321)。
///
/// 优先从后端 model/modelName/deviceModel/productModel 等字段解析;
/// 缺失时降级为 [name](若 name 是 MAC 地址则跳过),再降级为 [deviceSn],
/// 最终兜底 [mac],确保不会把 MAC 误当成型号展示。
final String model;
/// 设备序列号。
final String deviceSn;
/// 设备 UID。
final String deviceUid;
/// 当前状态。
final DeviceStatus status;
/// 分组 ID。
final String groupId;
/// 所属门店 ID(用于拉取设备分组列表;后端命名多样,按常见别名解析)。
final String storeId;
/// 房号(房间号,如「802」「A栋301」)。
final String roomNo;
/// 最近一次同步时间。
final DateTime? lastSyncTime;
/// 创建时间。
final DateTime? createTime;
/// IP 地址(真实后端无此字段,默认回退为 MAC)。
final String ip;
/// 机房 / 位置(真实后端无此字段,默认为空)。
final String location;
/// 所属门店名称(真实后端可能返回 storeName / orgName)。
final String storeName;
/// 操作系统描述(真实后端无此字段,默认为空)。
final String os;
/// 已连续运行时长(秒)(真实后端无此字段,默认为 0)。
final int uptimeSeconds;
/// 是否包含运行指标(仅 mock 模式为 true)。
final bool hasMetrics;
/// 运行指标。
final DeviceMetrics metrics;
/// 创建副本并覆盖指定字段。
Device copyWith({
String? id,
String? mac,
String? name,
String? model,
String? deviceSn,
String? deviceUid,
DeviceStatus? status,
String? groupId,
String? storeId,
String? roomNo,
DateTime? lastSyncTime,
DateTime? createTime,
String? ip,
String? location,
String? storeName,
String? os,
int? uptimeSeconds,
bool? hasMetrics,
DeviceMetrics? metrics,
}) {
return Device(
id: id ?? this.id,
mac: mac ?? this.mac,
name: name ?? this.name,
model: model ?? this.model,
deviceSn: deviceSn ?? this.deviceSn,
deviceUid: deviceUid ?? this.deviceUid,
status: status ?? this.status,
groupId: groupId ?? this.groupId,
storeId: storeId ?? this.storeId,
roomNo: roomNo ?? this.roomNo,
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
createTime: createTime ?? this.createTime,
ip: ip ?? this.ip,
location: location ?? this.location,
storeName: storeName ?? this.storeName,
os: os ?? this.os,
uptimeSeconds: uptimeSeconds ?? this.uptimeSeconds,
hasMetrics: hasMetrics ?? this.hasMetrics,
metrics: metrics ?? this.metrics,
);
}
/// 设备标识:统一为「MAC / 房号」组合。
/// 优先 MAC + 房号;任一缺失则只用存在的那项;两者皆空时逐级降级到
/// name → model → mac,确保永不展示空白。
String get macRoomLabel {
final m = mac.trim();
final r = roomNo.trim();
if (m.isNotEmpty && r.isNotEmpty) return '$m / $r';
if (m.isNotEmpty) return m;
if (r.isNotEmpty) return r;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
/// 仅展示 MAC 地址(房号改为独立「房号:XXX」列示,不再用「/」把两者粘在一起)。
/// MAC 为空时逐级降级到 name → model → mac,确保标题不空白。
String get macLabel {
final m = mac.trim();
if (m.isNotEmpty) return m;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
factory Device.fromJson(Map<String, dynamic> json) {
// 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。
final Map<String, dynamic> data = _unwrapDeviceJson(json);
final mac = data['mac']?.toString() ?? '';
final metricsJson = data['metrics'];
// 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage /
// cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。
final Map<String, dynamic> mergedMetrics = <String, dynamic>{};
if (metricsJson is Map<String, dynamic>) {
mergedMetrics.addAll(metricsJson);
}
final flatCpu = _firstNum(data, [
'cpuUsage',
'cpu_usage',
'cpu',
'cpuPercent',
'cpu_percent',
'cpuRate',
'cpu_rate',
]);
if (flatCpu != null) {
mergedMetrics.putIfAbsent('cpuUsage', () => flatCpu);
}
final flatMem = _firstNum(data, [
'memoryUsage',
'memory_usage',
'mem',
'memUsage',
'memory',
'memoryPercent',
'mem_percent',
]);
if (flatMem != null) {
mergedMetrics.putIfAbsent('memoryUsage', () => flatMem);
}
final flatDisk = _firstNum(data, [
'diskUsage',
'disk_usage',
'disk',
'diskPercent',
'disk_percent',
]);
if (flatDisk != null) {
mergedMetrics.putIfAbsent('diskUsage', () => flatDisk);
}
// 爱视后台列表字段名为 deviceName / device_name,name 字段常缺失或存 MAC/IP。
final name = data['deviceName']?.toString() ??
data['device_name']?.toString() ??
data['name']?.toString() ??
'';
// IP 可能落在 ip / ipAddress / ip_address / deviceIp / device_ip 等多个字段。
final ip = data['ip']?.toString() ??
data['ipAddress']?.toString() ??
data['ip_address']?.toString() ??
data['deviceIp']?.toString() ??
data['device_ip']?.toString() ??
'';
final deviceSn = data['deviceSn']?.toString() ?? '';
// 型号字段后端命名多样,按常见别名逐级解析;解析不到时按规则降级。
final rawModel = _firstString(data, [
'model',
'modelName',
'model_name',
'deviceModel',
'device_model',
'productModel',
'product_model',
'productName',
'product_name',
'board',
'hardware',
'brand',
'deviceType',
'device_type',
'type',
]) ??
'';
final model = _resolveModel(rawModel, name, deviceSn, mac);
return Device(
id: data['id']?.toString() ?? '',
mac: mac,
name: name,
model: model,
deviceSn: deviceSn,
deviceUid: data['deviceUid']?.toString() ?? '',
status: DeviceStatus.fromString(data['status']?.toString()),
groupId: data['groupId']?.toString() ?? '',
storeId: _resolveStoreId(data),
// Web 端设备列表把最近心跳时间存为 heartbeatTime / heartbeat_time,
// 详情接口多为 lastSyncTime;兼容两种命名,避免最后活跃时间解析为空。
lastSyncTime: _parseDateTime(
data['lastSyncTime'] ??
data['last_sync_time'] ??
data['heartbeatTime'] ??
data['heartbeat_time'] ??
data['lastHeartbeat'] ??
data['last_heartbeat'],
),
createTime: _parseDateTime(data['createTime']),
// UI 兼容字段:真实数据缺失时回退。
ip: ip,
location: _firstString(data, [
'location',
'locationDesc',
'loc',
'position',
'place',
'area',
'region',
]) ??
'',
storeName: data['storeName']?.toString() ??
data['orgName']?.toString() ??
data['store']?.toString() ?? '',
os: _firstString(data, [
'os',
'osName',
'system',
'systemVersion',
'osVersion',
'platform',
'osInfo',
'sysVersion',
]) ??
'',
// 房号/房间号字段后端命名极不统一,按常见别名逐级回退。
roomNo: _firstString(data, [
'roomNo',
'room',
'roomNoStr',
'roomNumber',
'room_number',
'roomNum',
'roomNumStr',
'roomId',
'room_id',
'roomName',
'houseNumber',
'house_number',
'houseNo',
'houseNoStr',
'roomCode',
'room_code',
]) ??
'',
uptimeSeconds: _firstNum(data, [
'uptimeSeconds',
'uptime',
'uptimeSec',
'uptimeSecs',
'runTime',
'runningTime',
'onlineTime',
'bootTime',
'upTime',
'up_time',
])?.toInt() ?? 0,
hasMetrics: mergedMetrics.isNotEmpty,
metrics: mergedMetrics.isNotEmpty
? DeviceMetrics.fromJson(mergedMetrics)
: const DeviceMetrics.zero(),
);
}
/// 兜底解包设备 JSON。
///
/// 后端 /device/info/info/{id} 详情接口可能并未把设备对象直接作为响应体,
/// 而是再包一层 `{device|info|data: {...}}`;若顶层没有 `id` 但某嵌套字段
/// 是含 `id` 的 Map,则返回该嵌套 Map,否则原样返回。
/// 这样可避免详情页所有遥测字段因解析不到而显示为空。
static Map<String, dynamic> _unwrapDeviceJson(Map<String, dynamic> json) {
if (json.containsKey('id')) return json;
for (final key in const ['device', 'info', 'data', 'result', 'record']) {
final nested = json[key];
if (nested is Map<String, dynamic> &&
(nested.containsKey('id') || nested.containsKey('mac'))) {
return nested;
}
}
return json;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空数值。
///
/// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回,
/// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。
static num? _firstNum(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
if (value is num) return value;
final n = double.tryParse(value.toString());
if (n != null) return n;
}
return null;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空字符串值。
static String? _firstString(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
final text = value.toString().trim();
if (text.isNotEmpty) return text;
}
return null;
}
/// 解析设备所属门店 ID。
///
/// 后端字段命名极不统一:可能直接在顶层返回 `storeId` / `orgId`,也可能嵌套在
/// `store` / `storeInfo` / `org` 对象中。本方法先尝试顶层字段,再尝试嵌套对象
/// 中的 `id` / `orgId` / `storeId`,尽力避免分组下拉因取不到门店 ID 而无法加载。
static String _resolveStoreId(Map<String, dynamic> json) {
final topLevel = _firstString(json, [
'storeId',
'storeOrgId',
'orgId',
'store_id',
'org_id',
'storeIdStr',
]);
if (topLevel != null && topLevel.isNotEmpty) return topLevel;
for (final nestedKey in const ['store', 'storeInfo', 'org', 'organization']) {
final nested = json[nestedKey];
if (nested is Map<String, dynamic>) {
final id = _firstString(nested, [
'id',
'orgId',
'storeId',
'store_id',
'org_id',
]);
if (id != null && id.isNotEmpty) return id;
}
}
return '';
}
/// 解析最终展示的型号文本。
///
/// 优先级:
/// 1. 后端明确返回的 [rawModel]
/// 2. [name](仅当非空且不是 MAC 地址时)
/// 3. [deviceSn]
/// 4. [mac](最终兜底,避免空标题)
static String _resolveModel(
String rawModel,
String name,
String deviceSn,
String mac,
) {
if (rawModel.isNotEmpty) return rawModel;
if (name.isNotEmpty && !_isMacAddress(name)) return name;
if (deviceSn.isNotEmpty) return deviceSn;
return mac;
}
/// 判断 [value] 是否为 MAC 地址格式(xx:xx:xx:xx:xx:xx 或 xx-xx-xx-xx-xx-xx)。
static bool _isMacAddress(String value) {
return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$')
.hasMatch(value.trim());
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) {
return null;
}
final text = value.toString().trim();
if (text.isEmpty) {
return null;
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'mac': mac,
'name': name,
'model': model,
'deviceSn': deviceSn,
'deviceUid': deviceUid,
'status': status.name,
'groupId': groupId,
'storeId': storeId,
'lastSyncTime': lastSyncTime?.toIso8601String(),
'createTime': createTime?.toIso8601String(),
'ip': ip,
'location': location,
'storeName': storeName,
'os': os,
'roomId': roomNo,
'uptimeSeconds': uptimeSeconds,
'metrics': hasMetrics ? metrics.toJson() : null,
};
}
}

View File

@@ -0,0 +1,281 @@
/// 介绍页分类模型。
///
/// 对应后端 /company/introCategory/* 接口。
///
/// 后端实际字段:
/// - id, orgId, orgName, categoryType, name, subCategories,
/// createTime, updateTime
/// 注:后端分类列表不返回 sort/status,兼容处理为默认值。
class IntroCategory {
const IntroCategory({
required this.id,
required this.orgId,
required this.orgName,
required this.categoryType,
required this.name,
required this.sort,
required this.status,
required this.createTime,
this.subCategories,
});
/// 分类 ID。
final String id;
/// 所属门店/组织 ID。
final String orgId;
/// 所属门店/组织名称(用于门店筛选展示)。
final String orgName;
/// 分类类型编码(如 gongsi/gsjs/hotel 等)。
final String categoryType;
/// 分类名称。
final String name;
/// 排序号(后端可能缺失,缺省 0)。
final int sort;
/// 状态(1=启用/正常,0=禁用),与 Web 后端保持一致。
final int status;
/// 创建时间。
final DateTime? createTime;
/// 后端可能嵌入的子分类原始数据(暂不直接解析,用于后续扩展)。
final List<dynamic>? subCategories;
factory IntroCategory.fromJson(Map<String, dynamic> json) {
return IntroCategory(
id: json['id']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
orgName: json['orgName']?.toString() ?? '',
categoryType: json['categoryType']?.toString() ?? '',
name: json['name']?.toString() ?? '',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: _parseStatus(json['status']),
createTime: _parseDateTime(json['createTime']),
subCategories: json['subCategories'] is List
? List<dynamic>.from(json['subCategories'] as List)
: null,
);
}
/// 与 Web 后端保持一致:1=启用/正常,0=禁用;缺失/null 视为启用。
static int _parseStatus(dynamic value) {
if (value == null) return 1;
if (value is bool) return value ? 1 : 0;
final text = value.toString().trim().toLowerCase();
if (text == '1' || text == 'active' || text == '正常' || text == '启用' ||
text == 'enabled' || text == 'true') {
return 1;
}
if (text == '0' ||
text == 'inactive' ||
text == '禁用' ||
text == '停用' ||
text == 'disabled' ||
text == 'false') {
return 0;
}
return 1;
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'orgId': orgId,
'orgName': orgName,
'categoryType': categoryType,
'name': name,
'sort': sort,
'status': status,
'createTime': createTime?.toIso8601String(),
'subCategories': subCategories,
};
}
}
/// 介绍页子分类模型。
///
/// 对应后端 /company/introSubcategory/* 接口。
///
/// 后端实际字段:
/// - id, categoryId, categoryType, mediaType, mediaUrl,
/// title, description, status, sortOrder, contents, createTime, updateTime
class IntroSubcategory {
const IntroSubcategory({
required this.id,
required this.categoryId,
required this.categoryType,
required this.title,
required this.description,
required this.mediaType,
required this.mediaUrl,
required this.sort,
required this.status,
this.contents,
});
/// 子分类 ID。
final String id;
/// 所属分类 ID。
final String categoryId;
/// 分类类型编码。
final String categoryType;
/// 子分类标题(后端用 title 而非 name)。
final String title;
/// 描述/简介。
final String description;
/// 媒体类型:image / video。
final String mediaType;
/// 媒体地址。
final String mediaUrl;
/// 排序号(后端字段 sortOrder)。
final int sort;
/// 状态(0=正常 1=禁用)。
final int status;
/// 后端可能嵌入的内容原始数据。
final List<dynamic>? contents;
factory IntroSubcategory.fromJson(Map<String, dynamic> json) {
return IntroSubcategory(
id: json['id']?.toString() ?? '',
categoryId: json['categoryId']?.toString() ?? '',
categoryType: json['categoryType']?.toString() ?? '',
title: json['title']?.toString() ??
json['name']?.toString() ??
json['subcategoryTitle']?.toString() ??
'',
description: json['description']?.toString() ?? '',
mediaType: json['mediaType']?.toString() ?? '',
mediaUrl: json['mediaUrl']?.toString() ?? '',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: IntroCategory._parseStatus(json['status']),
contents: json['contents'] is List
? List<dynamic>.from(json['contents'] as List)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryId': categoryId,
'categoryType': categoryType,
'title': title,
'description': description,
'mediaType': mediaType,
'mediaUrl': mediaUrl,
'sortOrder': sort,
'status': status,
'contents': contents,
};
}
}
/// 介绍页详情模型。
///
/// 对应后端 /company/introDetail/* 接口。
///
/// 后端实际字段:
/// - id, subcategoryId, subcategoryTitle, mediaType, mediaUrl,
/// title, description, status, sortOrder, createTime, updateTime
class IntroDetail {
const IntroDetail({
required this.id,
required this.subcategoryId,
required this.title,
required this.description,
required this.mediaType,
required this.mediaUrl,
required this.sort,
required this.status,
});
/// 详情 ID。
final String id;
/// 所属子分类 ID。
final String subcategoryId;
/// 标题。
final String title;
/// 描述/内容。
final String description;
/// 媒体类型:image / video。
final String mediaType;
/// 媒体地址。
final String mediaUrl;
/// 排序号(后端字段 sortOrder)。
final int sort;
/// 状态(0=正常 1=禁用)。
final int status;
factory IntroDetail.fromJson(Map<String, dynamic> json) {
return IntroDetail(
id: json['id']?.toString() ?? '',
subcategoryId: json['subcategoryId']?.toString() ?? '',
title: json['title']?.toString() ??
json['name']?.toString() ??
json['subcategoryTitle']?.toString() ??
'',
description: json['description']?.toString() ??
json['content']?.toString() ??
'',
mediaType: json['mediaType']?.toString() ?? '',
mediaUrl: json['mediaUrl']?.toString() ??
json['imageUrl']?.toString() ??
json['videoUrl']?.toString() ??
'',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: IntroCategory._parseStatus(json['status']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'subcategoryId': subcategoryId,
'title': title,
'description': description,
'mediaType': mediaType,
'mediaUrl': mediaUrl,
'sortOrder': sort,
'status': status,
};
}
}

View File

@@ -0,0 +1,132 @@
/// 滚动字幕配置参数:速度档位与级别。
///
/// 与 Web 后台 `/views/modules/theme/scrolling-text.data.ts` 保持一致,
/// 移动端仅展示档位文案,落库仍是原始数值,便于与 Web 端互相识别。
///
/// 速度(scrollSpeed,单位 ms/像素,数值越小滚动越快):
/// - 慢 = 200
/// - 较慢 = 100
/// - 中 = 50
/// - 较快 = 30
/// - 快 = 10
///
/// 级别(level):
/// - 危险 = 50
/// - 紧急 = 40
/// - 普通 = 20
class MarqueeConfig {
const MarqueeConfig({
this.speed = MarqueeSpeed.medium,
this.grade = MarqueeGrade.normal,
});
final MarqueeSpeed speed;
final MarqueeGrade grade;
/// 将任意历史 speed 值吸附到最近档位(兼容 Web 端精确值)。
static MarqueeSpeed speedFromValue(int? value) {
if (value == null) return MarqueeSpeed.medium;
final target = MarqueeSpeed.values.fold<MarqueeSpeed>(
MarqueeSpeed.values.first,
(closest, current) {
final diffClosest = (closest.value - value).abs();
final diffCurrent = (current.value - value).abs();
return diffCurrent < diffClosest ? current : closest;
},
);
return target;
}
/// 将任意历史 level 值吸附到最近级别。
static MarqueeGrade gradeFromValue(int? value) {
if (value == null) return MarqueeGrade.normal;
final target = MarqueeGrade.values.fold<MarqueeGrade>(
MarqueeGrade.values.first,
(closest, current) {
final diffClosest = (closest.value - value).abs();
final diffCurrent = (current.value - value).abs();
return diffCurrent < diffClosest ? current : closest;
},
);
return target;
}
MarqueeConfig setSpeed(MarqueeSpeed speed) =>
MarqueeConfig(speed: speed, grade: grade);
MarqueeConfig setGrade(MarqueeGrade grade) =>
MarqueeConfig(speed: speed, grade: grade);
MarqueeConfig copyWith({
MarqueeSpeed? speed,
MarqueeGrade? grade,
}) =>
MarqueeConfig(
speed: speed ?? this.speed,
grade: grade ?? this.grade,
);
}
/// 滚动速度档位。
enum MarqueeSpeed {
slow(200, '慢'),
slower(100, '较慢'),
medium(50, '中'),
faster(30, '较快'),
fast(10, '快');
const MarqueeSpeed(this.value, this.labelZh);
/// 后端落库值。
final int value;
/// 中文展示文案。
final String labelZh;
/// 英文展示文案。
String get labelEn {
switch (this) {
case MarqueeSpeed.slow:
return 'Slow';
case MarqueeSpeed.slower:
return 'Slower';
case MarqueeSpeed.medium:
return 'Medium';
case MarqueeSpeed.faster:
return 'Faster';
case MarqueeSpeed.fast:
return 'Fast';
}
}
String label(bool useEnglish) => useEnglish ? labelEn : labelZh;
}
/// 滚动字幕级别。
enum MarqueeGrade {
danger(50, '危险'),
urgent(40, '紧急'),
normal(20, '普通');
const MarqueeGrade(this.value, this.labelZh);
/// 后端落库值。
final int value;
/// 中文展示文案。
final String labelZh;
/// 英文展示文案。
String get labelEn {
switch (this) {
case MarqueeGrade.danger:
return 'Danger';
case MarqueeGrade.urgent:
return 'Urgent';
case MarqueeGrade.normal:
return 'Normal';
}
}
String label(bool useEnglish) => useEnglish ? labelEn : labelZh;
}

View File

@@ -0,0 +1,211 @@
/// 滚动字幕消息模型。
///
/// 对应 Web 后台「滚动字幕」表(tv_scrolling_text / theme_scrolling_text)
/// 以及管理模块「滚动字幕管理」中的单条字幕记录。
///
/// 字段映射:
/// - Web 端返回 `orgId` / `orgName`,本模型内部使用 `storeId` / `storeName`。
/// - Web 端返回 `scrollSpeed`,本模型内部使用 `speed`。
/// - Web 端返回 `bgColor`,本模型内部使用 `backgroundColor`。
/// - `status` 沿用 Web 端语义:`1=启用`,`0=禁用`。
class MarqueeMessage {
const MarqueeMessage({
required this.id,
required this.storeId,
required this.storeName,
required this.content,
required this.status,
required this.speed,
required this.createTime,
required this.updateTime,
this.fontColor,
this.backgroundColor,
this.fontSize,
this.level,
this.startTime,
this.endTime,
});
/// 字幕 ID。
final String id;
/// 所属门店 ID(对应 Web 端 orgId)。
final String storeId;
/// 所属门店名称(对应 Web 端 orgName)。
final String storeName;
/// 字幕内容。
final String content;
/// 状态:`1=启用`,`0=禁用`。
final int status;
/// 滚动速度(对应 Web 端 scrollSpeed,单位 ms/像素,越小越快)。
///
/// 档位映射:慢=200 / 较慢=100 / 中=50 / 较快=30 / 快=10。
final int speed;
/// 字体颜色(Hex,如 `#FFFFFF`)。
final String? fontColor;
/// 背景颜色(Hex,如 `#3B82F6`,对应 Web 端 bgColor)。
final String? backgroundColor;
/// 字体大小(px,如 32)。
final int? fontSize;
/// 字幕级别/优先级。
final int? level;
/// 开始生效时间(Web 端为 `HH:mm:ss` 字符串,本地存为时间)。
final DateTime? startTime;
/// 结束生效时间(Web 端为 `HH:mm:ss` 字符串,本地存为时间)。
final DateTime? endTime;
/// 创建时间。
final DateTime? createTime;
/// 更新时间。
final DateTime? updateTime;
bool get isEnabled => status == 1;
bool get isDisabled => status == 0;
factory MarqueeMessage.fromJson(Map<String, dynamic> json) {
return MarqueeMessage(
id: (json['id'] ?? json['marqueeId'])?.toString() ?? '',
storeId: json['storeId']?.toString() ??
json['orgId']?.toString() ??
json['org_id']?.toString() ??
'',
storeName: json['storeName']?.toString() ??
json['orgName']?.toString() ??
json['org_name']?.toString() ??
'',
content: json['content']?.toString() ?? '',
status: _parseInt(json['status'], fallback: 0) ?? 0,
speed: _parseInt(json['speed'], fallback: 50) ??
_parseInt(json['scrollSpeed'], fallback: 50) ??
_parseInt(json['scroll_speed'], fallback: 50) ??
50,
fontColor: json['fontColor']?.toString() ?? json['font_color']?.toString(),
backgroundColor: json['backgroundColor']?.toString() ??
json['bgColor']?.toString() ??
json['bg_color']?.toString(),
fontSize: _parseInt(json['fontSize'] ?? json['font_size']),
level: _parseInt(json['level']),
startTime: _parseDateTime(json['startTime'] ?? json['start_time']),
endTime: _parseDateTime(json['endTime'] ?? json['end_time']),
createTime: _parseDateTime(json['createTime'] ?? json['create_time']),
updateTime: _parseDateTime(json['updateTime'] ?? json['update_time']),
);
}
/// 转换为本地缓存使用的 JSON。
Map<String, dynamic> toJson() {
return {
'id': id,
'storeId': storeId,
'storeName': storeName,
'content': content,
'status': status,
'speed': speed,
if (fontColor != null) 'fontColor': fontColor,
if (backgroundColor != null) 'backgroundColor': backgroundColor,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
if (startTime != null) 'startTime': _formatTime(startTime!),
if (endTime != null) 'endTime': _formatTime(endTime!),
'createTime': createTime?.toIso8601String(),
'updateTime': updateTime?.toIso8601String(),
};
}
/// 转换为 Web 后台可接收的字段名(用于新增/编辑请求)。
Map<String, dynamic> toWebJson() {
return {
if (id.isNotEmpty) 'id': int.tryParse(id),
'orgId': int.tryParse(storeId) ?? storeId,
'orgName': storeName,
'content': content,
'scrollSpeed': speed,
if (fontColor != null) 'fontColor': fontColor,
if (backgroundColor != null) 'bgColor': backgroundColor,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
'status': status,
if (startTime != null) 'startTime': _formatTime(startTime!),
if (endTime != null) 'endTime': _formatTime(endTime!),
};
}
MarqueeMessage copyWith({
String? id,
String? storeId,
String? storeName,
String? content,
int? status,
int? speed,
String? fontColor,
String? backgroundColor,
int? fontSize,
int? level,
DateTime? startTime,
DateTime? endTime,
DateTime? createTime,
DateTime? updateTime,
}) {
return MarqueeMessage(
id: id ?? this.id,
storeId: storeId ?? this.storeId,
storeName: storeName ?? this.storeName,
content: content ?? this.content,
status: status ?? this.status,
speed: speed ?? this.speed,
fontColor: fontColor ?? this.fontColor,
backgroundColor: backgroundColor ?? this.backgroundColor,
fontSize: fontSize ?? this.fontSize,
level: level ?? this.level,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
createTime: createTime ?? this.createTime,
updateTime: updateTime ?? this.updateTime,
);
}
/// 颜色字段兜底:当 Web 端未返回颜色时返回默认半透明色。
static const String defaultFontColor = '#FFFFFF';
static const String defaultBackgroundColor = '#3B82F6';
/// 兼容 `yyyy-MM-dd HH:mm:ss`、ISO 8601 以及纯 `HH:mm:ss` 时间字符串。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
// 纯时间:补齐一个固定日期用于本地 DateTime 存储。
if (RegExp(r'^\d{2}:\d{2}:\d{2}$').hasMatch(text)) {
return DateTime.tryParse('2000-01-01 $text');
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 时间字段序列化为 `HH:mm:ss`,与 Web 后台保持一致。
static String _formatTime(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}';
}
/// 解析整数字段,兼容 int / String / null。
static int? _parseInt(dynamic value, {int? fallback}) {
if (value == null) return fallback;
if (value is int) return value;
if (value is String) return int.tryParse(value.trim()) ?? fallback;
return fallback;
}
}

128
lib/models/media_asset.dart Normal file
View File

@@ -0,0 +1,128 @@
/// 媒体资源模型。
///
/// 对应后端 /sys/mediaAsset/* 接口的媒体实体。
class MediaAsset {
const MediaAsset({
required this.id,
required this.code,
required this.name,
required this.type,
required this.coverUrl,
required this.playUrl,
required this.duration,
required this.status,
required this.createTime,
});
/// 媒体 ID。
final String id;
/// 媒体编码。
final String code;
/// 媒体名称。
final String name;
/// 媒体类型(video / image / audio 等)。
final String type;
/// 封面图地址。
final String coverUrl;
/// 播放地址。
final String playUrl;
/// 时长(秒)。
final int duration;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory MediaAsset.fromJson(Map<String, dynamic> json) {
return MediaAsset(
id: json['id']?.toString() ?? '',
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
type: json['type']?.toString() ?? '',
coverUrl: json['coverUrl']?.toString() ?? '',
playUrl: json['playUrl']?.toString() ?? '',
duration: int.tryParse(json['duration']?.toString() ?? '0') ?? 0,
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'type': type,
'coverUrl': coverUrl,
'playUrl': playUrl,
'duration': duration,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}
/// 媒体排行榜条目模型。
///
/// 对应后端 /sys/mediaAssetRank/* 接口的排行实体。
class MediaRankItem {
const MediaRankItem({
required this.id,
required this.code,
required this.mediaName,
required this.sortOrder,
required this.coverUrl,
});
/// 条目 ID。
final String id;
/// 媒体编码。
final String code;
/// 媒体名称。
final String mediaName;
/// 排序序号。
final int sortOrder;
/// 封面图地址。
final String coverUrl;
factory MediaRankItem.fromJson(Map<String, dynamic> json) {
return MediaRankItem(
id: json['id']?.toString() ?? '',
code: (json['code'] ?? json['mediaCode'])?.toString() ?? '',
mediaName: json['mediaName']?.toString() ?? '',
sortOrder: int.tryParse(json['sortOrder']?.toString() ?? '0') ?? 0,
coverUrl: json['coverUrl']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'mediaName': mediaName,
'sortOrder': sortOrder,
'coverUrl': coverUrl,
};
}
}

273
lib/models/org_node.dart Normal file
View File

@@ -0,0 +1,273 @@
/// 组织节点模型。
///
/// 对应后端 /sys/org/* 接口的组织实体,支持树形结构。
/// type 取值:0=平台 1=渠道 2=代理商 3=门店 4=设备分组。
class OrgNode {
const OrgNode({
required this.id,
required this.name,
required this.type,
required this.parentId,
this.children = const [],
required this.address,
required this.contactPhone,
required this.activationCode,
this.province = '',
this.city = '',
this.themeId,
DateTime? createdAt,
this.expireTime,
int? groupLimit,
this.orderNo = 0,
this.status = 'ACTIVE',
this.mpath = '',
}) : createdAt = createdAt,
groupLimit = groupLimit;
/// 组织节点 ID。
final String id;
/// 组织名称。
final String name;
/// 类型(0=平台 1=渠道 2=代理商 3=门店 4=设备分组)。
final int type;
/// 父级 ID。
final String parentId;
/// 子节点列表(树形结构)。
final List<OrgNode> children;
/// 地址。
final String address;
/// 联系电话。
final String contactPhone;
/// 省(新接口 /sys/org/tree 返回 province)。
final String province;
/// 市(新接口 /sys/org/tree 返回 city)。
final String city;
/// 主题 ID(新接口 /sys/org/tree 返回 themeId)。
final int? themeId;
/// 激活码。
final String activationCode;
/// 创建时间(组织树接口返回 createdAt,ISO8601 字符串)。
///
/// 门店列表需按建店时间倒序排列,故从组织树节点透传该字段;
/// 老记录可能缺省(null),此时排序回退到 orgId 数值倒序。
final DateTime? createdAt;
/// 到期时间(组织树/门店接口可能返回 expireTime/expire_time/expiredTime)。
///
/// 门店预览列表需展示到期状态(已过期 / 无到期时间),故从组织树节点透传该字段。
final DateTime? expireTime;
/// 设备总数上限(组织树接口返回 groupLimit)。
///
/// 「建店时设置的设备上限」需回显并随门店保存,故从组织树节点透传。
final int? groupLimit;
/// 排序号(组织接口返回 orderNo,渠道商管理页按此排序展示)。
final int orderNo;
/// 状态:'ACTIVE'=启用,'DISABLED'=禁用。
///
/// 后端可能返回字符串(ACTIVE/DISABLED)或整型(0=正常/1=禁用,
/// 与 [AppVersion.status] 约定一致),统一规整为字符串便于 UI 判断。
final String status;
/// 组织编码(mpath 路径编码,渠道商管理页「组织编码」列展示)。
///
/// 后端未返回时回退为空串,UI 层再降级显示节点 id。
final String mpath;
/// 后端组织类型字符串到数字的映射。
/// 旧接口用 int:0=平台 1=渠道 2=代理商 3=门店 4=设备分组。
/// 新接口 /sys/org/tree 用字符串 type:CHANNEL / SERVICE_PROVIDER / STORE / GROUP。
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601(含时区、毫秒)两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 解析可空整型(设备上限 groupLimit 等)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
return int.tryParse(value.toString());
}
static int _parseType(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
final s = value.toString().toUpperCase();
switch (s) {
case 'CHANNEL':
case '渠道':
return 1;
case 'SERVICE_PROVIDER':
case '代理商':
return 2;
case 'STORE':
case '门店':
return 3;
case 'GROUP':
case '设备分组':
return 4;
default:
return int.tryParse(s) ?? 0;
}
}
/// 解析组织状态,统一规整为 'ACTIVE' / 'DISABLED'。
///
/// 兼容后端两种形态:字符串(ACTIVE/DISABLED、启用/禁用)与整型
/// (与 [AppVersion.status] 一致:0=正常 1=禁用)。
static String _parseStatus(dynamic value) {
if (value == null) return 'ACTIVE';
if (value is int) return value == 0 ? 'ACTIVE' : 'DISABLED';
final s = value.toString().toUpperCase();
if (s == 'DISABLED' || s == '禁用') return 'DISABLED';
return 'ACTIVE';
}
factory OrgNode.fromJson(Map<String, dynamic> json) {
final childrenRaw = json['list'] ?? json['children'];
return OrgNode(
// 对齐 Web 端 normalizeOrg:id 兼容 orgId / org_id,parentId 兼容 parent_id,
// 避免后端偶发返回 orgId/parent_id 时节点 id 为空导致展开/编辑/删除失效。
id: json['id']?.toString() ??
json['orgId']?.toString() ??
json['org_id']?.toString() ??
'',
name: json['name']?.toString() ?? '',
type: _parseType(json['type']),
parentId: json['parentId']?.toString() ??
json['parent_id']?.toString() ??
'',
children: childrenRaw is List
? childrenRaw
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList()
: const <OrgNode>[],
address: json['address']?.toString() ?? '',
contactPhone: json['contactPhone']?.toString() ?? json['phone']?.toString() ?? '',
activationCode: json['activationCode']?.toString() ?? '',
createdAt: _parseDateTime(
json['createdAt'] ??
json['createTime'] ??
json['created_time'] ??
json['created_at'],
),
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
groupLimit: _parseInt(
json['groupLimit'] ?? json['deviceLimit'] ?? json['device_limit'],
),
province: json['province']?.toString() ?? '',
city: json['city']?.toString() ?? '',
themeId: json['themeId'] is int
? json['themeId'] as int
: int.tryParse(json['themeId']?.toString() ?? ''),
orderNo: _parseInt(json['orderNo'] ?? json['order_no']) ?? 0,
status: _parseStatus(json['status']),
mpath: json['mpath']?.toString() ??
json['orgCode']?.toString() ??
json['org_code']?.toString() ??
'',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'type': type,
'parentId': parentId,
'children': children.map((e) => e.toJson()).toList(),
'address': address,
'contactPhone': contactPhone,
'activationCode': activationCode,
'province': province,
'city': city,
'themeId': themeId,
'orderNo': orderNo,
'status': status,
'mpath': mpath,
'expireTime': expireTime?.toIso8601String(),
};
}
/// 不可变拷贝(主要用于渠道商管理页给节点挂载子节点树)。
OrgNode copyWith({
String? id,
String? name,
int? type,
String? parentId,
List<OrgNode>? children,
String? address,
String? contactPhone,
String? activationCode,
String? province,
String? city,
int? themeId,
DateTime? createdAt,
DateTime? expireTime,
int? groupLimit,
int? orderNo,
String? status,
String? mpath,
}) {
return OrgNode(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
parentId: parentId ?? this.parentId,
children: children ?? this.children,
address: address ?? this.address,
contactPhone: contactPhone ?? this.contactPhone,
activationCode: activationCode ?? this.activationCode,
province: province ?? this.province,
city: city ?? this.city,
themeId: themeId ?? this.themeId,
createdAt: createdAt ?? this.createdAt,
expireTime: expireTime ?? this.expireTime,
groupLimit: groupLimit ?? this.groupLimit,
orderNo: orderNo ?? this.orderNo,
status: status ?? this.status,
mpath: mpath ?? this.mpath,
);
}
/// 类型展示文案(渠道商管理页徽章用)。
String get typeLabel {
switch (type) {
case 1:
return '渠道';
case 2:
return '代理';
case 3:
return '门店';
case 4:
return '设备分组';
default:
return '平台';
}
}
/// 是否已禁用。
bool get isDisabled => status.toUpperCase() == 'DISABLED';
/// 「组织编码」列展示值:优先 mpath,缺省时降级为节点 id。
String get orgCodeLabel => mpath.isNotEmpty ? mpath : id;
}

75
lib/models/schedule.dart Normal file
View File

@@ -0,0 +1,75 @@
/// 定时任务模型。
///
/// 对应后端 /sys/schedule/* 接口的定时任务实体。
/// status 取值:0=正常 1=暂停。
class Schedule {
const Schedule({
required this.jobId,
required this.beanName,
required this.methodName,
required this.params,
required this.cronExpression,
required this.status,
required this.remark,
required this.createTime,
});
/// 任务 ID。
final String jobId;
/// Spring Bean 名称。
final String beanName;
/// 方法名称。
final String methodName;
/// 方法参数。
final String params;
/// Cron 表达式。
final String cronExpression;
/// 状态(0=正常 1=暂停)。
final int status;
/// 备注。
final String remark;
/// 创建时间。
final DateTime? createTime;
factory Schedule.fromJson(Map<String, dynamic> json) {
return Schedule(
jobId: (json['jobId'] ?? json['id'])?.toString() ?? '',
beanName: json['beanName']?.toString() ?? '',
methodName: json['methodName']?.toString() ?? '',
params: json['params']?.toString() ?? '',
cronExpression: json['cronExpression']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
remark: json['remark']?.toString() ?? '',
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'jobId': jobId,
'beanName': beanName,
'methodName': methodName,
'params': params,
'cronExpression': cronExpression,
'status': status,
'remark': remark,
'createTime': createTime?.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,59 @@
/// 门店配置(到期时间 / 禁用状态等)。
///
/// 对应后端 `GET /device/storeConfig/info/{orgId}`,与 Web 后台「门店配置」弹窗
/// 同源(参考 screen-front `src/api/device.ts` 的 `getStoreConfig`)。
///
/// 重要:组织树 `/sys/org/tree` 与通用门店列表接口(`myStores` / `listByLoginOrg`)
/// 通常**不返回** `expireTime`;移动端门店的到期时间必须由此端点补全,
/// 否则会恒为「无到期时间」。
class StoreConfig {
const StoreConfig({
this.expireTime,
this.mediaVolume,
this.disabled = false,
});
/// 到期时间(null 表示不限/未设置)。
final DateTime? expireTime;
/// 媒体区域音量(0-100,null 表示未设置)。
final int? mediaVolume;
/// 是否禁用(后端用 bool 或 '1'/0)。
final bool disabled;
factory StoreConfig.fromJson(Map<String, dynamic> json) {
final rawDisabled = json['disabled'];
final disabled = rawDisabled == true ||
(rawDisabled is num && rawDisabled != 0) ||
(rawDisabled is String && rawDisabled == '1');
return StoreConfig(
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
mediaVolume: _parseInt(json['mediaVolume'] ?? json['media_volume']),
disabled: disabled,
);
}
/// 空配置(接口异常/无数据时的占位)。
static StoreConfig empty() => const StoreConfig();
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 兼容 int / num / String 的整数解析(null 安全)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
final text = value.toString().trim();
if (text.isEmpty) return null;
return int.tryParse(text);
}
}

219
lib/models/store_info.dart Normal file
View File

@@ -0,0 +1,219 @@
import '../data/region_data.dart';
/// 门店信息模型。
///
/// 对应后端 /sys/store/* 接口的门店实体。
///
/// 扩展字段([themeId]/[splashUrl]/[logoUrl]/省市区/[addressDetail])均为可空,
/// 向后兼容既有后端响应;这些字段仅在「前端占位」阶段由本地缓存承载,
/// 后续由后端提供后自动填充。
class StoreInfo {
const StoreInfo({
required this.orgId,
required this.name,
required this.address,
required this.contactPhone,
required this.activationCode,
required this.status,
required this.expireTime,
required this.createTime,
this.parentId,
this.themeId,
this.splashUrl,
this.logoUrl,
this.provinceCode,
this.cityCode,
this.districtCode,
this.addressDetail,
int? deviceLimit,
}) : deviceLimit = deviceLimit;
/// 门店组织 ID。
final String orgId;
/// 父级组织 ID(所属渠道/代理商)。
final String? parentId;
/// 门店名称。
final String name;
/// 地址(兼容旧文本地址;新结构优先用省市区拼接)。
final String address;
/// 联系电话。
final String contactPhone;
/// 激活码。
final String activationCode;
/// 状态(0=正常 1=禁用)。
final int status;
/// 到期时间。
final DateTime? expireTime;
/// 创建时间。
final DateTime? createTime;
/// 设备总数上限(组织树 groupLimit / 激活码 maxActivateCount)。
final int? deviceLimit;
/// 主题 id(如 'ocean');前端占位,对应 [StoreThemePresets.byId]。
final String? themeId;
/// 启动图本地路径 / 远程 URL(占位)。
final String? splashUrl;
/// Logo 本地路径 / 远程 URL(占位)。
final String? logoUrl;
/// 省 code(6 位)。
final String? provinceCode;
/// 市 code。
final String? cityCode;
/// 区 code。
final String? districtCode;
/// 街道/门牌等详情文本。
final String? addressDetail;
factory StoreInfo.fromJson(Map<String, dynamic> json) {
return StoreInfo(
orgId: (json['orgId'] ?? json['id'])?.toString() ?? '',
parentId: json['parentId']?.toString() ??
json['parent_id']?.toString() ??
(json['parent'] is Map ? json['parent']['id']?.toString() : null),
name: json['name']?.toString() ?? '',
address: json['address']?.toString() ?? '',
contactPhone: json['contactPhone']?.toString() ?? '',
activationCode: json['activationCode']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
createTime: _parseDateTime(
json['createTime'] ??
json['create_time'] ??
json['createdTime'] ??
json['createdAt'] ??
json['created_at'],
),
deviceLimit: _parseInt(
json['groupLimit'] ?? json['deviceLimit'] ?? json['device_limit'],
),
themeId: json['themeId']?.toString(),
splashUrl: json['splashUrl']?.toString(),
logoUrl: json['logoUrl']?.toString(),
provinceCode: json['provinceCode']?.toString(),
cityCode: json['cityCode']?.toString(),
districtCode: json['districtCode']?.toString(),
addressDetail: json['addressDetail']?.toString(),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 解析可空整型(设备上限等)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
return int.tryParse(value.toString());
}
Map<String, dynamic> toJson() {
return {
'orgId': orgId,
'parentId': parentId,
'name': name,
'address': address,
'contactPhone': contactPhone,
'activationCode': activationCode,
'status': status,
'expireTime': expireTime?.toIso8601String(),
'createTime': createTime?.toIso8601String(),
'deviceLimit': deviceLimit,
'themeId': themeId,
'splashUrl': splashUrl,
'logoUrl': logoUrl,
'provinceCode': provinceCode,
'cityCode': cityCode,
'districtCode': districtCode,
'addressDetail': addressDetail,
};
}
/// 不可变更新副本(仅覆盖传入的非空参数)。
///
/// 用于门店列表加载后,用 [OrgService.getStoreListByLoginOrg] 回填的
/// 到期时间等非组织树字段,避免重建整个对象。
StoreInfo copyWith({
String? orgId,
String? parentId,
String? name,
String? address,
String? contactPhone,
String? activationCode,
int? status,
DateTime? expireTime,
DateTime? createTime,
int? deviceLimit,
String? themeId,
String? splashUrl,
String? logoUrl,
String? provinceCode,
String? cityCode,
String? districtCode,
String? addressDetail,
}) {
return StoreInfo(
orgId: orgId ?? this.orgId,
parentId: parentId ?? this.parentId,
name: name ?? this.name,
address: address ?? this.address,
contactPhone: contactPhone ?? this.contactPhone,
activationCode: activationCode ?? this.activationCode,
status: status ?? this.status,
expireTime: expireTime ?? this.expireTime,
createTime: createTime ?? this.createTime,
deviceLimit: deviceLimit ?? this.deviceLimit,
themeId: themeId ?? this.themeId,
splashUrl: splashUrl ?? this.splashUrl,
logoUrl: logoUrl ?? this.logoUrl,
provinceCode: provinceCode ?? this.provinceCode,
cityCode: cityCode ?? this.cityCode,
districtCode: districtCode ?? this.districtCode,
addressDetail: addressDetail ?? this.addressDetail,
);
}
/// 拼接展示地址:省名 + 市名 + 区名 + 详情文本。
///
/// 若已设置省市区则优先用编码反查名称拼接;否则回退到旧文本 [address]。
String get regionText {
final names = <String>[];
if (provinceCode != null && provinceCode!.isNotEmpty) {
final p = RegionData.instance.nameOf(provinceCode!);
if (p.isNotEmpty) names.add(p);
}
if (cityCode != null && cityCode!.isNotEmpty) {
final c = RegionData.instance.nameOf(cityCode!);
if (c.isNotEmpty) names.add(c);
}
if (districtCode != null && districtCode!.isNotEmpty) {
final d = RegionData.instance.nameOf(districtCode!);
if (d.isNotEmpty) names.add(d);
}
final detail = (addressDetail ?? '').trim();
if (detail.isNotEmpty) names.add(detail);
if (names.isEmpty) return address;
return names.join('');
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// 门店主题/皮肤枚举。
///
/// 仅定义「id + 名称 + 主色」,UI 统一引用 [id](`themeId`),不硬编码颜色。
/// 当前为前端占位实现(纯色块 + 名称),后续可扩展为内置占位图。
enum StoreTheme {
/// 经典(默认)。
classic,
/// 海洋蓝。
ocean,
/// 森林绿。
forest,
/// 日落橙。
sunset,
/// 极简灰。
mono;
/// 主题 id(小写字符串,用于网络传输与本地缓存)。
String get id => name;
/// 主题中文名称。
String get label {
switch (this) {
case StoreTheme.classic:
return '经典';
case StoreTheme.ocean:
return '海洋蓝';
case StoreTheme.forest:
return '森林绿';
case StoreTheme.sunset:
return '日落橙';
case StoreTheme.mono:
return '极简灰';
}
}
/// 主题主色(用于色块预览与模拟主页头部)。
Color get primary {
switch (this) {
case StoreTheme.classic:
return const Color(0xFF1565C0);
case StoreTheme.ocean:
return const Color(0xFF00838F);
case StoreTheme.forest:
return const Color(0xFF2E7D32);
case StoreTheme.sunset:
return const Color(0xFFE65100);
case StoreTheme.mono:
return const Color(0xFF455A64);
}
}
}
/// 内置主题清单与查询工具。
class StoreThemePresets {
StoreThemePresets._();
/// 全部内置主题(顺序即展示顺序)。
static const List<StoreTheme> all = StoreTheme.values;
/// 按 id 查询主题;找不到返回 null。
static StoreTheme? byId(String? id) {
if (id == null || id.isEmpty) return null;
for (final theme in all) {
if (theme.id == id) return theme;
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
/// 系统参数配置模型。
///
/// 对应后端 /sys/config/* 接口的配置实体。
class SysConfig {
const SysConfig({
required this.id,
required this.paramKey,
required this.paramValue,
required this.remark,
});
/// 主键 ID。
final String id;
/// 参数键名。
final String paramKey;
/// 参数值。
final String paramValue;
/// 备注。
final String remark;
factory SysConfig.fromJson(Map<String, dynamic> json) {
return SysConfig(
id: json['id']?.toString() ?? '',
paramKey: json['paramKey']?.toString() ?? '',
paramValue: json['paramValue']?.toString() ?? '',
remark: json['remark']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'paramKey': paramKey,
'paramValue': paramValue,
'remark': remark,
};
}
}

74
lib/models/sys_log.dart Normal file
View File

@@ -0,0 +1,74 @@
/// 系统操作日志模型。
///
/// 对应后端 /sys/log/* 接口的日志实体。
class SysLog {
const SysLog({
required this.id,
required this.username,
required this.operation,
required this.method,
required this.params,
required this.time,
required this.ip,
required this.createDate,
});
/// 日志 ID。
final String id;
/// 操作用户名。
final String username;
/// 操作描述。
final String operation;
/// 请求方法(类名#方法名)。
final String method;
/// 请求参数。
final String params;
/// 执行时长(毫秒)。
final int time;
/// 操作 IP。
final String ip;
/// 创建时间。
final DateTime? createDate;
factory SysLog.fromJson(Map<String, dynamic> json) {
return SysLog(
id: json['id']?.toString() ?? '',
username: json['username']?.toString() ?? '',
operation: json['operation']?.toString() ?? '',
method: json['method']?.toString() ?? '',
params: json['params']?.toString() ?? '',
time: int.tryParse(json['time']?.toString() ?? '0') ?? 0,
ip: json['ip']?.toString() ?? '',
createDate: _parseDateTime(json['createDate']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'operation': operation,
'method': method,
'params': params,
'time': time,
'ip': ip,
'createDate': createDate?.toIso8601String(),
};
}
}

72
lib/models/sys_menu.dart Normal file
View File

@@ -0,0 +1,72 @@
/// 系统菜单模型。
///
/// 对应后端 /sys/menu/* 接口的菜单实体。
/// type 取值:0=目录 1=菜单 2=按钮。
class SysMenu {
const SysMenu({
required this.menuId,
required this.name,
required this.url,
required this.type,
required this.icon,
required this.parentId,
required this.orderNum,
required this.perms,
required this.show,
});
/// 菜单 ID。
final String menuId;
/// 菜单名称。
final String name;
/// 路由地址。
final String url;
/// 类型(0=目录 1=菜单 2=按钮)。
final int type;
/// 图标。
final String icon;
/// 父级 ID。
final String parentId;
/// 排序号。
final int orderNum;
/// 权限标识。
final String perms;
/// 是否显示(0=显示 1=隐藏)。
final int show;
factory SysMenu.fromJson(Map<String, dynamic> json) {
return SysMenu(
menuId: (json['menuId'] ?? json['id'])?.toString() ?? '',
name: json['name']?.toString() ?? '',
url: json['url']?.toString() ?? '',
type: int.tryParse(json['type']?.toString() ?? '0') ?? 0,
icon: json['icon']?.toString() ?? '',
parentId: json['parentId']?.toString() ?? '',
orderNum: int.tryParse(json['orderNum']?.toString() ?? '0') ?? 0,
perms: json['perms']?.toString() ?? '',
show: int.tryParse(json['show']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'menuId': menuId,
'name': name,
'url': url,
'type': type,
'icon': icon,
'parentId': parentId,
'orderNum': orderNum,
'perms': perms,
'show': show,
};
}
}

84
lib/models/sys_role.dart Normal file
View File

@@ -0,0 +1,84 @@
/// 系统角色模型。
///
/// 对应后端 /sys/role/* 接口的角色实体。
class SysRole {
const SysRole({
required this.roleId,
required this.roleName,
required this.remark,
required this.createTime,
});
/// 角色 ID。
final String roleId;
/// 角色名称。
final String roleName;
/// 备注。
final String remark;
/// 创建时间。
final DateTime? createTime;
factory SysRole.fromJson(Map<String, dynamic> json) {
return SysRole(
roleId: (json['roleId'] ?? json['id'])?.toString() ?? '',
roleName: json['roleName']?.toString() ?? '',
remark: json['remark']?.toString() ?? '',
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'roleId': roleId,
'roleName': roleName,
'remark': remark,
'createTime': createTime?.toIso8601String(),
};
}
}
/// 角色-菜单关联模型(用于角色权限分配)。
class RoleMenu {
const RoleMenu({
required this.id,
required this.roleId,
required this.menuId,
});
/// 主键 ID。
final String id;
/// 角色 ID。
final String roleId;
/// 菜单 ID。
final String menuId;
factory RoleMenu.fromJson(Map<String, dynamic> json) {
return RoleMenu(
id: json['id']?.toString() ?? '',
roleId: json['roleId']?.toString() ?? '',
menuId: json['menuId']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'roleId': roleId,
'menuId': menuId,
};
}
}

218
lib/models/sys_user.dart Normal file
View File

@@ -0,0 +1,218 @@
/// 系统用户模型。
///
/// 对应后端 /sys/user/* 接口的用户实体。
class SysUser {
const SysUser({
required this.id,
required this.username,
required this.realName,
required this.email,
required this.mobile,
required this.status,
required this.roleId,
required this.roleName,
required this.roleNames,
required this.orgId,
required this.orgName,
required this.createTime,
});
/// 用户 ID。
final String id;
/// 登录账号。
final String username;
/// 真实姓名。
final String realName;
/// 邮箱。
final String email;
/// 手机号。
final String mobile;
/// 状态(0=正常 1=禁用)。
final int status;
/// 角色 ID。
final String roleId;
/// 角色名称。
final String roleName;
/// 角色名称列表(后端部分接口返回 roleNames 数组)。
final List<String> roleNames;
/// 所属组织 ID。
final String orgId;
/// 所属组织名称(门店 / 渠道商 / 代理商名称)。
final String orgName;
/// 创建时间。
final DateTime? createTime;
factory SysUser.fromJson(Map<String, dynamic> json) {
final roleList = _parseStringList(json['roleIdList'] ?? json['roleIds']);
final roleNameFromObj = _extractNestedName(
json['roles'] ?? json['role'] ?? json['sysRoles'] ?? json['sysRole'],
const ['roleName', 'name', 'role'],
);
final roleIdFromObj = _extractNestedName(
json['roles'] ?? json['role'] ?? json['sysRoles'] ?? json['sysRole'],
const ['roleId', 'id'],
);
final roleNames = _parseStringList(
json['roleNames'] ?? json['roleName'] ?? roleNameFromObj,
);
final roleId = json['roleId']?.toString() ??
roleIdFromObj ??
(roleList.isNotEmpty ? roleList.first : '');
final orgNameFromObj = _extractNestedName(
json['org'] ??
json['dept'] ??
json['sysOrg'] ??
json['sysDept'] ??
json['organization'],
const ['orgName', 'name', 'deptName', 'org'],
);
final orgIdFromObj = _extractNestedName(
json['org'] ??
json['dept'] ??
json['sysOrg'] ??
json['sysDept'] ??
json['organization'],
const ['orgId', 'id', 'deptId'],
);
return SysUser(
id: json['id']?.toString() ?? json['userId']?.toString() ?? '',
username: json['username']?.toString() ?? '',
realName: json['realName']?.toString() ?? '',
email: json['email']?.toString() ?? '',
mobile: json['mobile']?.toString() ?? '',
status: _parseStatus(json['status']),
roleId: roleId,
roleName: json['roleName']?.toString() ??
(roleNames.isNotEmpty ? roleNames.first : ''),
roleNames: roleNames.isNotEmpty ? roleNames : roleList,
orgId: json['orgId']?.toString() ?? orgIdFromObj ?? '',
orgName: json['orgName']?.toString() ??
json['storeName']?.toString() ??
json['deptName']?.toString() ??
orgNameFromObj ??
'',
createTime: _parseDateTime(json['createTime']),
);
}
/// 从嵌套对象或对象列表中提取名称/ID 字段。
/// 用于兼容后端把角色/组织以对象/对象数组返回的情况。
static String? _extractNestedName(dynamic value, List<String> keys) {
if (value is Map) {
final map = value as Map<String, dynamic>;
for (final key in keys) {
final v = map[key]?.toString();
if (v != null && v.isNotEmpty) return v;
}
}
if (value is List && value.isNotEmpty) {
return _extractNestedName(value.first, keys);
}
return null;
}
/// 兼容 int / String / bool 三种状态表示:
/// - int: 1=正常, 0=禁用(新后台 /sys/user/list 返回 status=1 为启用)
/// - String: '0'/'正常'/'启用'/'true'=正常, 其他=禁用
/// - bool: true=正常, false=禁用
static int _parseStatus(dynamic value) {
if (value == null) return 0;
if (value is bool) return value ? 0 : 1;
if (value is int) {
// 新后台:1=启用/正常,0=禁用/停用。
return value == 1 ? 0 : 1;
}
final text = value.toString().trim().toLowerCase();
if (text.isEmpty ||
text == '0' ||
text == '正常' ||
text == '启用' ||
text == 'true' ||
text == 'ok' ||
text == 'enabled' ||
text == '1') {
return 0;
}
return 1;
}
static List<String> _parseStringList(dynamic value) {
if (value is List) {
return value.map((e) => e?.toString() ?? '').where((e) => e.isNotEmpty).toList();
}
if (value is String && value.isNotEmpty) {
return [value];
}
return const <String>[];
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'realName': realName,
'email': email,
'mobile': mobile,
'status': status,
'roleId': roleId,
'roleName': roleName,
'roleNames': roleNames,
'orgId': orgId,
'orgName': orgName,
'createTime': createTime?.toIso8601String(),
};
}
/// 复制并覆盖指定字段,用于列表层批量补全角色信息(/_enrichRoles)。
SysUser copyWith({
String? id,
String? username,
String? realName,
String? email,
String? mobile,
int? status,
String? roleId,
String? roleName,
List<String>? roleNames,
String? orgId,
String? orgName,
DateTime? createTime,
}) {
return SysUser(
id: id ?? this.id,
username: username ?? this.username,
realName: realName ?? this.realName,
email: email ?? this.email,
mobile: mobile ?? this.mobile,
status: status ?? this.status,
roleId: roleId ?? this.roleId,
roleName: roleName ?? this.roleName,
roleNames: roleNames ?? this.roleNames,
orgId: orgId ?? this.orgId,
orgName: orgName ?? this.orgName,
createTime: createTime ?? this.createTime,
);
}
}

View File

@@ -0,0 +1,94 @@
/// 门店分组下的主题页面(对应 web 后台 /theme/page/org/{orgId}/photos)。
class ThemePage {
const ThemePage({
required this.themeId,
required this.name,
this.themePhoto,
this.active = false,
this.orderNo,
});
/// 主题 ID(tv_theme.id)。
final int themeId;
/// 主题名称。
final String name;
/// 主题预览图 URL。
final String? themePhoto;
/// 是否为当前分组激活主题。
final bool active;
/// 排序号。
final int? orderNo;
factory ThemePage.fromJson(Map<String, dynamic> json) {
final rawActive = json['active'];
return ThemePage(
themeId: (json['themeId'] ?? json['theme_id'] ?? json['id'] ?? 0) as int,
name: (json['name'] ?? json['themeName'] ?? '')?.toString() ?? '',
themePhoto: (json['themePhoto'] ?? json['theme_photo'])?.toString(),
active: rawActive == true ||
(rawActive is num && rawActive.toInt() == 1) ||
(rawActive is String && rawActive.toLowerCase() == 'true'),
orderNo: (json['orderNo'] ?? json['order_no']) is num
? ((json['orderNo'] ?? json['order_no']) as num).toInt()
: null,
);
}
}
/// 门店设备分组及其主题列表(界面配置)。
class ThemeGroup {
const ThemeGroup({
required this.groupId,
required this.groupName,
required this.themes,
this.themeLimit = 6,
});
/// 设备分组 ID。
final int groupId;
/// 设备分组名称。
final String groupName;
/// 分组下主题数量上限。
final int themeLimit;
/// 分组下已添加的主题列表。
final List<ThemePage> themes;
factory ThemeGroup.fromJson(Map<String, dynamic> json) {
final themesRaw = json['themes'];
final themes = themesRaw is List
? themesRaw
.whereType<Map<String, dynamic>>()
.map(ThemePage.fromJson)
.where((t) => t.themeId > 0)
.toList()
: <ThemePage>[];
themes.sort((a, b) => (a.orderNo ?? 0).compareTo(b.orderNo ?? 0));
final rawLimit = json['themeLimit'] ?? json['theme_limit'];
final themeLimit = rawLimit is num && rawLimit.toInt() > 0
? rawLimit.toInt()
: 6;
return ThemeGroup(
groupId: (json['groupId'] ?? json['group_id'] ?? 0) as int,
groupName: (json['groupName'] ?? json['group_name'] ?? '')?.toString() ??
'未命名分组',
themeLimit: themeLimit,
themes: themes,
);
}
/// 当前激活的主题名称(空表示未激活任何主题)。
String? get activeThemeName {
final active = themes.where((t) => t.active).toList();
if (active.isEmpty) return null;
return active.first.name;
}
}

View File

@@ -0,0 +1,55 @@
/// 主题模板(对应 web 后台 /theme/themetemplate/* 接口)。
///
/// 用于门店表单中「主题皮肤」选择;门店的 themeId 通常即为模板 id。
class ThemeTemplate {
const ThemeTemplate({
required this.id,
required this.name,
this.description,
this.coverUrl,
this.status = 'ENABLED',
});
/// 模板 ID(对应门店 themeId)。
final String id;
/// 模板名称。
final String name;
/// 模板描述。
final String? description;
/// 封面预览图 URL。
final String? coverUrl;
/// 状态:ENABLED / DISABLED。
final String status;
factory ThemeTemplate.fromJson(Map<String, dynamic> json) {
return ThemeTemplate(
id: json['id']?.toString() ??
json['templateId']?.toString() ??
json['themeTemplateId']?.toString() ??
'',
name: json['name']?.toString() ??
json['templateName']?.toString() ??
'',
description: json['description']?.toString(),
coverUrl: json['coverUrl']?.toString() ??
json['cover_url']?.toString(),
status: json['status']?.toString() ?? 'ENABLED',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
if (description != null) 'description': description,
if (coverUrl != null) 'coverUrl': coverUrl,
'status': status,
};
}
bool get isEnabled => status.toUpperCase() == 'ENABLED';
}

80
lib/models/tv_app.dart Normal file
View File

@@ -0,0 +1,80 @@
/// TV 应用模型。
///
/// 对应后端 /theme/tvapp/* 接口的 TV 应用实体。
class TvApp {
const TvApp({
required this.id,
required this.appName,
required this.packageName,
required this.appCategory,
required this.iconUrl,
required this.orgId,
required this.groupId,
required this.status,
required this.createTime,
});
/// 应用 ID。
final String id;
/// 应用名称。
final String appName;
/// 包名。
final String packageName;
/// 应用分类。
final String appCategory;
/// 图标地址。
final String iconUrl;
/// 组织 ID。
final String orgId;
/// 设备分组 ID。
final String groupId;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory TvApp.fromJson(Map<String, dynamic> json) {
return TvApp(
id: json['id']?.toString() ?? '',
appName: json['appName']?.toString() ?? '',
packageName: json['packageName']?.toString() ?? '',
appCategory: json['appCategory']?.toString() ?? '',
iconUrl: json['iconUrl']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
groupId: json['groupId']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'appName': appName,
'packageName': packageName,
'appCategory': appCategory,
'iconUrl': iconUrl,
'orgId': orgId,
'groupId': groupId,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}

76
lib/models/user.dart Normal file
View File

@@ -0,0 +1,76 @@
/// 用户模型。
///
/// 字段由 [AuthService] 从真实登录响应(/sys/login)与
/// 当前用户接口(/sys/user/info)映射而来。
class User {
const User({
required this.id,
required this.account,
required this.displayName,
required this.role,
required this.token,
this.orgId = '',
this.roleId = '',
this.orgType = 0,
this.superAdmin = false,
});
/// 用户唯一标识(真实模式下取 orgId,缺省回退为账号)。
final String id;
/// 登录账号。
final String account;
/// 显示名称。
final String displayName;
/// 角色(如:运维管理员;多个角色以 `/` 连接)。
final String role;
/// 登录令牌。
final String token;
/// 当前用户所属组织 ID(用于表单默认选中)。
final String orgId;
/// 当前用户角色 ID(用于表单默认选中)。
final String roleId;
/// 当前用户所属组织类型(来自登录响应 orgType):
/// 0=平台(管理员) 1=渠道(渠道商) 2=代理商。
/// 用于按账号类别显隐功能模块(用户管理 / 渠道商管理)。
final int orgType;
/// 是否超级管理员(来自登录响应 superAdmin);为 true 视为管理员。
final bool superAdmin;
/// 序列化为 JSON(用于本地持久化)。
Map<String, dynamic> toJson() => {
'id': id,
'account': account,
'displayName': displayName,
'role': role,
'token': token,
'orgId': orgId,
'roleId': roleId,
'orgType': orgType,
'superAdmin': superAdmin,
};
/// 从 JSON 反序列化。
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id']?.toString() ?? '',
account: json['account']?.toString() ?? '',
displayName: json['displayName']?.toString() ?? '',
role: json['role']?.toString() ?? '',
token: json['token']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
roleId: json['roleId']?.toString() ?? '',
orgType: json['orgType'] is int
? json['orgType'] as int
: int.tryParse(json['orgType']?.toString() ?? '') ?? 0,
superAdmin: json['superAdmin'] is bool
? json['superAdmin'] as bool
: false,
);
}

View File

@@ -0,0 +1,158 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user.dart';
import '../services/api_client.dart';
import '../services/auth_service.dart';
import '../utils/credential_storage.dart';
import '../utils/session_org_scope.dart';
/// AuthService 提供者。
final authServiceProvider = Provider<AuthService>((ref) => AuthService());
/// 登录状态。
class AuthState {
const AuthState({
this.user,
this.isLoading = false,
this.errorMessage,
});
/// 当前登录用户;为 null 表示未登录。
final User? user;
/// 是否正在执行登录请求。
final bool isLoading;
/// 最近一次登录失败的错误信息。
final String? errorMessage;
/// 是否已登录。
bool get isLoggedIn => user != null;
AuthState copyWith({
User? user,
bool? isLoading,
String? errorMessage,
bool clearUser = false,
bool clearError = false,
}) {
return AuthState(
user: clearUser ? null : (user ?? this.user),
isLoading: isLoading ?? this.isLoading,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
);
}
}
/// 登录状态管理。
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
// 注册会话失效回调:401 刷新失败 → 清空登录态(含本地持久化),
// 路由 redirect 监听本状态,自动跳转登录页。
ApiClient.instance.onUnauthorized = () {
SessionOrgScope.instance.clear();
unawaited(_clearPersistedSession());
state = const AuthState();
};
// 尝试从本地存储恢复登录态(免登录)。
return _tryRestoreSession();
}
/// 从本地存储恢复登录态。
AuthState _tryRestoreSession() {
final cs = CredentialStorage.instance;
final token = cs.token;
final userMap = cs.savedUserMap;
if (token != null && token.isNotEmpty && userMap != null) {
// 恢复 TokenStore(供 ApiInterceptor 使用)。
TokenStore.instance.save(
token: token,
refreshToken: cs.refreshToken,
);
try {
final user = User.fromJson(userMap);
// 恢复数据权限范围:仅当登录用户 orgId 非空时收敛。
SessionOrgScope.instance.setCurrentOrg(user.orgId);
return AuthState(user: user);
} catch (_) {
// JSON 解析失败,清除残留数据。
_clearPersistedSession();
}
}
return const AuthState();
}
/// 执行登录;成功返回 true,失败返回 false 并写入错误信息。
///
/// [captcha]/[uuid] 仅在后端要求验证码时传值;当前各环境已关闭登录验证码,
/// 调用方(登录页)可不传,由 [AuthService.login] 决定是否写入请求体。
Future<bool> login({
required String account,
required String password,
String captcha = '',
String uuid = '',
bool rememberPassword = false,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final user = await ref.read(authServiceProvider).login(
account: account,
password: password,
captcha: captcha,
uuid: uuid,
);
// 持久化登录态(token + 用户信息),用于免登录恢复。
await CredentialStorage.instance.saveSession(
token: user.token,
refreshToken: TokenStore.instance.refreshToken,
userJson: jsonEncode(user.toJson()),
);
// 记住密码。
if (rememberPassword) {
await CredentialStorage.instance.saveCredentials(
account: account,
password: password,
);
} else {
await CredentialStorage.instance.clearCredentials();
}
state = AuthState(user: user, isLoading: false);
// 登录成功后写入数据权限范围,供 OrgService 客户端收敛组织/门店数据。
SessionOrgScope.instance.setCurrentOrg(user.orgId);
return true;
} on AuthException catch (e) {
state = state.copyWith(isLoading: false, errorMessage: e.message);
return false;
} catch (_) {
state = state.copyWith(isLoading: false, errorMessage: '登录失败,请稍后重试');
return false;
}
}
/// 登出并清空登录态。
Future<void> logout() async {
await ref.read(authServiceProvider).logout();
SessionOrgScope.instance.clear();
await _clearPersistedSession();
state = const AuthState();
}
/// 清除本地持久化的登录态。
Future<void> _clearPersistedSession() async {
await CredentialStorage.instance.clearSession();
}
}
/// 登录状态提供者。
final authProvider = NotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);

View File

@@ -0,0 +1,106 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/device.dart';
import '../services/device_service.dart';
import '../services/org_service.dart';
import '../utils/session_org_scope.dart';
import 'devices_provider.dart';
/// 仪表盘统计数据。
class DashboardStats {
const DashboardStats({
this.deviceTotal = 0,
this.storeTotal = 0,
this.onlineCount = 0,
this.offlineCount = 0,
});
final int deviceTotal;
final int storeTotal;
final int onlineCount;
final int offlineCount;
DashboardStats copyWith({
int? deviceTotal,
int? storeTotal,
int? onlineCount,
int? offlineCount,
}) {
return DashboardStats(
deviceTotal: deviceTotal ?? this.deviceTotal,
storeTotal: storeTotal ?? this.storeTotal,
onlineCount: onlineCount ?? this.onlineCount,
offlineCount: offlineCount ?? this.offlineCount,
);
}
}
/// 仪表盘统计状态。
class DashboardStatsNotifier extends StateNotifier<DashboardStats> {
DashboardStatsNotifier(this._deviceService, this._orgService)
: super(const DashboardStats());
final DeviceService _deviceService;
final OrgService _orgService;
Future<void> load() async {
try {
int deviceTotal = 0;
int onlineCount = 0;
int offlineCount = 0;
if (SessionOrgScope.instance.enabled) {
// 受限账号:仅统计其组织子树内的设备。
// 设备接口可能忽略 orgId,故改为「拉取列表 + 按 storeId 在范围内收敛」,
// 杜绝跨组织设备越权统计(只会少计、不会多计或泄露其他组织数据)。
final ids = await _orgService.scopedOrgIdSet();
if (ids.isNotEmpty) {
final devices = await _deviceService.fetchDevices(limit: 1000);
final scoped =
devices.where((d) => ids.contains(d.storeId)).toList();
deviceTotal = scoped.length;
onlineCount =
scoped.where((d) => d.status == DeviceStatus.online).length;
offlineCount =
scoped.where((d) => d.status == DeviceStatus.offline).length;
}
} else {
deviceTotal = await _deviceService.fetchDeviceTotalCount();
// 若后端未返回 totalCount,则拉取一页列表用其长度兜底。
if (deviceTotal <= 0) {
final devices = await _deviceService.fetchDevices(limit: 100);
deviceTotal = devices.length;
}
// 在线/离线:分别按 status 拉 totalCount(O(1),无需遍历全量设备)。
final results = await Future.wait<int>([
_deviceService.fetchDeviceCountByStatus(status: 'online'),
_deviceService.fetchDeviceCountByStatus(status: 'offline'),
]);
onlineCount = results[0];
offlineCount = results[1];
}
// 门店总数:优先组织树,失败回退门店接口(fetchStoreCount 内部已含回退链)。
final storeTotal = await _orgService.fetchStoreCount();
state = state.copyWith(
deviceTotal: deviceTotal,
storeTotal: storeTotal,
onlineCount: onlineCount,
offlineCount: offlineCount,
);
} catch (_) {
// 统计失败不阻断页面,保持旧值或 0。
}
}
}
final orgServiceProvider = Provider<OrgService>((ref) => OrgService());
final dashboardStatsProvider =
StateNotifierProvider<DashboardStatsNotifier, DashboardStats>(
(ref) => DashboardStatsNotifier(
ref.watch(deviceServiceProvider),
ref.watch(orgServiceProvider),
),
);

View File

@@ -0,0 +1,33 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/alert.dart';
import '../models/device.dart';
import '../services/device_service.dart';
/// DeviceService 提供者。
final deviceServiceProvider =
Provider<DeviceService>((ref) => DeviceService());
/// 设备列表提供者(下拉刷新时调用 [ProviderRef.refresh] 即可)。
final devicesProvider = FutureProvider<List<Device>>((ref) {
return ref.watch(deviceServiceProvider).fetchDevices();
});
/// 按门店筛选的设备列表提供者(参数为门店 orgId,null 表示全部门店)。
final devicesByStoreProvider =
FutureProvider.family<List<Device>, String?>((ref, storeOrgId) {
return ref
.watch(deviceServiceProvider)
.fetchDevices(storeOrgId: storeOrgId);
});
/// 设备详情提供者(按设备 ID 参数化)。
final deviceDetailProvider =
FutureProvider.family<Device, String>((ref, deviceId) {
return ref.watch(deviceServiceProvider).fetchDeviceById(deviceId);
});
/// 告警列表提供者。
final alertsProvider = FutureProvider<List<Alert>>((ref) {
return ref.watch(deviceServiceProvider).fetchAlerts();
});

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 显示方向偏好:跟随系统 / 竖屏 / 横屏。
///
/// 与主题、语言并列,作为「我的」页可手动切换的显示设置;切换后通过
/// [SystemChrome.setPreferredOrientations] 实时生效,并重渲染界面(横屏沿用
/// NavigationRail 布局,竖屏沿用 NavigationBar 布局,二者共用同一套橙白主题,
/// 视觉风格保持一致)。
enum DisplayOrientation {
/// 跟随系统(默认):允许全部四个方向,由设备物理朝向决定。
system,
/// 仅竖屏。
portrait,
/// 仅横屏。
landscape,
}
const String _kDisplayOrientation = 'display_orientation';
/// 将偏好映射为 [SystemChrome] 需要的设备方向列表。
List<DeviceOrientation> deviceOrientationsFor(DisplayOrientation mode) {
switch (mode) {
case DisplayOrientation.portrait:
return const [DeviceOrientation.portraitUp];
case DisplayOrientation.landscape:
return const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight];
case DisplayOrientation.system:
return const [
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
];
}
}
/// 立即应用显示方向(调用 [SystemChrome.setPreferredOrientations])。
void applyDisplayOrientation(DisplayOrientation mode) {
SystemChrome.setPreferredOrientations(deviceOrientationsFor(mode));
}
/// 从持久化读取已保存偏好;异常或无记录时返回 [DisplayOrientation.system]。
Future<DisplayOrientation> loadSavedOrientation() async {
try {
final prefs = await SharedPreferences.getInstance();
final v = prefs.getString(_kDisplayOrientation);
if (v != null) {
for (final e in DisplayOrientation.values) {
if (e.name == v) return e;
}
}
} catch (_) {
// 忽略:SharedPreferences 不可用时退化为系统。
}
return DisplayOrientation.system;
}
/// 显示方向偏好管理(持久化 + 实时生效)。
class DisplayOrientationNotifier extends Notifier<DisplayOrientation> {
@override
DisplayOrientation build() {
// 首选项在 main 启动期已应用;这里异步回读以保证 UI 选中态与持久值一致。
_load();
return DisplayOrientation.system;
}
Future<void> _load() async {
final saved = await loadSavedOrientation();
if (saved != state) {
state = saved;
applyDisplayOrientation(saved);
}
}
/// 切换显示方向:更新状态、即时生效并持久化。
Future<void> set(DisplayOrientation mode) async {
state = mode;
applyDisplayOrientation(mode);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kDisplayOrientation, mode.name);
} catch (_) {
// 忽略持久化失败,运行时仍生效。
}
}
}
/// 显示方向偏好提供者。
final displayOrientationProvider =
NotifierProvider<DisplayOrientationNotifier, DisplayOrientation>(
DisplayOrientationNotifier.new,
);

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/store_local_cache.dart';
/// 应用支持的语言。
enum AppLocale {
/// 简体中文(默认)。
zh,
/// 英语。
en,
}
/// [AppLocale] 的辅助方法。
extension AppLocaleX on AppLocale {
/// 标准 BCP-47 locale 标识。
Locale get locale {
switch (this) {
case AppLocale.zh:
return const Locale('zh', 'CN');
case AppLocale.en:
return const Locale('en', 'US');
}
}
/// 设置页展示的中文名。
String get label {
switch (this) {
case AppLocale.zh:
return '简体中文';
case AppLocale.en:
return 'English';
}
}
/// 设置页展示的英文名。
String get labelEn {
switch (this) {
case AppLocale.zh:
return 'Chinese (Simplified)';
case AppLocale.en:
return 'English';
}
}
/// 持久化存储用的字符串键。
String get storageKey {
switch (this) {
case AppLocale.zh:
return 'zh_CN';
case AppLocale.en:
return 'en_US';
}
}
}
/// 当前语言提供者(默认简体中文)。
///
/// 通过 [LocaleNotifier.setLocale] 切换,并持久化到本地缓存,
/// 下次启动自动恢复。
class LocaleNotifier extends Notifier<AppLocale> {
static const String _storageKey = 'app_locale';
@override
AppLocale build() {
final saved = StoreLocalCache.instance.getString(_storageKey);
if (saved == AppLocale.en.storageKey) {
return AppLocale.en;
}
return AppLocale.zh;
}
/// 设置语言并持久化。
Future<void> setLocale(AppLocale locale) async {
if (state == locale) return;
state = locale;
await StoreLocalCache.instance.setString(_storageKey, locale.storageKey);
}
}
/// 语言提供者实例。
final localeProvider = NotifierProvider<LocaleNotifier, AppLocale>(
LocaleNotifier.new,
);

View File

@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 持久化键:是否显示设备详情页的运行指标(CPU / 内存 / 磁盘 / 网络)。
const String _kShowRuntimeMetrics = 'show_runtime_metrics';
/// 读取已保存的显示偏好;默认返回 false(即默认隐藏运行指标)。
Future<bool> loadSavedShowRuntimeMetrics() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kShowRuntimeMetrics) ?? false;
} catch (_) {
return false;
}
}
/// 设备详情页运行指标显示偏好管理。
///
/// - 默认关闭,首次进入设备详情时不展示 CPU/内存/磁盘/网络卡片。
/// - 用户可在「我的 → 设置」中手动开启。
/// - 状态持久化到 [SharedPreferences],切换账号/重启 App 后仍保持。
class ShowRuntimeMetricsNotifier extends Notifier<bool> {
@override
bool build() {
_load();
return false;
}
Future<void> _load() async {
final saved = await loadSavedShowRuntimeMetrics();
if (saved != state) {
state = saved;
}
}
/// 切换显示状态并持久化。
Future<void> setValue(bool value) async {
state = value;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowRuntimeMetrics, value);
} catch (_) {
// 忽略持久化失败,运行时状态仍生效。
}
}
}
/// 运行指标显示偏好提供者。
final showRuntimeMetricsProvider = NotifierProvider<ShowRuntimeMetricsNotifier, bool>(
ShowRuntimeMetricsNotifier.new,
);

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 主题模式管理,默认跟随系统(logo 明度均值 245/255,「明亮轻盈」才是品牌主场)。
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() => ThemeMode.system;
/// 设置主题模式。
void setThemeMode(ThemeMode mode) {
state = mode;
}
/// 在浅色 / 深色之间切换。
void toggle() {
state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
}
}
/// 主题模式提供者。
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(
ThemeModeNotifier.new,
);

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
import '../../utils/i10n.dart';
import '../../utils/permissions.dart';
import '../../utils/responsive.dart';
/// 管理中心页(底部导航第 4 个 tab):各管理模块入口。
///
/// 模块可见性由 [SessionPermissions] 统一驱动,与 [router.dart] 的路由可达性
/// 保持一致:
/// - 用户管理:所有已登录账号可见(数据范围由后端按会话权限收敛);
/// - 渠道商管理:管理员 + 渠道商可见,代理商不可见;
/// - 其余模块(设备/激活码/公司介绍/滚动字幕)对所有账号可见。
class AdminHubPage extends ConsumerWidget {
const AdminHubPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = AppLocalizations.of(context);
// 注:按需求「设备分组」整合进设备管理功能(设备详情/设备列表内可改分组),
// 「应用管理」移除以简化管理页。故网格不再单列这两项。
final scheme = Theme.of(context).colorScheme;
final user = ref.watch(authProvider).user;
// 菜单可见性与路由可达性由同一套 SessionPermissions 谓词驱动:
// - 用户管理:所有已登录账号(各角色看自己范围内用户);
// - 渠道商管理:管理员 + 渠道商(代理商不可见,路由层亦会拦截其深链)。
final showUserManagement = SessionPermissions.canViewUserManagement(user);
final showChannelManagement =
SessionPermissions.canViewChannelManagement(user);
final items = [
_MenuItem(
title: t.deviceManagement,
icon: Icons.devices_outlined,
color: scheme.primary,
route: '/admin/device',
),
_MenuItem(
title: t.activationCode,
icon: Icons.vpn_key_outlined,
color: Colors.amber,
route: '/admin/activation',
),
if (showUserManagement)
_MenuItem(
title: t.userManagement,
icon: Icons.people_outlined,
color: scheme.outline,
route: '/admin/sys/user',
),
_MenuItem(
title: t.companyIntro,
icon: Icons.business_outlined,
color: Colors.teal,
route: '/admin/intro/store-list',
),
_MenuItem(
title: t.marqueeManagement,
icon: Icons.subtitles_outlined,
color: Colors.deepPurple,
route: '/admin/marquee',
),
if (showChannelManagement)
_MenuItem(
title: t.channelManagement,
icon: Icons.account_tree_outlined,
color: Colors.blue,
route: '/admin/channel',
),
];
return Scaffold(
appBar: AppBar(title: Text(t.admin)),
body: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
child: InkWell(
onTap: () => context.push(item.route),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 40, color: item.color),
const SizedBox(height: 8),
Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
);
},
),
);
}
}
class _MenuItem {
const _MenuItem({
required this.title,
required this.icon,
required this.color,
required this.route,
});
final String title;
final IconData icon;
final Color color;
final String route;
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/app_version.dart';
import '../../../services/app_service.dart';
import '../../../services/api_client.dart';
import '../../../utils/formatters.dart';
/// 应用版本管理页。
class AppVersionPage extends ConsumerStatefulWidget {
const AppVersionPage({super.key});
@override
ConsumerState<AppVersionPage> createState() => _AppVersionPageState();
}
class _AppVersionPageState extends ConsumerState<AppVersionPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
List<AppVersion> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await AppService().getAppVersionList(page: 1, limit: 20);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = _friendlyError(e);
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final list = await AppService().getAppVersionList(page: _page + 1, limit: 20);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
String _friendlyError(Object e) {
if (e is ApiException) return e.message;
final msg = e.toString();
if (msg.contains('404') ||
msg.contains('DioException') ||
msg.contains('SocketException')) {
return '应用版本接口暂不可用,请联系管理员确认权限或接口地址';
}
return '加载失败:$msg';
}
void _showForm({AppVersion? editing}) {
final isEdit = editing != null;
final appNameCtrl = TextEditingController(text: editing?.appName ?? '');
final packageCtrl = TextEditingController(text: editing?.packageName ?? '');
final versionNameCtrl = TextEditingController(text: editing?.versionName ?? '');
final versionCodeCtrl =
TextEditingController(text: editing?.versionCode.toString() ?? '');
final apkUrlCtrl = TextEditingController(text: editing?.apkUrl ?? '');
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑版本' : '新增版本',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: appNameCtrl,
decoration: const InputDecoration(
labelText: '应用名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: packageCtrl,
decoration: const InputDecoration(
labelText: '包名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: versionNameCtrl,
decoration: const InputDecoration(
labelText: '版本名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: versionCodeCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '版本号', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: apkUrlCtrl,
decoration: const InputDecoration(
labelText: 'APK下载地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: orgIdCtrl,
decoration: const InputDecoration(
labelText: '组织ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: groupIdCtrl,
decoration: const InputDecoration(
labelText: '分组ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'appName': appNameCtrl.text,
'packageName': packageCtrl.text,
'versionName': versionNameCtrl.text,
'versionCode': int.tryParse(versionCodeCtrl.text) ?? 0,
'apkUrl': apkUrlCtrl.text,
'orgId': orgIdCtrl.text,
'groupId': groupIdCtrl.text,
'status': statusValue,
};
try {
if (isEdit) {
await AppService().updateAppVersion(data);
} else {
await AppService().saveAppVersion(data);
}
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此版本吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await AppService().deleteAppVersion([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('应用版本管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索应用名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onSubmitted: (v) => setState(() => _keyword = v),
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: _items.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[i];
if (_keyword.isNotEmpty &&
!item.appName.toLowerCase().contains(_keyword.toLowerCase())) {
return const SizedBox.shrink();
}
return Card(
child: ListTile(
title: Text(item.appName,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('包名: ${item.packageName}'),
Text('版本: ${item.versionName} (${item.versionCode})'),
if (item.createTime != null)
Text('创建: ${Formatters.formatDateTime(item.createTime!)}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/tv_app.dart';
import '../../../services/app_service.dart';
import '../../../services/api_client.dart';
/// TV端应用管理页。
class TvAppPage extends ConsumerStatefulWidget {
const TvAppPage({super.key});
@override
ConsumerState<TvAppPage> createState() => _TvAppPageState();
}
class _TvAppPageState extends ConsumerState<TvAppPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
List<TvApp> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: 1, limit: 20, appName: kw);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: _page + 1, limit: 20, appName: kw);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
void _showForm({TvApp? editing}) {
final isEdit = editing != null;
final nameCtrl = TextEditingController(text: editing?.appName ?? '');
final pkgCtrl = TextEditingController(text: editing?.packageName ?? '');
final catCtrl = TextEditingController(text: editing?.appCategory ?? '');
final iconCtrl = TextEditingController(text: editing?.iconUrl ?? '');
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑应用' : '新增应用',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '应用名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: pkgCtrl,
decoration: const InputDecoration(
labelText: '包名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: catCtrl,
decoration: const InputDecoration(
labelText: '分类', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: iconCtrl,
decoration: const InputDecoration(
labelText: '图标地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: orgIdCtrl,
decoration: const InputDecoration(
labelText: '组织ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: groupIdCtrl,
decoration: const InputDecoration(
labelText: '分组ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'appName': nameCtrl.text,
'packageName': pkgCtrl.text,
'appCategory': catCtrl.text,
'iconUrl': iconCtrl.text,
'orgId': orgIdCtrl.text,
'groupId': groupIdCtrl.text,
'status': statusValue,
};
try {
await AppService().saveTvApp(data);
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此应用吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await AppService().deleteTvApp([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('TV端应用管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索应用名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
_refresh();
},
),
border: const OutlineInputBorder(),
),
onSubmitted: (v) {
setState(() => _keyword = v);
_refresh();
},
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: _items.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[i];
return Card(
child: ListTile(
leading: item.iconUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
item.iconUrl,
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.apps, size: 40),
),
)
: const CircleAvatar(child: Icon(Icons.apps)),
title: Text(item.appName,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('包名: ${item.packageName}'),
if (item.appCategory.isNotEmpty)
Text('分类: ${item.appCategory}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,894 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/org_node.dart';
import '../../../services/api_client.dart';
import '../../../services/org_service.dart';
import '../../../utils/formatters.dart';
import '../../../utils/i10n.dart';
import 'channel_version_manage_dialog.dart';
/// 渠道商管理页:渠道 → 代理/子代理 树形 CRUD。
///
/// 功能结构、布局与交互逻辑对齐 Web 后台 `org.vue`(screen-front
/// `views/modules/org/org.vue`):
/// - 列表:渠道 + 代理树形展示,默认展开顶层渠道;
/// - 搜索:按名称模糊匹配;
/// - 筛选:类型(全部/渠道/代理)+ 状态(全部/启用/禁用),与 Web 一致;
/// - 详情:点按行打开底部详情卡,展示全部字段;
/// - 新增/编辑/新增子级/删除:底部表单或确认弹窗;
/// - 删除守卫:存在子集时禁止删除(Web "存在子集时不允许删除");
/// - 移动端适配:点按区域放大、`Dismissible` 左滑删除手势。
///
/// 数据来自 [OrgService.getChannelTree](组合 /sys/org/channels 与
/// /sys/org/channelAgentTree),增删改复用既有组织接口
/// [OrgService.createOrg] / [updateOrg] / [deleteOrg]。
class ChannelManagementPage extends ConsumerStatefulWidget {
const ChannelManagementPage({super.key});
@override
ConsumerState<ChannelManagementPage> createState() =>
_ChannelManagementPageState();
}
class _ChannelManagementPageState
extends ConsumerState<ChannelManagementPage> {
List<OrgNode> _tree = [];
bool _loading = false;
String? _error;
final _searchController = TextEditingController();
String _keyword = '';
int? _typeFilter;
String? _statusFilter;
final Set<String> _expanded = {};
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final tree = await OrgService().getChannelTree();
if (!mounted) return;
// 默认展开顶层渠道,便于一眼看到其下代理。
final autoExpand = tree.map((n) => n.id).toSet();
setState(() {
_tree = tree;
_loading = false;
_expanded.addAll(autoExpand);
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = formatApiError(e);
});
}
}
/// 计算当前应展示的扁平行(含层级 depth 与父级名称)。
///
/// - 无筛选(关键词 / 类型 / 状态都为空):按 [_expanded] 折叠/展开;
/// - 有筛选:忽略展开态,递归展开全部并仅显示命中节点(父未命中但子命中时
/// 子仍可见),确保筛选结果不被折叠隐藏。
List<_ChannelRow> _computeRows() {
final rows = <_ChannelRow>[];
final kw = _keyword.trim().toLowerCase();
final filterActive =
kw.isNotEmpty || _typeFilter != null || _statusFilter != null;
bool pass(OrgNode n) {
if (kw.isNotEmpty && !n.name.toLowerCase().contains(kw)) return false;
if (_typeFilter != null && n.type != _typeFilter) return false;
if (_statusFilter != null &&
n.status.toUpperCase() != _statusFilter) {
return false;
}
return true;
}
void walk(List<OrgNode> nodes, int depth, String? parentName) {
for (final n in nodes) {
if (filterActive ? pass(n) : true) {
rows.add(
_ChannelRow(node: n, depth: depth, parentName: parentName),
);
}
final childrenVisible =
n.children.isNotEmpty && (filterActive || _expanded.contains(n.id));
if (childrenVisible) walk(n.children, depth + 1, n.name);
}
}
walk(_tree, 0, null);
return rows;
}
void _toggleExpand(OrgNode node) {
if (node.children.isEmpty) return;
setState(() {
if (_expanded.contains(node.id)) {
_expanded.remove(node.id);
} else {
_expanded.add(node.id);
}
});
}
/// 渠道/代理可新增子级(与 Web [canShowAddChildForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
bool _canAddChild(OrgNode n) => n.type == 1 || n.type == 2;
/// 可下钻版本管理(与 Web [canShowVersionForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
bool _canShowVersion(OrgNode n) => n.type == 1 || n.type == 2;
Color _typeColor(BuildContext context, int type) {
if (type == 1) return Colors.green;
if (type == 2) return Colors.orange;
return Theme.of(context).colorScheme.primary;
}
/// 删除节点(含确认弹窗)。存在子集时直接拦截并提示,与 Web 一致。
///
/// 返回是否删除成功;[skipConfirm] 用于滑动手势等已显式触发的场景。
Future<bool> _delete(OrgNode node, {bool skipConfirm = false}) async {
final t = AppLocalizations.of(context);
if (node.children.isNotEmpty) {
_snack(t.channelCannotDeleteWithChildren);
return false;
}
if (!skipConfirm) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.deleteConfirmContent(node.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return false;
}
try {
await OrgService().deleteOrg(node.id);
if (!mounted) return false;
_load();
_snack(t.deleted);
return true;
} catch (e) {
if (!mounted) return false;
_snack(e is ApiException ? e.message : t.deleteFailed);
return false;
}
}
/// 底部详情卡:展示节点全部字段 + 操作入口(新增子级/编辑/删除)。
void _showDetail(OrgNode node, String? parentName) {
final t = AppLocalizations.of(context);
final hasChildren = node.children.isNotEmpty;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
expand: false,
builder: (sheetCtx, scrollCtrl) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: Text(
node.name,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_DetailChip(label: node.typeLabel, color: _typeColor(ctx, node.type)),
const SizedBox(width: 8),
_DetailChip(
label: node.isDisabled ? t.channelDisabled : t.channelEnabled,
color: node.isDisabled ? Colors.red : Colors.green,
),
],
),
),
const Divider(height: 20),
Expanded(
child: ListView(
controller: scrollCtrl,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_DetailRow(t.channelOrgCode, node.orgCodeLabel),
_DetailRow(t.channelSort, node.orderNo.toString()),
_DetailRow(t.channelParent, parentName ?? '-'),
_DetailRow(
t.channelContactPhone,
node.contactPhone.isEmpty ? '-' : node.contactPhone,
),
_DetailRow(
t.channelAddress,
node.address.isEmpty ? '-' : node.address,
),
if (node.createdAt != null)
_DetailRow(
t.createTime,
Formatters.formatDateTime(node.createdAt!),
),
if (node.expireTime != null)
_DetailRow(
t.channelExpireTime,
Formatters.formatDate(node.expireTime!),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _canShowVersion(node)
? SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
showChannelVersionManageDialog(context, node: node);
},
icon: const Icon(Icons.system_update_alt),
label: Text(t.channelVersionManage),
),
)
: const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
if (_canAddChild(node))
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
_showForm(parent: node);
},
icon: const Icon(Icons.add),
label: Text(t.addSubChannel),
),
),
if (_canAddChild(node)) const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
_showForm(editing: node);
},
icon: const Icon(Icons.edit),
label: Text(t.edit),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(ctx).colorScheme.errorContainer,
foregroundColor:
Theme.of(ctx).colorScheme.onErrorContainer,
),
onPressed: hasChildren
? null
: () {
Navigator.pop(ctx);
_delete(node);
},
icon: const Icon(Icons.delete),
label: Text(t.delete),
),
),
],
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final rows = _computeRows();
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.channelManagement),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(168),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchChannelHint,
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onChanged: (v) => setState(() => _keyword = v),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
SegmentedButton<int?>(
segments: [
ButtonSegment(value: null, label: Text(t.all)),
ButtonSegment(
value: 1,
label: Text(t.channelTypeChannel),
),
ButtonSegment(
value: 2,
label: Text(t.channelTypeAgent),
),
],
selected: {_typeFilter},
onSelectionChanged: (set) =>
setState(() => _typeFilter = set.first),
),
SegmentedButton<String?>(
segments: [
ButtonSegment(value: null, label: Text(t.all)),
ButtonSegment(
value: 'ACTIVE',
label: Text(t.channelEnabled),
),
ButtonSegment(
value: 'DISABLED',
label: Text(t.channelDisabled),
),
],
selected: {_statusFilter},
onSelectionChanged: (set) =>
setState(() => _statusFilter = set.first),
),
],
),
),
],
),
),
),
),
body: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _load, child: Text(t.retry)),
],
),
)
: _loading && _tree.isEmpty
? const Center(child: CircularProgressIndicator())
: rows.isEmpty
? Center(child: Text(t.noData))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: rows.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final row = rows[i];
return Dismissible(
key: Key(row.node.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: Theme.of(context).colorScheme.error,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(Icons.delete, color: Colors.white),
const SizedBox(width: 8),
Text(
t.channelSwipeToDelete,
style: const TextStyle(color: Colors.white),
),
],
),
),
confirmDismiss: (_) => _delete(
row.node,
skipConfirm: true,
),
child: _ChannelRowTile(
row: row,
isExpanded: _expanded.contains(row.node.id),
onToggle: _toggleExpand,
onDetail: _showDetail,
onEdit: (n) => _showForm(editing: n),
onAddChild: (n) => _showForm(parent: n),
onVersionManage: (n) =>
showChannelVersionManageDialog(
context,
node: n,
),
onDelete: _delete,
),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
tooltip: t.addChannel,
child: const Icon(Icons.add),
),
);
}
/// 新增/编辑/新增子级 的底部表单。
void _showForm({OrgNode? editing, OrgNode? parent}) {
final t = AppLocalizations.of(context);
final isEdit = editing != null;
final isChild = parent != null && !isEdit;
// 类型:编辑沿用原类型;新增子级默认代理(SERVICE_PROVIDER);
// 新增顶层渠道默认渠道(CHANNEL)。
int typeValue = editing?.type ?? (isChild ? 2 : 1);
final nameCtrl = TextEditingController(text: editing?.name ?? '');
final orderCtrl = TextEditingController(
text: editing?.orderNo.toString() ?? '0',
);
final phoneCtrl =
TextEditingController(text: editing?.contactPhone ?? '');
final addressCtrl = TextEditingController(text: editing?.address ?? '');
int statusValue = editing?.isDisabled == true ? 1 : 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isEdit
? t.editChannel
: isChild
? t.addSubChannel
: t.addChannel,
style: Theme.of(ctx).textTheme.titleMedium,
),
const SizedBox(height: 16),
if (isChild)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'${t.channelParent}:${parent!.name}',
style: Theme.of(ctx)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(ctx).hintColor),
),
),
TextField(
controller: nameCtrl,
decoration: InputDecoration(
labelText: t.channelName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: typeValue,
decoration: InputDecoration(
labelText: t.channelType,
border: const OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 1, child: Text('渠道')),
DropdownMenuItem(value: 2, child: Text('代理')),
],
onChanged: (v) {
if (v != null) setSheet(() => typeValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: orderCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.channelSort,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: InputDecoration(
labelText: t.channelStatus,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: 0, child: Text(t.channelEnabled)),
DropdownMenuItem(value: 1, child: Text(t.channelDisabled)),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: phoneCtrl,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: t.channelContactPhone,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: addressCtrl,
decoration: InputDecoration(
labelText: t.channelAddress,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () async {
final name = nameCtrl.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.channelNameRequired)),
);
return;
}
final typeString =
typeValue == 1 ? 'CHANNEL' : 'SERVICE_PROVIDER';
final statusString =
statusValue == 0 ? 'ACTIVE' : 'DISABLED';
final data = <String, dynamic>{
'name': name,
'type': typeString,
'orderNo':
int.tryParse(orderCtrl.text.trim()) ?? 0,
'status': statusString,
'contactPhone': phoneCtrl.text.trim(),
'address': addressCtrl.text.trim(),
if (isEdit) 'id': editing?.id,
if (isChild) 'parentId': parent?.id,
};
try {
if (isEdit) {
await OrgService().updateOrg(data);
} else {
await OrgService().createOrg(data);
}
if (!ctx.mounted) return;
Navigator.pop(ctx);
_load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
e is ApiException ? e.message : t.saveFailed,
),
),
);
}
},
child: Text(t.save),
),
),
],
),
],
),
),
),
),
),
);
}
}
/// 渠道树的一行(节点 + 层级深度 + 父级名称)。
class _ChannelRow {
const _ChannelRow({
required this.node,
required this.depth,
this.parentName,
});
final OrgNode node;
final int depth;
final String? parentName;
}
/// 渠道行卡片:层级缩进 + 展开按钮 + 类型/状态徽章 + 点按查看详情 + 操作菜单。
class _ChannelRowTile extends StatelessWidget {
const _ChannelRowTile({
required this.row,
required this.isExpanded,
required this.onToggle,
required this.onDetail,
required this.onEdit,
required this.onAddChild,
required this.onVersionManage,
required this.onDelete,
});
final _ChannelRow row;
final bool isExpanded;
final void Function(OrgNode) onToggle;
final void Function(OrgNode, String?) onDetail;
final void Function(OrgNode) onEdit;
final void Function(OrgNode) onAddChild;
final void Function(OrgNode) onVersionManage;
final Future<bool> Function(OrgNode, {bool skipConfirm}) onDelete;
Color _typeColor(BuildContext context, int type) {
if (type == 1) return Colors.green;
if (type == 2) return Colors.orange;
return Theme.of(context).colorScheme.primary;
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final node = row.node;
final hasChildren = node.children.isNotEmpty;
return Row(
children: [
if (hasChildren)
IconButton(
icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
visualDensity: VisualDensity.compact,
onPressed: () => onToggle(node),
)
else
const SizedBox(width: 40),
Expanded(
child: InkWell(
// 点按行主体打开详情(清晰的大点按区域,触控友好)。
onTap: () => onDetail(node, row.parentName),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
node.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
),
Chip(
label: Text(node.typeLabel),
visualDensity: VisualDensity.compact,
backgroundColor:
_typeColor(context, node.type).withOpacity(0.15),
labelStyle: TextStyle(
color: _typeColor(context, node.type),
fontSize: 12,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: node.isDisabled
? Colors.red.withOpacity(0.15)
: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
node.isDisabled
? t.channelDisabled
: t.channelEnabled,
style: TextStyle(
fontSize: 12,
color: node.isDisabled ? Colors.red : Colors.green,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${t.channelOrgCode}:${node.orgCodeLabel} · '
'${t.channelSort}:${node.orderNo}'
'${node.createdAt != null ? ' · ${t.createTime}:${Formatters.formatDateTime(node.createdAt!)}' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'add') onAddChild(node);
if (v == 'edit') onEdit(node);
if (v == 'version') onVersionManage(node);
if (v == 'delete') onDelete(node);
},
itemBuilder: (_) => [
if (node.type == 1 || node.type == 2)
PopupMenuItem(value: 'add', child: Text(t.addSubChannel)),
PopupMenuItem(value: 'edit', child: Text(t.edit)),
if (node.type == 1 || node.type == 2)
PopupMenuItem(
value: 'version',
child: Text(t.channelVersionManage),
),
PopupMenuItem(
value: 'delete',
enabled: !hasChildren,
child: Text(
hasChildren ? '${t.delete}(有子集)' : t.delete,
style: TextStyle(
color: hasChildren
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.error,
),
),
),
],
),
],
);
}
}
/// 详情卡:字段行。
class _DetailRow extends StatelessWidget {
const _DetailRow(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// 详情卡:类型/状态徽章。
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(fontSize: 12, color: color),
),
);
}
}

View File

@@ -0,0 +1,435 @@
import 'package:flutter/material.dart';
import '../../../models/org_node.dart';
import '../../../services/api_client.dart';
import '../../../services/app_service.dart';
import '../../../services/upload_service.dart';
import '../../../utils/i10n.dart';
/// 渠道 / 代理节点的 App 版本管理对话框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue`:每个组织(scope=ORG)
/// 至多维护一条版本配置,进入时按 [OrgNode.id] 拉取当前版本——
/// 有则进入「编辑」流程(含删除按钮),无则进入「新增」流程。
///
/// 字段与 Web 完全一致:版本名 / 版本号 / 下载地址 / 强制更新 / 弹窗提示;
/// 下载地址支持手动录入,或点击「上传文件」按钮直传 APK(对齐 Web 后台的 el-upload,
/// 经 /theme/upload/credential 取 STS 凭证直传 COS),上传成功后自动回填 URL。
///
/// 用法:
/// ```dart
/// showChannelVersionManageDialog(context, node: node);
/// ```
Future<void> showChannelVersionManageDialog(
BuildContext context, {
required OrgNode node,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (_) => _ChannelVersionManageSheet(node: node),
);
}
class _ChannelVersionManageSheet extends StatefulWidget {
const _ChannelVersionManageSheet({required this.node});
final OrgNode node;
@override
State<_ChannelVersionManageSheet> createState() =>
_ChannelVersionManageSheetState();
}
class _ChannelVersionManageSheetState
extends State<_ChannelVersionManageSheet> {
final _nameCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
/// 当前版本 ID(非空表示编辑态)。
String? _versionId;
bool _forceUpdate = false;
bool _showPopup = true;
bool _loading = true;
bool _submitting = false;
/// APK 上传中标记,上传期间禁用 URL 手动输入并禁用上传按钮。
bool _uploading = false;
/// APK 上传进度(0~100),仅在 [_uploading] 为 true 时展示。
int _uploadProgress = 0;
AppLocalizations get t => AppLocalizations.of(context);
@override
void initState() {
super.initState();
_loadCurrent();
}
@override
void dispose() {
_nameCtrl.dispose();
_codeCtrl.dispose();
_urlCtrl.dispose();
super.dispose();
}
/// 进入时拉取该组织的当前版本配置,有则回填表单(编辑态),无则空表单(新增态)。
Future<void> _loadCurrent() async {
try {
final data = await AppService().searchAppVersion(orgId: widget.node.id);
if (!mounted) return;
if (data != null) {
_versionId = data['id']?.toString();
_nameCtrl.text = data['versionName']?.toString() ?? '';
final rawCode = data['versionCode'] ?? data['version_code'];
_codeCtrl.text = rawCode?.toString() ?? '';
_urlCtrl.text = (data['downloadUrl'] ?? data['download_url'] ?? data['apkUrl'])
?.toString() ??
'';
_forceUpdate = _toBool(data['forceUpdate'] ?? data['force_update']);
_showPopup = _toBool(data['showPopup'] ?? data['show_popup'], fallback: true);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
bool _toBool(dynamic v, {bool fallback = false}) {
if (v == null) return fallback;
final n = int.tryParse(v.toString()) ?? 0;
return n == 1;
}
String _errText(Object? e) =>
e is ApiException ? e.message : (t.loadFailed);
String get _title {
final name = widget.node.name.trim();
final action = _versionId != null ? t.editVersion : t.addVersion;
return name.isNotEmpty ? '$action - $name' : action;
}
Future<void> _submit() async {
final name = _nameCtrl.text.trim();
final codeText = _codeCtrl.text.trim();
final url = _urlCtrl.text.trim();
if (name.isEmpty) {
_snack(t.versionNameRequired);
return;
}
final code = int.tryParse(codeText);
if (code == null || code <= 0) {
_snack(t.versionCodeRequired);
return;
}
if (url.isEmpty) {
_snack(t.versionUrlRequired);
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
_snack(t.versionUrlInvalid);
return;
}
setState(() => _submitting = true);
final payload = <String, dynamic>{
'scope': 'ORG',
'orgId': widget.node.id,
'versionName': name,
'versionCode': code,
'downloadUrl': url,
'forceUpdate': _forceUpdate ? 1 : 0,
'showPopup': _showPopup ? 1 : 0,
if (_versionId != null) 'id': _versionId,
};
try {
if (_versionId != null) {
await AppService().updateAppVersion(payload);
} else {
await AppService().saveAppVersion(payload);
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _delete() async {
final id = _versionId;
if (id == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.versionDeleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return;
setState(() => _submitting = true);
try {
await AppService().deleteAppVersion([int.parse(id)]);
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.versionDeleted)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
/// 选择 .apk 文件并直传腾讯云 COS,成功后把返回 URL 填回下载地址输入框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue` 的「上传文件」按钮:
/// 点击 → 系统文件选择器(仅 .apk)→ 选完即传 → 回填 downloadUrl。
Future<void> _pickAndUpload() async {
// 1. 打开文件选择器,仅允许 .apk;用户取消返回 null。
final picked = await UploadService.pickApkFile();
if (!mounted) return;
if (picked == null) return;
// 2. 校验扩展名(选择器已限制,这里兜底)。
if (!picked.name.toLowerCase().endsWith('.apk')) {
_snack(t.pleaseSelectApk);
return;
}
// 3. 进入上传态,统一在 finally 复位,避免 async 后 setState 崩溃。
setState(() => _uploading = true);
try {
final url = await UploadService().uploadApkByOriginalName(
picked,
onProgress: (p) {
if (mounted) setState(() => _uploadProgress = p);
},
);
if (!mounted) return;
// 4. 成功:回填下载地址并提示。
_urlCtrl.text = url;
_snack(t.uploadSuccess);
} catch (e) {
if (!mounted) return;
_snack('${t.uploadFailed}:${_errText(e)}');
} finally {
if (mounted) {
setState(() {
_uploading = false;
_uploadProgress = 0;
});
}
}
}
@override
Widget build(BuildContext context) {
final isEdit = _versionId != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
_title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _loading
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: CircularProgressIndicator(),
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameCtrl,
decoration: InputDecoration(
labelText: t.versionName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.versionCode,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
enabled: !_uploading,
decoration: InputDecoration(
labelText: t.downloadUrlHint,
border: const OutlineInputBorder(),
suffixIcon: TextButton.icon(
onPressed: _uploading ? null : _pickAndUpload,
icon: const Icon(Icons.upload),
label: _uploading
? Text('${t.uploading} $_uploadProgress%')
: Text(t.uploadFile),
),
),
),
const SizedBox(height: 16),
_SwitchRow(
label: t.forceUpdate,
value: _forceUpdate,
onChanged: (v) => setState(() => _forceUpdate = v),
),
const SizedBox(height: 8),
_SwitchRow(
label: t.showPopup,
value: _showPopup,
onChanged: (v) => setState(() => _showPopup = v),
),
const SizedBox(height: 20),
Row(
children: [
if (isEdit)
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _delete,
icon: const Icon(Icons.delete),
style: OutlinedButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
),
label: Text(t.delete),
),
),
if (isEdit) const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _submitting
? null
: () => Navigator.pop(context),
child: Text(t.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(isEdit ? t.save : t.add),
),
),
],
),
const SizedBox(height: 16),
],
),
),
],
),
),
);
}
}
/// 简易「是/否」开关行(对齐 Web 的 radio 否/是)。
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Row(
children: [
SizedBox(
width: 84,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(t.forceUpdateNo)),
ButtonSegment(value: true, label: Text(t.forceUpdateYes)),
],
selected: {value},
onSelectionChanged: (set) => onChanged(set.first),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/store_info.dart';
import '../../../services/api_client.dart';
import '../../../services/org_service.dart';
import '../../../utils/formatters.dart';
import '../../../utils/i10n.dart';
/// 公司介绍 - 门店列表入口页。
///
/// 以门店列表形式展示各门店,支持按关键字搜索门店;点击门店进入该门店的
/// 公司介绍内容页(按内容类型分类呈现)。
class IntroStoreListPage extends ConsumerStatefulWidget {
const IntroStoreListPage({super.key});
@override
ConsumerState<IntroStoreListPage> createState() => _IntroStoreListPageState();
}
class _IntroStoreListPageState extends ConsumerState<IntroStoreListPage> {
final _orgService = OrgService();
final _searchController = TextEditingController();
List<StoreInfo> _allStores = [];
List<StoreInfo> _stores = [];
bool _loading = false;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final items = await _orgService.getMyStores();
if (!mounted) return;
_allStores = items;
_applyFilter();
setState(() => _loading = false);
} catch (e) {
if (!mounted) return;
setState(() {
_error = formatApiError(e, fallback: '门店加载失败,请稍后重试');
_loading = false;
});
}
}
void _applyFilter() {
final keyword = _keyword.trim().toLowerCase();
if (keyword.isEmpty) {
_stores = List.of(_allStores);
} else {
_stores = _allStores
.where((s) => s.name.toLowerCase().contains(keyword))
.toList();
}
// 与门店管理页保持一致:按创建时间倒序(最新的排最前面)。
_stores.sort((a, b) {
if (a.createTime != null && b.createTime != null) {
return b.createTime!.compareTo(a.createTime!);
}
if (a.createTime == null && b.createTime != null) return 1;
if (a.createTime != null && b.createTime == null) return -1;
return b.orgId.compareTo(a.orgId);
});
}
void _onSearch() {
setState(() {
_keyword = _searchController.text;
_applyFilter();
});
}
void _onStoreTap(StoreInfo store) {
context.push('/admin/intro/store-content/${store.orgId}', extra: store);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.introCategoryTitle)),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchStoreHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_keyword = '';
_applyFilter();
});
},
),
),
onSubmitted: (_) => _onSearch(),
),
),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildBody() {
final t = AppLocalizations.of(context);
if (_loading && _stores.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _stores.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${t.loadFailed}:$_error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadData,
child: Text(t.retry),
),
],
),
);
}
if (_stores.isEmpty) {
return RefreshIndicator(
onRefresh: _loadData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 200),
Center(child: Text(t.noData)),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _stores.length,
itemBuilder: (context, index) {
final store = _stores[index];
return _StoreListTile(
store: store,
onTap: () => _onStoreTap(store),
);
},
),
);
}
}
/// 门店列表项:复用门店管理页的视觉风格。
class _StoreListTile extends StatelessWidget {
const _StoreListTile({required this.store, required this.onTap});
final StoreInfo store;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = AppLocalizations.of(context);
final isDisabled = store.status == 1;
final statusColor = isDisabled ? Colors.grey : Colors.green;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
onTap: onTap,
title: Row(
children: [
Expanded(
child: Text(
store.name,
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: statusColor.withOpacity(0.6)),
),
child: Text(
isDisabled ? t.statusDisabled : t.statusNormal,
style: TextStyle(
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
if (store.address.isNotEmpty)
_infoLine(context, Icons.location_on, store.address),
if (store.contactPhone.isNotEmpty)
_infoLine(context, Icons.phone, store.contactPhone),
if (store.expireTime != null)
_infoLine(
context,
Icons.event,
'${t.expireTime}:${Formatters.formatDateTime(store.expireTime!)}',
),
],
),
trailing: const Icon(Icons.chevron_right),
),
);
}
Widget _infoLine(BuildContext context, IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(icon, size: 14, color: Theme.of(context).colorScheme.outline),
const SizedBox(width: 4),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/media_asset.dart';
import '../../../services/media_service.dart';
import '../../../services/api_client.dart';
/// 媒资管理页。
class MediaListPage extends ConsumerStatefulWidget {
const MediaListPage({super.key});
@override
ConsumerState<MediaListPage> createState() => _MediaListPageState();
}
class _MediaListPageState extends ConsumerState<MediaListPage> {
final _scrollController = ScrollController();
final _searchController = TextEditingController();
List<MediaAsset> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await MediaService().getMediaAssetList(page: 1, limit: 20);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final list = await MediaService().getMediaAssetList(page: _page + 1, limit: 20);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
void _showForm({MediaAsset? editing}) {
final isEdit = editing != null;
final nameCtrl = TextEditingController(text: editing?.name ?? '');
final codeCtrl = TextEditingController(text: editing?.code ?? '');
final coverCtrl = TextEditingController(text: editing?.coverUrl ?? '');
final playCtrl = TextEditingController(text: editing?.playUrl ?? '');
final durationCtrl =
TextEditingController(text: editing?.duration.toString() ?? '');
String typeValue = editing?.type.isNotEmpty == true ? editing!.type : 'video';
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑媒资' : '新增媒资',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: codeCtrl,
decoration: const InputDecoration(
labelText: '编码', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: typeValue,
decoration: const InputDecoration(
labelText: '类型', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 'video', child: Text('影视')),
DropdownMenuItem(value: 'audio', child: Text('音乐')),
DropdownMenuItem(value: 'image', child: Text('图片')),
],
onChanged: (v) {
if (v != null) setSheet(() => typeValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: coverCtrl,
decoration: const InputDecoration(
labelText: '封面地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: playCtrl,
decoration: const InputDecoration(
labelText: '播放地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: durationCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '时长(秒)', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'name': nameCtrl.text,
'code': codeCtrl.text,
'type': typeValue,
'coverUrl': coverCtrl.text,
'playUrl': playCtrl.text,
'duration': int.tryParse(durationCtrl.text) ?? 0,
'status': statusValue,
};
try {
await MediaService().saveMediaAsset(data);
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此媒资吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().deleteMediaAsset([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
String _typeLabel(String type) {
switch (type.toLowerCase()) {
case 'video':
return '影视';
case 'audio':
return '音乐';
case 'image':
return '图片';
default:
return type;
}
}
String _formatDuration(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) return '${h}h${m.toString().padLeft(2, '0')}m';
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final filtered = _keyword.isEmpty
? _items
: _items
.where((e) => e.name.toLowerCase().contains(_keyword.toLowerCase()))
.toList();
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('媒资管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索媒资名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
border: const OutlineInputBorder(),
),
onChanged: (v) => setState(() => _keyword = v),
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: filtered.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filtered.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= filtered.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = filtered[i];
return Card(
child: ListTile(
leading: item.coverUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
item.coverUrl,
width: 50,
height: 70,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(
width: 50,
height: 70,
color: Colors.grey.shade300,
child: const Icon(Icons.video_library, size: 24),
),
),
)
: Container(
width: 50,
height: 70,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.video_library, size: 24),
),
title: Text(item.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('编码: ${item.code}'),
Text('类型: ${_typeLabel(item.type)}'),
if (item.duration > 0)
Text('时长: ${_formatDuration(item.duration)}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/media_asset.dart';
import '../../../services/media_service.dart';
import '../../../services/api_client.dart';
/// 媒资排行榜 Top10 管理页。
class MediaRankPage extends ConsumerStatefulWidget {
const MediaRankPage({super.key});
@override
ConsumerState<MediaRankPage> createState() => _MediaRankPageState();
}
class _MediaRankPageState extends ConsumerState<MediaRankPage> {
List<MediaRankItem> _items = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await MediaService().getMediaAssetRankTop10();
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _moveUp(String code) async {
try {
await MediaService().moveUpMediaAssetRank(code);
_loadData();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
Future<void> _moveDown(String code) async {
try {
await MediaService().moveDownMediaAssetRank(code);
_loadData();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
Future<void> _remove(String code) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认移除'),
content: const Text('确定要从排行榜移除此媒资吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('移除')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().removeMediaAssetRank(code);
_loadData();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('移除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '移除失败')),
);
}
}
Future<void> _clearAll() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认清空'),
content: const Text('确定要清空整个排行榜吗?此操作不可撤销。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('清空')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().clearMediaAssetRank();
_loadData();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
void _showAddDialog() {
final searchCtrl = TextEditingController();
List<MediaAsset> allMedia = [];
bool searching = false;
showModalBottomSheet<void>(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (sheetCtx) {
return StatefulBuilder(
builder: (ctx, setSheet) {
Future<void> doSearch() async {
setSheet(() => searching = true);
try {
allMedia = await MediaService().getMediaAssetList(page: 1, limit: 50);
} catch (_) {
allMedia = [];
} finally {
setSheet(() => searching = false);
}
}
if (allMedia.isEmpty && !searching) {
doSearch();
}
final kw = searchCtrl.text.trim().toLowerCase();
final filtered = kw.isEmpty
? allMedia
: allMedia.where((m) => m.name.toLowerCase().contains(kw)).toList();
final existingCodes = _items.map((e) => e.code).toSet();
final available = filtered.where((m) => !existingCodes.contains(m.code)).toList();
return SizedBox(
height: MediaQuery.of(ctx).size.height * 0.7,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text('选择媒资', style: Theme.of(ctx).textTheme.titleLarge),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: searchCtrl,
decoration: const InputDecoration(
hintText: '搜索媒资名称',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (_) => setSheet(() {}),
),
),
const SizedBox(height: 8),
Expanded(
child: searching
? const Center(child: CircularProgressIndicator())
: available.isEmpty
? const Center(child: Text('无可选媒资'))
: ListView.builder(
itemCount: available.length,
itemBuilder: (_, index) {
final media = available[index];
return ListTile(
leading: media.coverUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
media.coverUrl,
width: 40,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(
width: 40,
height: 56,
child: Icon(Icons.video_library)),
),
)
: const SizedBox(
width: 40,
height: 56,
child: Icon(Icons.video_library),
),
title: Text(media.name),
subtitle: Text(media.code),
trailing: FilledButton.tonal(
onPressed: () => _addToRank(ctx, media.code, media.name),
child: const Text('添加'),
),
);
},
),
),
],
),
);
},
);
},
);
}
Future<void> _addToRank(BuildContext sheetCtx, String code, String name) async {
try {
await MediaService().setMediaAssetRank([code]);
if (!mounted) return;
Navigator.of(sheetCtx).pop();
_loadData();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已添加「$name」到排行榜')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '添加失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('媒资排行榜 Top10'),
actions: [
if (_items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: _clearAll,
tooltip: '清空排行榜',
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _loadData, child: const Text('重试')),
],
))
: _items.isEmpty
? const Center(child: Text('排行榜为空,点击+添加'))
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _items.length,
itemBuilder: (ctx, i) {
final item = _items[i];
final rank = i + 1;
return Card(
child: ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: rank <= 3
? [Colors.amber, Colors.grey, Colors.brown][rank - 1]
: Theme.of(ctx).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$rank',
style: TextStyle(
fontWeight: FontWeight.bold,
color: rank <= 3 ? Colors.white : null,
),
),
),
const SizedBox(width: 8),
if (item.coverUrl.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
item.coverUrl,
width: 36,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(
width: 36,
height: 48,
child: Icon(Icons.video_library, size: 20)),
),
)
else
const SizedBox(
width: 36,
height: 48,
child: Icon(Icons.video_library, size: 20),
),
],
),
title: Text(item.mediaName.isNotEmpty
? item.mediaName
: item.code),
subtitle: Text(item.code),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: i > 0 ? () => _moveUp(item.code) : null,
),
IconButton(
icon: const Icon(Icons.arrow_downward),
onPressed: i < _items.length - 1
? () => _moveDown(item.code)
: null,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _remove(item.code),
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog,
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_config.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 参数配置页。
class ConfigPage extends ConsumerStatefulWidget {
const ConfigPage({super.key});
@override
ConsumerState<ConfigPage> createState() => _ConfigPageState();
}
class _ConfigPageState extends ConsumerState<ConfigPage> {
final SystemService _service = SystemService();
final List<SysConfig> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final TextEditingController _searchController = TextEditingController();
String _searchKeyword = '';
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getConfigList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
void _onSearchChanged(String value) {
_searchKeyword = value;
// 本地过滤,因为 getConfigList 不支持搜索参数
setState(() {});
}
List<SysConfig> get _filteredItems {
if (_searchKeyword.isEmpty) return _items;
return _items
.where((c) => c.paramKey.toLowerCase().contains(
_searchKeyword.toLowerCase(),
))
.toList();
}
Future<void> _showForm({SysConfig? config}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _ConfigForm(config: config),
);
if (result == null) return;
try {
await _service.saveConfig(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteConfig(SysConfig config) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除参数「${config.paramKey}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteConfig([int.tryParse(config.id) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final filtered = _filteredItems;
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('参数配置')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索参数键名',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchKeyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
),
border: const OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
),
Expanded(child: _buildBody(filtered)),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody(List<SysConfig> items) {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: items.length + 1,
itemBuilder: (context, index) {
if (index == items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore && _searchKeyword.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final config = items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(config.paramKey),
subtitle: Text(
'值:${config.paramValue.isEmpty ? "(空)" : config.paramValue}'
'${config.remark.isNotEmpty ? "\n备注:${config.remark}" : ""}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(config: config);
} else if (value == 'delete') {
_deleteConfig(config);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
);
}
}
/// 参数配置表单。
class _ConfigForm extends StatefulWidget {
const _ConfigForm({this.config});
final SysConfig? config;
@override
State<_ConfigForm> createState() => _ConfigFormState();
}
class _ConfigFormState extends State<_ConfigForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _paramKeyCtrl;
late TextEditingController _paramValueCtrl;
late TextEditingController _remarkCtrl;
@override
void initState() {
super.initState();
_paramKeyCtrl = TextEditingController(text: widget.config?.paramKey ?? '');
_paramValueCtrl =
TextEditingController(text: widget.config?.paramValue ?? '');
_remarkCtrl = TextEditingController(text: widget.config?.remark ?? '');
}
@override
void dispose() {
_paramKeyCtrl.dispose();
_paramValueCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'paramKey': _paramKeyCtrl.text.trim(),
'paramValue': _paramValueCtrl.text.trim(),
'remark': _remarkCtrl.text.trim(),
};
if (widget.config != null) {
data['id'] = widget.config!.id;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.config != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑参数' : '新增参数',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _paramKeyCtrl,
decoration: const InputDecoration(
labelText: '参数键名',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入参数键名' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _paramValueCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: '参数值',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入参数值' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
labelText: '备注',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_log.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 系统日志页(TabBar:系统日志 / 审计日志 / JST调用日志)。
class LogPage extends ConsumerStatefulWidget {
const LogPage({super.key});
@override
ConsumerState<LogPage> createState() => _LogPageState();
}
class _LogPageState extends ConsumerState<LogPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('系统日志'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '系统日志'),
Tab(text: '审计日志'),
Tab(text: 'JST调用日志'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_LogTab(title: '系统日志', logType: _LogType.system),
_LogTab(title: '审计日志', logType: _LogType.audit),
_LogTab(title: 'JST调用日志', logType: _LogType.jst),
],
),
);
}
}
/// 日志类型枚举。
enum _LogType { system, audit, jst }
/// 日志列表 Tab。
class _LogTab extends StatefulWidget {
const _LogTab({
required this.title,
required this.logType,
});
final String title;
final _LogType logType;
@override
State<_LogTab> createState() => _LogTabState();
}
class _LogTabState extends State<_LogTab>
with AutomaticKeepAliveClientMixin {
final SystemService _service = SystemService();
final List<SysLog> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<List<SysLog>> _fetch({int page = 1, int limit = 20}) {
switch (widget.logType) {
case _LogType.system:
return _service.getLogList(page: page, limit: limit);
case _LogType.audit:
return _service.getAuditLogList(page: page, limit: limit);
case _LogType.jst:
return _service.getApiJstCallLogList(page: page, limit: limit);
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _fetch(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}:'
'${dt.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
super.build(context);
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final log = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
log.operation.isEmpty
? '(无操作描述)'
: log.operation,
style: Theme.of(context).textTheme.titleSmall,
),
),
Text(
'${log.time}ms',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
Text('用户:${log.username}'),
const SizedBox(height: 2),
Text(
'方法:${log.method}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text('IP:${log.ip}'),
const SizedBox(height: 2),
Text(
'时间:${_formatDate(log.createDate)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_menu.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 菜单管理页(树形展示)。
class MenuPage extends ConsumerStatefulWidget {
const MenuPage({super.key});
@override
ConsumerState<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends ConsumerState<MenuPage> {
final SystemService _service = SystemService();
List<SysMenu> _items = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
_items = await _service.getMenuList();
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
await _loadData();
}
/// 构建菜单树:parentId 为空或 "0" 的为根节点。
List<_MenuNode> _buildTree(List<SysMenu> menus) {
final nodeMap = <String, _MenuNode>{};
for (final menu in menus) {
nodeMap[menu.menuId] = _MenuNode(menu: menu);
}
final roots = <_MenuNode>[];
for (final menu in menus) {
final node = nodeMap[menu.menuId]!;
final parentId = menu.parentId.isEmpty || menu.parentId == '0'
? null
: menu.parentId;
if (parentId == null || !nodeMap.containsKey(parentId)) {
roots.add(node);
} else {
nodeMap[parentId]!.children.add(node);
}
}
// 排序
void sortNodes(List<_MenuNode> nodes) {
nodes.sort((a, b) => a.menu.orderNum.compareTo(b.menu.orderNum));
for (final node in nodes) {
sortNodes(node.children);
}
}
sortNodes(roots);
return roots;
}
Future<void> _showForm({SysMenu? menu}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _MenuForm(menu: menu, allMenus: _items),
);
if (result == null) return;
try {
await _service.saveMenu(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteMenu(SysMenu menu) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除菜单「${menu.name}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteMenu(menu.menuId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
String _typeLabel(int type) {
switch (type) {
case 0:
return '目录';
case 1:
return '菜单';
case 2:
return '按钮';
default:
return '未知';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('菜单管理')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty) {
return const Center(child: Text('暂无数据'));
}
final tree = _buildTree(_items);
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: tree.length,
itemBuilder: (context, index) {
return _buildTreeNode(tree[index]);
},
),
);
}
Widget _buildTreeNode(_MenuNode node) {
final menu = node.menu;
final hasChildren = node.children.isNotEmpty;
if (hasChildren) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ExpansionTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
children: node.children.map((child) => _buildTreeNode(child)).toList(),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
}
IconData _iconData(String icon) {
switch (icon) {
case 'home':
return Icons.home;
case 'settings':
return Icons.settings;
case 'person':
return Icons.person;
case 'people':
return Icons.people;
case 'menu':
return Icons.menu;
case 'list':
return Icons.list;
case 'dashboard':
return Icons.dashboard;
case 'devices':
return Icons.devices;
case 'notifications':
return Icons.notifications;
default:
return Icons.folder;
}
}
}
/// 菜单树节点辅助类。
class _MenuNode {
_MenuNode({required this.menu});
final SysMenu menu;
final List<_MenuNode> children = [];
}
/// 菜单表单。
class _MenuForm extends StatefulWidget {
const _MenuForm({this.menu, this.allMenus = const []});
final SysMenu? menu;
final List<SysMenu> allMenus;
@override
State<_MenuForm> createState() => _MenuFormState();
}
class _MenuFormState extends State<_MenuForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _urlCtrl;
late TextEditingController _iconCtrl;
late TextEditingController _orderNumCtrl;
late TextEditingController _permsCtrl;
late String _parentId;
late int _type;
late bool _show;
@override
void initState() {
super.initState();
final m = widget.menu;
_nameCtrl = TextEditingController(text: m?.name ?? '');
_urlCtrl = TextEditingController(text: m?.url ?? '');
_iconCtrl = TextEditingController(text: m?.icon ?? '');
_orderNumCtrl = TextEditingController(text: (m?.orderNum ?? 0).toString());
_permsCtrl = TextEditingController(text: m?.perms ?? '');
_parentId = m?.parentId ?? '0';
_type = m?.type ?? 0;
_show = (m?.show ?? 0) == 0;
}
@override
void dispose() {
_nameCtrl.dispose();
_urlCtrl.dispose();
_iconCtrl.dispose();
_orderNumCtrl.dispose();
_permsCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'name': _nameCtrl.text.trim(),
'parentId': _parentId,
'type': _type,
'url': _urlCtrl.text.trim(),
'icon': _iconCtrl.text.trim(),
'orderNum': int.tryParse(_orderNumCtrl.text.trim()) ?? 0,
'perms': _permsCtrl.text.trim(),
'show': _show ? 0 : 1,
};
if (widget.menu != null) {
data['menuId'] = widget.menu!.menuId;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.menu != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑菜单' : '新增菜单',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: '菜单名称',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入菜单名称' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _parentId,
decoration: const InputDecoration(
labelText: '父级菜单',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: '0', child: Text('一级菜单')),
...widget.allMenus.map((m) => DropdownMenuItem(
value: m.menuId,
child: Text(m.name),
)),
],
onChanged: (v) => setState(() => _parentId = v ?? '0'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: const Text('目录'),
value: 0,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 0),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('菜单'),
value: 1,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 1),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('按钮'),
value: 2,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 2),
contentPadding: EdgeInsets.zero,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: '路由地址',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _iconCtrl,
decoration: const InputDecoration(
labelText: '图标',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _orderNumCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '排序号',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _permsCtrl,
decoration: const InputDecoration(
labelText: '权限标识',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('是否显示'),
value: _show,
onChanged: (v) => setState(() => _show = v),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_role.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 角色管理页。
class RolePage extends ConsumerStatefulWidget {
const RolePage({super.key});
@override
ConsumerState<RolePage> createState() => _RolePageState();
}
class _RolePageState extends ConsumerState<RolePage> {
final SystemService _service = SystemService();
final List<SysRole> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getRoleList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
Future<void> _showForm({SysRole? role}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _RoleForm(role: role),
);
if (result == null) return;
try {
await _service.saveRole(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteRole(SysRole role) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除角色「${role.roleName}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteRole([int.tryParse(role.roleId) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('角色管理')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final role = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(role.roleName),
subtitle: Text(role.remark.isEmpty ? '无备注' : role.remark),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(role: role);
} else if (value == 'delete') {
_deleteRole(role);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
);
}
}
/// 角色表单。
class _RoleForm extends StatefulWidget {
const _RoleForm({this.role});
final SysRole? role;
@override
State<_RoleForm> createState() => _RoleFormState();
}
class _RoleFormState extends State<_RoleForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _roleNameCtrl;
late TextEditingController _remarkCtrl;
@override
void initState() {
super.initState();
_roleNameCtrl = TextEditingController(text: widget.role?.roleName ?? '');
_remarkCtrl = TextEditingController(text: widget.role?.remark ?? '');
}
@override
void dispose() {
_roleNameCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'roleName': _roleNameCtrl.text.trim(),
'remark': _remarkCtrl.text.trim(),
};
if (widget.role != null) {
data['roleId'] = widget.role!.roleId;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.role != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑角色' : '新增角色',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _roleNameCtrl,
decoration: const InputDecoration(
labelText: '角色名称',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入角色名称' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: '备注',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,624 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/schedule.dart';
import '../../../models/sys_log.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 定时任务管理页。
class SchedulePage extends ConsumerStatefulWidget {
const SchedulePage({super.key});
@override
ConsumerState<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends ConsumerState<SchedulePage> {
final SystemService _service = SystemService();
final List<Schedule> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getScheduleList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
Future<void> _showForm({Schedule? schedule}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _ScheduleForm(schedule: schedule),
);
if (result == null) return;
try {
await _service.saveSchedule(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteSchedule(Schedule schedule) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除任务「${schedule.beanName}.${schedule.methodName}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteSchedule([int.tryParse(schedule.jobId) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
Future<void> _pauseSchedule(Schedule schedule) async {
try {
await _service.pauseSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已暂停')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('暂停失败:$e')),
);
}
}
}
Future<void> _resumeSchedule(Schedule schedule) async {
try {
await _service.resumeSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已恢复')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('恢复失败:$e')),
);
}
}
}
Future<void> _runSchedule(Schedule schedule) async {
try {
await _service.runSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已触发执行')),
);
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('执行失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('定时任务')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final schedule = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text('${schedule.beanName}.${schedule.methodName}'),
subtitle: Text(
'Cron:${schedule.cronExpression} | ${schedule.status == 0 ? '正常' : '暂停'}'
'${schedule.remark.isNotEmpty ? "\n${schedule.remark}" : ""}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'edit':
_showForm(schedule: schedule);
break;
case 'delete':
_deleteSchedule(schedule);
break;
case 'pause':
_pauseSchedule(schedule);
break;
case 'resume':
_resumeSchedule(schedule);
break;
case 'run':
_runSchedule(schedule);
break;
case 'log':
_showScheduleLog(schedule);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
const PopupMenuItem(value: 'pause', child: Text('暂停')),
const PopupMenuItem(value: 'resume', child: Text('恢复')),
const PopupMenuItem(value: 'run', child: Text('立即运行')),
const PopupMenuItem(value: 'log', child: Text('日志')),
],
),
),
);
},
),
);
}
void _showScheduleLog(Schedule schedule) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _ScheduleLogPage(jobId: schedule.jobId),
),
);
}
}
/// 定时任务日志页。
class _ScheduleLogPage extends StatefulWidget {
const _ScheduleLogPage({required this.jobId});
final String jobId;
@override
State<_ScheduleLogPage> createState() => _ScheduleLogPageState();
}
class _ScheduleLogPageState extends State<_ScheduleLogPage> {
final SystemService _service = SystemService();
final List<SysLog> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list =
await _service.getScheduleLogList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}:'
'${dt.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('任务日志')),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无日志'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final log = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
log.operation.isEmpty ? '(无操作描述)' : log.operation,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text('用户:${log.username}'),
const SizedBox(height: 2),
Text(
'方法:${log.method}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text('耗时:${log.time}ms'),
const SizedBox(height: 2),
Text(
'时间:${_formatDate(log.createDate)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
}
}
/// 定时任务表单。
class _ScheduleForm extends StatefulWidget {
const _ScheduleForm({this.schedule});
final Schedule? schedule;
@override
State<_ScheduleForm> createState() => _ScheduleFormState();
}
class _ScheduleFormState extends State<_ScheduleForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _beanName;
late final TextEditingController _methodName;
late final TextEditingController _params;
late final TextEditingController _cronExpression;
late final TextEditingController _remark;
@override
void initState() {
super.initState();
final s = widget.schedule;
_beanName = TextEditingController(text: s?.beanName ?? '');
_methodName = TextEditingController(text: s?.methodName ?? '');
_params = TextEditingController(text: s?.params ?? '');
_cronExpression = TextEditingController(text: s?.cronExpression ?? '');
_remark = TextEditingController(text: s?.remark ?? '');
}
@override
void dispose() {
_beanName.dispose();
_methodName.dispose();
_params.dispose();
_cronExpression.dispose();
_remark.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEdit = widget.schedule != null;
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑任务' : '新增任务',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
TextFormField(
controller: _beanName,
decoration: const InputDecoration(labelText: 'Bean名称'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入Bean名称' : null,
),
TextFormField(
controller: _methodName,
decoration: const InputDecoration(labelText: '方法名称'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入方法名称' : null,
),
TextFormField(
controller: _params,
decoration: const InputDecoration(labelText: '参数'),
),
TextFormField(
controller: _cronExpression,
decoration: const InputDecoration(labelText: 'Cron表达式'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入Cron表达式' : null,
),
TextFormField(
controller: _remark,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
if (_formKey.currentState?.validate() != true) return;
final data = <String, dynamic>{
if (isEdit) 'jobId': widget.schedule!.jobId,
'beanName': _beanName.text.trim(),
'methodName': _methodName.text.trim(),
'params': _params.text.trim(),
'cronExpression': _cronExpression.text.trim(),
'remark': _remark.text.trim(),
};
Navigator.pop(context, data);
},
child: const Text('保存'),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/alert.dart';
import '../../app/theme.dart';
import '../../providers/devices_provider.dart';
import '../../utils/formatters.dart';
/// 告警列表页(底部导航第 2 个 tab)。
class AlertsPage extends ConsumerWidget {
const AlertsPage({super.key});
Color _levelColor(BuildContext context, AlertLevel level) {
final semantic = Theme.of(context).extension<AppSemanticColors>();
switch (level) {
case AlertLevel.critical:
return Colors.red;
case AlertLevel.warning:
return Colors.orange;
case AlertLevel.info:
return semantic?.info ?? Theme.of(context).colorScheme.primary;
}
}
IconData _levelIcon(AlertLevel level) {
switch (level) {
case AlertLevel.critical:
return Icons.error;
case AlertLevel.warning:
return Icons.warning_amber;
case AlertLevel.info:
return Icons.info_outline;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final alertsAsync = ref.watch(alertsProvider);
return Scaffold(
appBar: AppBar(title: const Text('告警中心')),
body: alertsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('告警列表加载失败'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => ref.refresh(alertsProvider),
child: const Text('重试'),
),
],
),
),
data: (alerts) {
if (alerts.isEmpty) {
return const Center(child: Text('暂无告警'));
}
return RefreshIndicator(
onRefresh: () async => ref.refresh(alertsProvider.future),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: alerts.length,
itemBuilder: (context, index) =>
_buildAlertCard(context, alerts[index]),
),
);
},
),
);
}
Widget _buildAlertCard(BuildContext context, Alert alert) {
final color = _levelColor(context, alert.level);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_levelIcon(alert.level), color: color, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
alert.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
_buildLevelTag(alert, color),
],
),
const SizedBox(height: 8),
Text(
alert.message,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
alert.deviceName,
style: Theme.of(context).textTheme.bodySmall,
),
),
Text(
Formatters.formatDateTime(alert.occurredAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
),
);
}
Widget _buildLevelTag(Alert alert, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(999),
),
child: Text(
alert.acknowledged ? '${alert.level.label}·已确认' : alert.level.label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}

View File

@@ -0,0 +1,567 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/store_info.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
import '../../services/api_client.dart';
import '../../utils/formatters.dart';
import '../../utils/i10n.dart';
/// 激活码管理页:门店选择、激活码信息、生成/重置。
class DeviceActivationPage extends ConsumerStatefulWidget {
const DeviceActivationPage({super.key});
@override
ConsumerState<DeviceActivationPage> createState() =>
_DeviceActivationPageState();
}
class _DeviceActivationPageState extends ConsumerState<DeviceActivationPage> {
final OrgService _orgService = OrgService();
final DeviceService _deviceService = DeviceService();
AppLocalizations get t => AppLocalizations.of(context);
List<StoreInfo> _stores = [];
String? _selectedStoreId;
bool _loadingStores = true;
Map<String, dynamic>? _codeInfo;
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadStores();
}
Future<void> _loadStores() async {
try {
final stores = await _orgService.getMyStores();
if (!mounted) return;
setState(() {
_stores = stores;
_loadingStores = false;
if (stores.isNotEmpty) {
_selectedStoreId = stores.first.orgId;
}
});
if (_selectedStoreId != null) {
_loadCodeInfo();
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingStores = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).storeListLoadFailed(e.toString()))),
);
}
}
Future<void> _loadCodeInfo() async {
if (_selectedStoreId == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final info = await _deviceService.getActivationCodeInfo(
_selectedStoreId!,
);
if (!mounted) return;
setState(() {
_codeInfo = info;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
void _showGenerateSheet() {
if (_selectedStoreId == null) return;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => _GenerateCodeSheet(
deviceService: _deviceService,
orgId: _selectedStoreId!,
onSaved: () {
Navigator.pop(ctx);
_loadCodeInfo();
},
),
);
}
Future<void> _resetCode() async {
if (_selectedStoreId == null) return;
final t = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.resetActivationCode),
content: Text(t.resetActivationCodeConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.orange),
child: Text(t.reset),
),
],
),
);
if (confirmed != true) return;
try {
await _deviceService.resetActivationCode(_selectedStoreId!);
if (!mounted) return;
// 结果以页面中央弹窗展示(替代左下方 SnackBar)。
_showResultDialog(
isSuccess: true,
title: t.activationCodeResetSuccess,
);
_loadCodeInfo();
} catch (e) {
if (!mounted) return;
_showResultDialog(
isSuccess: false,
title: t.activationCodeResetFailed(formatApiError(e)),
);
}
}
/// 居中弹窗展示激活码重置结果(成功/失败)。
///
/// 替代原左下角 SnackBar,保证用户清晰看到操作反馈。
void _showResultDialog({
required bool isSuccess,
required String title,
}) {
final t = AppLocalizations.of(context);
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
color: isSuccess ? Colors.green : Colors.red,
size: 42,
),
title: Center(child: Text(title, textAlign: TextAlign.center)),
actionsAlignment: MainAxisAlignment.center,
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.confirm),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.activationCodeManagement),
),
body: Column(
children: [
_buildStoreSelector(),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildStoreSelector() {
if (_loadingStores) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (_stores.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(t.noStoreAvailable),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: DropdownButtonFormField<String>(
value: _selectedStoreId,
decoration: InputDecoration(
labelText: t.selectStore,
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
),
items: _stores
.map((s) => DropdownMenuItem(
value: s.orgId,
child: Text(s.name),
))
.toList(),
onChanged: (v) {
setState(() => _selectedStoreId = v);
_loadCodeInfo();
},
),
);
}
Widget _buildBody() {
if (_selectedStoreId == null) {
return Center(child: Text(t.pleaseSelectStoreFirst));
}
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(t.activationCodeLoadFailed(_error!)),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadCodeInfo,
child: Text(t.retry),
),
],
),
);
}
if (_codeInfo == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.vpn_key_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(t.noActivationCode),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _showGenerateSheet,
icon: const Icon(Icons.add),
label: Text(t.generateActivationCode),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadCodeInfo,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
_buildCodeCard(),
const SizedBox(height: 16),
_buildInfoCard(),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _showGenerateSheet,
icon: const Icon(Icons.refresh),
label: Text(t.generateActivationCode),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _resetCode,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange,
),
icon: const Icon(Icons.restart_alt),
label: Text(t.resetActivationCode),
),
),
],
),
],
),
);
}
Widget _buildCodeCard() {
final code = _codeInfo!['code']?.toString() ?? '';
final theme = Theme.of(context);
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.vpn_key,
color: theme.colorScheme.primary, size: 20),
const SizedBox(width: 8),
Text(t.currentActivationCode,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 16),
if (code.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
t.activationCodeNoCodeHint,
style: const TextStyle(color: Colors.grey),
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.4)),
),
child: SelectableText(
code,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: theme.colorScheme.onPrimaryContainer,
),
),
),
if (code.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: code));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.activationCodeCopied)),
);
},
icon: const Icon(Icons.copy, size: 18),
label: Text(t.copy),
),
],
),
],
],
),
),
);
}
Widget _buildInfoCard() {
final theme = Theme.of(context);
final maxCount = _codeInfo!['maxActivateCount'];
final activatedCount = _codeInfo!['activatedCount'];
final createTime = _codeInfo!['createTime'];
String _formatTime(dynamic t) {
if (t == null) return '—';
final text = t.toString().trim();
if (text.isEmpty) return '—';
final dt = DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
return dt != null ? Formatters.formatDateTime(dt) : text;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_infoRow(
theme,
t.maxActivateCount,
maxCount?.toString() ?? '—',
),
_infoRow(
theme,
t.activatedCount,
activatedCount?.toString() ?? '—',
),
_infoRow(theme, t.createTime, _formatTime(createTime)),
],
),
),
);
}
Widget _infoRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(
label,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(value,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
),
],
),
);
}
}
/// 生成激活码表单。
class _GenerateCodeSheet extends StatefulWidget {
const _GenerateCodeSheet({
required this.deviceService,
required this.orgId,
required this.onSaved,
});
final DeviceService deviceService;
final String orgId;
final VoidCallback onSaved;
@override
State<_GenerateCodeSheet> createState() => _GenerateCodeSheetState();
}
class _GenerateCodeSheetState extends State<_GenerateCodeSheet> {
final _formKey = GlobalKey<FormState>();
final _countController = TextEditingController(text: '1');
bool _saving = false;
@override
void dispose() {
_countController.dispose();
super.dispose();
}
Future<void> _save() async {
final t = AppLocalizations.of(context);
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final count = int.parse(_countController.text.trim());
await widget.deviceService.generateActivationCode(
widget.orgId,
count,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.activationCodeGenerated)),
);
widget.onSaved();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.generateFailedWithError(e.toString()))),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
t.generateActivationCode,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
t.generateActivationCodeHint,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _countController,
decoration: InputDecoration(
labelText: t.maxActivationCountLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.looks),
helperText: t.maxActivationCountHint,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (v) {
final count = int.tryParse(v ?? '');
if (count == null || count <= 0) {
return t.positiveIntegerRequired;
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.generate),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,488 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/org_node.dart';
import '../../models/store_info.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
/// 设备分组页:门店选择、分组列表、新增/编辑/删除。
class DeviceGroupPage extends ConsumerStatefulWidget {
const DeviceGroupPage({super.key});
@override
ConsumerState<DeviceGroupPage> createState() => _DeviceGroupPageState();
}
class _DeviceGroupPageState extends ConsumerState<DeviceGroupPage> {
final OrgService _orgService = OrgService();
final DeviceService _deviceService = DeviceService();
final _scrollController = ScrollController();
List<StoreInfo> _stores = [];
String? _selectedStoreId;
bool _loadingStores = true;
List<OrgNode> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadStores();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadStores() async {
try {
final stores = await _orgService.getMyStores();
if (!mounted) return;
setState(() {
_stores = stores;
_loadingStores = false;
if (stores.isNotEmpty) {
_selectedStoreId = stores.first.orgId;
}
});
if (_selectedStoreId != null) {
_loadData();
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingStores = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('门店列表加载失败:$e')),
);
}
}
Future<void> _loadData() async {
if (_selectedStoreId == null) return;
setState(() {
_loading = true;
_error = null;
_page = 1;
_hasMore = true;
});
try {
final items = await _deviceService.getDeviceGroupList(
page: 1,
limit: 20,
storeId: _selectedStoreId!,
);
if (!mounted) return;
setState(() {
_items = items;
_hasMore = items.length >= 20;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore || _selectedStoreId == null) return;
setState(() => _loading = true);
try {
final items = await _deviceService.getDeviceGroupList(
page: _page + 1,
limit: 20,
storeId: _selectedStoreId!,
);
if (!mounted) return;
setState(() {
_page++;
_items.addAll(items);
_hasMore = items.length >= 20;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载更多失败:$e')),
);
}
}
Future<void> _deleteGroup(OrgNode group) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除分组'),
content: Text('确定要删除分组「${group.name}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _deviceService.deleteDeviceGroups([group.id]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('分组已删除')),
);
_loadData();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
void _showAddEditSheet({OrgNode? group}) {
if (_selectedStoreId == null) return;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => _GroupFormSheet(
deviceService: _deviceService,
storeId: _selectedStoreId!,
group: group,
onSaved: () {
Navigator.pop(ctx);
_loadData();
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('设备分组'),
),
body: Column(
children: [
_buildStoreSelector(),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
floatingActionButton: _selectedStoreId == null
? null
: FloatingActionButton(
onPressed: () => _showAddEditSheet(),
child: const Icon(Icons.add),
),
);
}
Widget _buildStoreSelector() {
if (_loadingStores) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (_stores.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text('暂无可选门店'),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: DropdownButtonFormField<String>(
value: _selectedStoreId,
decoration: const InputDecoration(
labelText: '选择门店',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
),
items: _stores
.map((s) => DropdownMenuItem(
value: s.orgId,
child: Text(s.name),
))
.toList(),
onChanged: (v) {
setState(() => _selectedStoreId = v);
_loadData();
},
),
);
}
Widget _buildBody() {
if (_selectedStoreId == null) {
return const Center(child: Text('请先选择门店'));
}
if (_loading && _items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('加载失败:$_error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadData,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty) {
return RefreshIndicator(
onRefresh: _loadData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 200),
Center(child: Text('暂无分组数据')),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
return _loading
? const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
final group = _items[index];
return _GroupListTile(
group: group,
onEdit: () => _showAddEditSheet(group: group),
onDelete: () => _deleteGroup(group),
);
},
),
);
}
}
/// 分组列表项。
class _GroupListTile extends StatelessWidget {
const _GroupListTile({
required this.group,
required this.onEdit,
required this.onDelete,
});
final OrgNode group;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.folder_outlined),
),
title: Text(group.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
if (group.address.isNotEmpty)
_line(Icons.location_on, group.address),
if (group.contactPhone.isNotEmpty)
_line(Icons.phone, group.contactPhone),
_line(Icons.tag, 'ID: ${group.id}'),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
onEdit();
} else if (value == 'delete') {
onDelete();
}
},
itemBuilder: (ctx) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
}
Widget _line(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(icon, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
],
),
);
}
}
/// 新增/编辑分组表单。
class _GroupFormSheet extends StatefulWidget {
const _GroupFormSheet({
required this.deviceService,
required this.storeId,
this.group,
required this.onSaved,
});
final DeviceService deviceService;
final String storeId;
final OrgNode? group;
final VoidCallback onSaved;
@override
State<_GroupFormSheet> createState() => _GroupFormSheetState();
}
class _GroupFormSheetState extends State<_GroupFormSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
bool _saving = false;
@override
void initState() {
super.initState();
if (widget.group != null) {
_nameController.text = widget.group!.name;
}
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final data = <String, dynamic>{
'name': _nameController.text.trim(),
'storeId': widget.storeId,
if (widget.group != null) 'id': widget.group!.id,
};
if (widget.group != null) {
await widget.deviceService.updateDeviceGroup(data);
} else {
await widget.deviceService.saveDeviceGroup(data);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.group != null ? '分组已更新' : '分组已创建'),
),
);
widget.onSaved();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.group != null;
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑分组' : '新增分组',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '分组名称',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.folder),
),
validator: (v) =>
v == null || v.trim().isEmpty ? '请输入分组名称' : null,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEdit ? '保存' : '创建'),
),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,554 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/store_info.dart';
import '../../services/api_client.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
import '../../utils/formatters.dart';
/// 门店配置页:门店选择、配置表单、保存/重置密码。
class StoreConfigPage extends ConsumerStatefulWidget {
const StoreConfigPage({super.key});
@override
ConsumerState<StoreConfigPage> createState() => _StoreConfigPageState();
}
class _StoreConfigPageState extends ConsumerState<StoreConfigPage> {
final OrgService _orgService = OrgService();
final DeviceService _deviceService = DeviceService();
List<StoreInfo> _stores = [];
String? _selectedStoreId;
bool _loadingStores = true;
Map<String, dynamic>? _config;
bool _loading = false;
bool _saving = false;
bool _disabled = false;
String? _error;
// 表单控制器
final _defaultVolumeController = TextEditingController();
final _heartbeatIntervalController = TextEditingController();
final _storePasswordController = TextEditingController();
final _maxVolumeController = TextEditingController();
final _offlineThresholdController = TextEditingController();
final _mediaVolumeController = TextEditingController();
final _expireTimeController = TextEditingController();
@override
void initState() {
super.initState();
_loadStores();
}
@override
void dispose() {
_defaultVolumeController.dispose();
_heartbeatIntervalController.dispose();
_storePasswordController.dispose();
_maxVolumeController.dispose();
_offlineThresholdController.dispose();
_mediaVolumeController.dispose();
_expireTimeController.dispose();
super.dispose();
}
Future<void> _loadStores() async {
try {
final stores = await _orgService.getMyStores();
if (!mounted) return;
setState(() {
_stores = stores;
_loadingStores = false;
if (stores.isNotEmpty) {
_selectedStoreId = stores.first.orgId;
}
});
if (_selectedStoreId != null) {
_loadConfig();
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingStores = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('门店列表加载失败:$e')),
);
}
}
Future<void> _loadConfig() async {
if (_selectedStoreId == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final config = await _deviceService.getStoreConfig(_selectedStoreId!);
if (!mounted) return;
_populateControllers(config);
setState(() {
_config = config;
_loading = false;
});
} catch (e) {
if (!mounted) return;
final friendly = formatApiError(e,
fallback: '门店配置加载失败,请稍后重试');
setState(() {
_error = '$friendly\n\n若持续出现「未知异常」,请联系后端排查 /device/storeConfig/info/{orgId} 接口。';
_loading = false;
});
}
}
void _populateControllers(Map<String, dynamic> config) {
// 字段名以 Web 后台 StoreConfig 模型为准(systemDefaultVolume / password),
// 同时兼容旧移动端字段名(defaultVolume / storePassword),避免回显为空。
_defaultVolumeController.text =
_toString(config['systemDefaultVolume'] ?? config['defaultVolume']);
_heartbeatIntervalController.text =
_toString(config['heartbeatInterval']);
_storePasswordController.text =
_toString(config['password'] ?? config['storePassword']);
_maxVolumeController.text = _toString(config['maxVolume']);
_offlineThresholdController.text =
_toString(config['offlineThreshold']);
_mediaVolumeController.text =
_toString(config['mediaVolume'] ?? config['media_volume']);
_expireTimeController.text = _toString(config['expireTime']);
_disabled = _parseDisabled(config['disabled']);
}
/// 解析禁用状态:true / 非零 num / 字符串 '1' 视为禁用=true,其余 false。
static bool _parseDisabled(dynamic value) {
if (value == true) return true;
if (value is num) return value != 0;
if (value is String) return value.trim() == '1';
return false;
}
static String _toString(dynamic value) {
if (value == null) return '';
return value.toString();
}
Future<void> _save() async {
if (_selectedStoreId == null) return;
setState(() => _saving = true);
try {
// 字段名以 Web 后台 StoreConfig 模型为准(见 screen-front src/api/device.ts):
// systemDefaultVolume / maxVolume / heartbeatInterval / password / expireTime。
// 仅发送非空字段,避免把后端既有值覆盖为空;密码留空则不传(与 Web 一致,
// 防止误清空)。offlineThreshold 为旧移动端遗留字段,后端忽略无害,保留发送。
final data = <String, dynamic>{'orgId': _selectedStoreId};
final sysDef = _defaultVolumeController.text.trim();
final vSysDef = int.tryParse(sysDef);
if (sysDef.isNotEmpty && vSysDef != null) {
data['systemDefaultVolume'] = vSysDef;
}
final max = _maxVolumeController.text.trim();
final vMax = int.tryParse(max);
if (max.isNotEmpty && vMax != null) data['maxVolume'] = vMax;
final hb = _heartbeatIntervalController.text.trim();
final vHb = int.tryParse(hb);
if (hb.isNotEmpty && vHb != null) data['heartbeatInterval'] = vHb;
final off = _offlineThresholdController.text.trim();
final vOff = int.tryParse(off);
if (off.isNotEmpty && vOff != null) data['offlineThreshold'] = vOff;
final pwd = _storePasswordController.text.trim();
if (pwd.isNotEmpty) data['password'] = pwd;
final exp = _expireTimeController.text.trim();
if (exp.isNotEmpty) data['expireTime'] = exp;
final media = _mediaVolumeController.text.trim();
final vMedia = int.tryParse(media);
if (media.isNotEmpty && vMedia != null) data['mediaVolume'] = vMedia;
// disabled 始终发送当前开关状态(布尔值),确保与 Web 后台一致。
data['disabled'] = _disabled;
await _deviceService.createOrUpdateStoreConfig(data);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('配置已保存')),
);
_loadConfig();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:${formatApiError(e)}')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _resetPassword() async {
if (_selectedStoreId == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('重置密码'),
content: const Text('确定要重置门店配置密码吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.orange),
child: const Text('重置'),
),
],
),
);
if (confirmed != true) return;
try {
await _deviceService.resetStoreConfigPassword(_selectedStoreId!);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码已重置')),
);
_loadConfig();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('重置失败:${formatApiError(e)}')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('门店配置'),
),
body: Column(
children: [
_buildStoreSelector(),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildStoreSelector() {
if (_loadingStores) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (_stores.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text('暂无可选门店'),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: DropdownButtonFormField<String>(
value: _selectedStoreId,
decoration: const InputDecoration(
labelText: '选择门店',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
),
items: _stores
.map((s) => DropdownMenuItem(
value: s.orgId,
child: Text(s.name),
))
.toList(),
onChanged: (v) {
setState(() => _selectedStoreId = v);
_loadConfig();
},
),
);
}
Widget _buildBody() {
if (_selectedStoreId == null) {
return const Center(child: Text('请先选择门店'));
}
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('加载失败:$_error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadConfig,
child: const Text('重试'),
),
],
),
);
}
if (_config == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
const Text('暂无配置信息'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.add),
label: const Text('创建配置'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadConfig,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
_buildConfigForm(),
const SizedBox(height: 16),
_buildExtraInfo(),
const SizedBox(height: 24),
_buildActions(),
],
),
);
}
Widget _buildConfigForm() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'配置项',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextFormField(
controller: _defaultVolumeController,
decoration: const InputDecoration(
labelText: '系统默认音量',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.volume_up),
helperText: '设备系统默认音量 (0-100)',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextFormField(
controller: _mediaVolumeController,
decoration: const InputDecoration(
labelText: '媒体区域音量',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.volume_down),
helperText: '媒体播放区域音量 (0-100)',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextFormField(
controller: _maxVolumeController,
decoration: const InputDecoration(
labelText: '最大音量',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.volume_up_sharp),
helperText: '设备允许的最大音量',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextFormField(
controller: _heartbeatIntervalController,
decoration: const InputDecoration(
labelText: '心跳间隔 (秒)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.favorite),
helperText: '设备心跳上报间隔',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextFormField(
controller: _offlineThresholdController,
decoration: const InputDecoration(
labelText: '离线阈值 (秒)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.timer_off),
helperText: '超过此时间无心跳则判定离线',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextFormField(
controller: _storePasswordController,
decoration: const InputDecoration(
labelText: '门店密码',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
helperText: '设备绑定门店时的验证密码',
),
obscureText: true,
),
const SizedBox(height: 12),
TextFormField(
controller: _expireTimeController,
decoration: InputDecoration(
labelText: '到期时间',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.event),
helperText: '留空则不修改;格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss',
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_month),
tooltip: '选择日期',
onPressed: _saving ? null : _pickExpireDate,
),
),
keyboardType: TextInputType.datetime,
),
const SizedBox(height: 12),
Row(
children: [
const SizedBox(width: 84, child: Text('禁用')),
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('否')),
ButtonSegment(value: true, label: Text('是')),
],
selected: {_disabled},
onSelectionChanged: (set) => setState(() => _disabled = set.first),
),
],
),
],
),
),
);
}
Future<void> _pickExpireDate() async {
final current = _expireTimeController.text.trim();
final initial = DateTime.tryParse(current) ??
DateTime.tryParse(current.replaceFirst(' ', 'T')) ??
DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null && mounted) {
setState(() =>
_expireTimeController.text = picked.toIso8601String().substring(0, 10));
}
}
Widget _buildExtraInfo() {
final theme = Theme.of(context);
final createTime = _config?['createTime'];
final updateTime = _config?['updateTime'];
String _fmt(dynamic t) {
if (t == null) return '—';
final text = t.toString().trim();
if (text.isEmpty) return '—';
final dt = DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
return dt != null ? Formatters.formatDateTime(dt) : text;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'其他信息',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_infoRow(theme, '创建时间', _fmt(createTime)),
_infoRow(theme, '更新时间', _fmt(updateTime)),
],
),
),
);
}
Widget _buildActions() {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('保存配置'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _resetPassword,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange,
),
icon: const Icon(Icons.password),
label: const Text('重置密码'),
),
),
],
);
}
Widget _infoRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(
label,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(value,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
),
],
),
);
}
}

View File

@@ -0,0 +1,618 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../models/org_node.dart';
import '../../providers/devices_provider.dart';
import '../../providers/metrics_visibility_provider.dart';
import '../../services/api_client.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
import '../../utils/formatters.dart';
import '../../utils/i10n.dart';
import '../../app/theme.dart';
import '../../utils/responsive.dart';
import '../../widgets/device_command_sheet.dart';
import '../../widgets/metric_card.dart';
import '../../widgets/status_chip.dart';
/// 设备详情页:指标卡(CPU/内存/磁盘/网络)、状态与运行时长。
class DeviceDetailPage extends ConsumerWidget {
const DeviceDetailPage({super.key, required this.deviceId});
/// 设备 ID。
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final deviceAsync = ref.watch(deviceDetailProvider(deviceId));
final showMetrics = ref.watch(showRuntimeMetricsProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: Text(t.deviceDetails)),
body: deviceAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () =>
ref.refresh(deviceDetailProvider(deviceId)),
child: Text(t.retry),
),
],
),
),
data: (device) => Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () async =>
ref.refresh(deviceDetailProvider(deviceId).future),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_buildHeader(context, ref, device),
const SizedBox(height: 16),
_buildBasicInfo(context, device),
const SizedBox(height: 16),
_buildConfigParams(context, device),
const SizedBox(height: 16),
_buildNetworkInfo(context, device),
const SizedBox(height: 16),
_buildRuntimeData(context, device),
if (showMetrics) ...[
const SizedBox(height: 16),
_buildMetricsGrid(context, device),
],
],
),
),
),
_buildStickyActions(context, ref, device),
],
),
),
);
}
Widget _buildHeader(BuildContext context, WidgetRef ref, Device device) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.macLabel,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
if (device.model.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${AppLocalizations.of(context).modelLabel}:${device.model}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
StatusChip(status: device.status),
],
);
}
/// 底部常驻操作栏(发送指令 / 编辑分组),滚动时保持可见。
Widget _buildStickyActions(
BuildContext context,
WidgetRef ref,
Device device,
) {
final t = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surface,
border: Border(top: BorderSide(color: scheme.outlineVariant)),
boxShadow: AppShadows.sm,
),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () => _showCommandSheet(context, device),
child: Text(t.sendCommand),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonal(
onPressed: () => _showEditSheet(context, ref, device),
child: Text(t.editGroup),
),
),
],
),
),
);
}
/// 信息行标签宽度随窗口尺寸自适应(窄屏 96 / 中屏 128 / 宽屏 160)。
double _labelWidth(BuildContext context) {
final w = context.responsive.windowSize;
return w == AppWindowSize.compact
? 96
: w == AppWindowSize.medium
? 128
: 160;
}
/// 修改设备分组 / 房号。
void _showEditSheet(BuildContext context, WidgetRef ref, Device device) {
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DeviceGroupRoomSheet(
device: device,
onSaved: (groupId, roomNo) async {
Navigator.pop(ctx);
try {
await DeviceService().updateDevice({
'id': device.id,
// 后端 /device/info/update 要求 mac 非空,修改分组/房号时须回传原 MAC。
'mac': device.mac,
'groupId': groupId,
'roomId': roomNo,
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).saveSuccess)),
);
ref.refresh(deviceDetailProvider(deviceId));
}
} on ApiException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).saveFailed}:${formatApiError(e)}')),
);
}
}
},
),
);
}
void _showCommandSheet(BuildContext context, Device device) { showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DeviceCommandSheet(
device: device,
deviceService: DeviceService(),
),
);
}
/// 带标题的分区块卡片。
Widget _sectionCard(
BuildContext context,
String title,
List<Widget> rows,
) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 8),
...rows,
],
),
),
);
}
/// 基本信息:设备名称 / 设备 ID / 型号 / 状态。
Widget _buildBasicInfo(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.basicInfo, [
_infoRow(context, theme, t.deviceName,
device.macLabel.isEmpty ? t.none : device.macLabel),
_infoRow(context, theme, t.deviceId,
device.id.isEmpty ? t.none : device.id),
_infoRow(context, theme, t.modelLabel,
device.model.isEmpty ? t.none : device.model),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelWidth(context),
child: Text(
t.status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: StatusChip(status: device.status)),
],
),
),
]);
}
/// 配置参数:所属门店 / 分组 / 房号 / 序列号 / UID / 创建时间。
Widget _buildConfigParams(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.configParams, [
_infoRow(context, theme, t.belongStore,
device.storeName.isEmpty ? t.none : device.storeName),
_infoRow(context, theme, t.belongGroup,
device.groupId.isEmpty ? t.notGrouped : device.groupId),
_infoRow(context, theme, t.roomNo,
device.roomNo.isEmpty ? t.none : device.roomNo),
_infoRow(context, theme, t.deviceSnLabel,
device.deviceSn.isEmpty ? t.none : device.deviceSn),
_infoRow(context, theme, t.deviceUidLabel,
device.deviceUid.isEmpty ? t.none : device.deviceUid),
_infoRow(context, theme, t.createTime,
device.createTime != null
? Formatters.formatDateTime(device.createTime!)
: t.none),
]);
}
/// 网络信息:IP 地址 / MAC 地址。
Widget _buildNetworkInfo(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.networkInfo, [
_infoRow(context, theme, t.ipAddress,
device.ip.isEmpty ? t.none : device.ip),
_infoRow(context, theme, t.macAddress,
device.mac.isEmpty ? t.none : device.mac),
]);
}
/// 运行时数据:在线时长 / 最后活跃时间。
Widget _buildRuntimeData(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
return _sectionCard(context, t.runtimeData, [
_infoRow(context, theme, t.uptime,
device.uptimeSeconds > 0
? t.formatUptime(device.uptimeSeconds)
: t.none),
_infoRow(context, theme, t.lastActiveTime,
device.lastSyncTime != null
? Formatters.formatDateTime(device.lastSyncTime!)
: t.none),
]);
}
Widget _infoRow(BuildContext context, ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelWidth(context),
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Widget _buildMetricsGrid(BuildContext context, Device device) {
final t = AppLocalizations.of(context);
final metrics = device.metrics;
// 真实后端不提供运行指标:无指标数据时显示 “—”(仅 mock 模式有值)。
final hasMetrics = device.hasMetrics;
final none = t.none;
return GridView.count(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.15,
children: [
MetricCard(
icon: Icons.memory,
title: t.cpuUsage,
value: hasMetrics ? Formatters.formatPercent(metrics.cpuUsage) : none,
progress: hasMetrics ? metrics.cpuUsage : null,
accentColor:
hasMetrics && metrics.cpuUsage > 0.8 ? Colors.red : null,
),
MetricCard(
icon: Icons.storage,
title: t.memoryUsage,
value:
hasMetrics ? Formatters.formatPercent(metrics.memoryUsage) : none,
progress: hasMetrics ? metrics.memoryUsage : null,
accentColor:
hasMetrics && metrics.memoryUsage > 0.85 ? Colors.red : null,
),
MetricCard(
icon: Icons.sd_storage,
title: t.diskUsage,
value:
hasMetrics ? Formatters.formatPercent(metrics.diskUsage) : none,
progress: hasMetrics ? metrics.diskUsage : null,
accentColor:
hasMetrics && metrics.diskUsage > 0.8 ? Colors.orange : null,
),
MetricCard(
icon: Icons.network_check,
title: t.networkRxTx,
value: hasMetrics
? '${metrics.networkRxKbps.toStringAsFixed(1)} / '
'${metrics.networkTxKbps.toStringAsFixed(1)} KB/s'
: none,
),
],
);
}
}
/// 修改设备分组 / 房号底部表单。
///
/// 分组改为「分组名称」下拉选择(与 Web 端一致):进入时按设备所属门店调用
/// `/sys/store/deviceGroups/{storeId}` 拉取该门店下专属分组,下拉显示 [OrgNode.name],
/// 提交 [OrgNode.id]。房号仍保留文本输入。
class DeviceGroupRoomSheet extends StatefulWidget {
const DeviceGroupRoomSheet({
super.key,
required this.device,
required this.onSaved,
});
final Device device;
final Future<void> Function(String groupId, String roomNo) onSaved;
@override
State<DeviceGroupRoomSheet> createState() => _DeviceGroupRoomSheetState();
}
class _DeviceGroupRoomSheetState extends State<DeviceGroupRoomSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _roomNoCtrl;
bool _saving = false;
/// 当前门店下的设备分组列表。
List<OrgNode> _groups = const [];
bool _groupsLoading = false;
String? _groupsError;
/// 选中的分组 ID(null 表示「未分组」)。
String? _selectedGroupId;
@override
void initState() {
super.initState();
_roomNoCtrl = TextEditingController(text: widget.device.roomNo);
_selectedGroupId =
widget.device.groupId.isEmpty ? null : widget.device.groupId;
_loadGroups();
}
@override
void dispose() {
_roomNoCtrl.dispose();
super.dispose();
}
/// 拉取当前门店下的设备分组列表。
///
/// 优先使用 `/device/group/list?storeId=xxx`,该接口支持按门店筛选且已在
/// [DeviceService] 中实现;若调用失败则降级为读取「当前分组」一项,避免阻塞
/// 用户修改房号或保存现有分组。
Future<void> _loadGroups() async {
if (widget.device.storeId.isEmpty) {
if (mounted) setState(() => _groupsLoading = false);
return;
}
if (mounted) setState(() => _groupsLoading = true);
try {
_groups = await DeviceService().getDeviceGroupList(
storeId: widget.device.storeId,
);
_groupsError = null;
} on ApiException catch (e) {
_groupsError = e.message;
_groups = const [];
} catch (e) {
_groupsError = formatApiError(e, fallback: '分组加载失败,请检查网络');
_groups = const [];
} finally {
if (mounted) setState(() => _groupsLoading = false);
}
}
/// 构造下拉项:首项「未分组」,随后各分组(显示名称,值为 ID)。
/// 若当前分组不在列表中(如后端未返回或跨门店),追加一项以便正确回显。
List<DropdownMenuItem<String>> _buildGroupItems(AppLocalizations t) {
final items = <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: '',
child: Text(t.notGrouped),
),
];
final seen = <String>{''};
for (final group in _groups) {
if (seen.contains(group.id)) continue;
seen.add(group.id);
items.add(
DropdownMenuItem<String>(
value: group.id,
child: Text(group.name.isNotEmpty ? group.name : group.id),
),
);
}
final current = widget.device.groupId;
if (current.isNotEmpty && !seen.contains(current)) {
items.add(
DropdownMenuItem<String>(
value: current,
child: Text(current),
),
);
}
return items;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await widget.onSaved(
_selectedGroupId ?? '',
_roomNoCtrl.text.trim(),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${t.editGroup} / ${t.roomNo}',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 所属分组:名称下拉选择(提交分组 ID)。
DropdownButtonFormField<String>(
value: _selectedGroupId ?? '',
isExpanded: true,
decoration: InputDecoration(
labelText: t.belongGroup,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.dvr),
),
items: _buildGroupItems(t),
onChanged: _groupsLoading
? null
: (value) => setState(() => _selectedGroupId = value),
),
const SizedBox(height: 8),
if (_groupsLoading)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(t.groupListLoading),
],
),
)
else if (_groupsError != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'${t.loadFailed}:$_groupsError',
style: const TextStyle(color: Colors.redAccent, fontSize: 12),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _roomNoCtrl,
decoration: InputDecoration(
labelText: t.roomNo,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.meeting_room),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.save),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,492 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../models/org_node.dart';
import '../../models/store_info.dart';
import '../../providers/devices_provider.dart';
import '../../services/api_client.dart';
import '../../services/org_service.dart';
import '../../utils/i10n.dart';
import '../../utils/session_org_scope.dart';
import '../../widgets/device_list_tile.dart';
import '../../widgets/org_tree_filter.dart';
/// 设备列表页(底部导航第 2 个 tab),支持关键字搜索与组织树筛选。
class DeviceListPage extends ConsumerStatefulWidget {
const DeviceListPage({super.key});
@override
ConsumerState<DeviceListPage> createState() => _DeviceListPageState();
}
class _DeviceListPageState extends ConsumerState<DeviceListPage>
with WidgetsBindingObserver {
final _searchController = TextEditingController();
String _keyword = '';
final _modelController = TextEditingController();
String _modelKeyword = '';
/// 门店名称搜索关键字:按所属门店名称过滤设备,与门店管理页的「搜索门店名称」对齐。
final _storeNameController = TextEditingController();
String _storeNameKeyword = '';
/// 可选门店列表(异步加载后填充,用于受限账号默认范围及门店名称反查)。
List<StoreInfo> _stores = [];
/// 设备列表(页面态,替代 devicesByStoreProvider 的 watch)。
List<Device> _devices = [];
bool _loadingDevices = false;
Object? _deviceError;
/// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。
Set<String> _selectedOrgIds = {};
/// 组织树(异步加载,失败则置空,树筛选不可用)。
List<OrgNode>? _orgTree;
/// 已发起删除、等待后端确认期间临时隐藏的设备 ID,避免行先回弹再消失。
final Set<String> _pendingDeleteIds = {};
/// 用于跳过首次启动时的 resumed 事件,避免与 initState 初始加载重复请求。
bool _initialResumeHandled = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initData();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_searchController.dispose();
_modelController.dispose();
_storeNameController.dispose();
super.dispose();
}
/// 初始化:加载门店列表(受限账号默认首店)、组织树,随后拉取设备。
Future<void> _initData() async {
await _loadStores();
if (!mounted) return;
await _loadOrgTree();
if (!mounted) return;
await _loadDevices();
}
/// 应用从后台切回前台时刷新设备/门店列表,以同步 Web 端等外部变更。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state != AppLifecycleState.resumed) return;
if (!_initialResumeHandled) {
_initialResumeHandled = true;
return;
}
_loadStores();
_loadDevices();
}
Future<void> _loadStores() async {
// 静默加载门店列表,失败不阻断设备列表。
try {
_stores = await OrgService().getMyStores();
} catch (e) {
_stores = [];
} finally {
if (mounted) setState(() {});
}
}
/// 异步加载组织树(静默失败:仅影响组织树筛选,不影响设备列表本身)。
Future<void> _loadOrgTree() async {
try {
_orgTree = await OrgService().getOrgTree();
} catch (_) {
_orgTree = null;
}
}
/// 按组织树筛选 / 受限账号 / 全部门店拉取设备,并叠加本地型号/关键词过滤。
Future<void> _loadDevices() async {
setState(() {
_loadingDevices = true;
_deviceError = null;
});
try {
final deviceService = ref.read(deviceServiceProvider);
List<Device> devices;
if (_selectedOrgIds.isEmpty) {
// 未选组织树:
if (SessionOrgScope.instance.enabled) {
// 受限账号:不能有「全部门店」视图(否则会拉取跨组织设备)。
// 改为拉取其组织范围内【全部门店】的设备(而非仅第一家),
// 既见全本组织设备、又不越权。门店列表本就按范围收敛,取其 orgId 集合即可。
final storeIds = _stores
.map((s) => s.orgId)
.where((id) => id.isNotEmpty)
.toSet();
if (storeIds.isEmpty) {
// 异常兜底:连一家门店都没有时,不调用全量接口,避免越权拉取跨组织设备。
devices = const [];
} else {
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
}
} else {
// 管理员:全量设备。
devices = await deviceService.fetchDevices();
}
} else {
// 组织树筛选:仅收集每个选中节点的【门店(type=3)后代】orgId。
final storeIds = <String>{};
for (final id in _selectedOrgIds) {
storeIds.addAll(
OrgService.collectDescendantStoreIds(_orgTree ?? [], id),
);
}
if (storeIds.isEmpty) {
// 选中的节点没有任何门店后代(如设备分组 / 无下属门店的空代理):
// 直接返回空结果——绝不回退为「用节点自身 id 充当 storeOrgId」,
// 也绝不退化为「全量设备」(fetchDevicesByOrgSet 空集会退化为跨组织全量)。
devices = const [];
} else {
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
}
}
if (!mounted) return;
_devices = devices;
setState(() => _loadingDevices = false);
} catch (e) {
if (!mounted) return;
_deviceError = e;
setState(() => _loadingDevices = false);
}
}
/// 打开组织树筛选抽屉,回填选中集合并重新拉取设备。
///
/// 返回 null(取消)时保持原筛选不变;返回非空集合(含空集合)时覆盖 [_selectedOrgIds]。
Future<void> _openOrgFilter() async {
final result = await showOrgTreeFilterSheet(
context,
initialSelected: _selectedOrgIds,
);
if (result == null) return;
setState(() => _selectedOrgIds = result);
await _loadDevices();
}
/// 按门店名称搜索:保存关键字到 [_storeNameKeyword] 并触发本地过滤。
/// 搜索结果由 [_filter] 在构建时实时应用,并在下拉刷新/生命周期刷新后保持。
void _onStoreSearch() {
setState(() {
_storeNameKeyword = _storeNameController.text.trim();
});
}
/// 取设备所属门店的展示名称(优先 [Device.storeName],其次按 storeId 在 [_stores] 中反查)。
String _deviceStoreName(Device device) {
if (device.storeName.isNotEmpty) return device.storeName;
for (final s in _stores) {
if (s.orgId == device.storeId) return s.name;
}
return '';
}
/// 组织树筛选按钮文案:空选显示「全部门店/全部组织」;
/// 有选显示首个选中节点名 + 超量「(+N)」。
String _orgFilterLabel() {
if (_selectedOrgIds.isEmpty) return '全部门店/全部组织';
if (_orgTree == null) return '已选 ${_selectedOrgIds.length} 项';
final byId = <String, OrgNode>{};
void index(List<OrgNode> ns) {
for (final n in ns) {
byId[n.id] = n;
index(n.children);
}
}
index(_orgTree!);
final names = <String>[];
for (final id in _selectedOrgIds) {
final n = byId[id];
if (n != null) names.add(n.name);
}
if (names.isEmpty) return '已选 ${_selectedOrgIds.length} 项';
if (names.length == 1) return names.first;
return '${names.first} (+${names.length - 1})';
}
List<Device> _filter(List<Device> devices) {
final keyword = _keyword.trim().toLowerCase();
final model = _modelKeyword.trim().toLowerCase();
final storeName = _storeNameKeyword.trim().toLowerCase();
// 过滤掉已发起删除、正在等待后端确认的设备(用与行 key 一致的唯一标识)。
final visible = devices.where((d) => !_pendingDeleteIds.contains(_rowKey(d)));
if (keyword.isEmpty && model.isEmpty && storeName.isEmpty) {
return visible.toList();
}
return visible.where((device) {
final matchesKeyword = keyword.isEmpty ||
device.mac.toLowerCase().contains(keyword) ||
device.name.toLowerCase().contains(keyword) ||
device.roomNo.toLowerCase().contains(keyword) ||
device.ip.contains(keyword);
final matchesModel = model.isEmpty ||
device.model.toLowerCase().contains(model) ||
device.deviceSn.toLowerCase().contains(model);
final matchesStoreName = storeName.isEmpty ||
_deviceStoreName(device).toLowerCase().contains(storeName);
return matchesKeyword && matchesModel && matchesStoreName;
}).toList();
}
/// 行唯一键:优先用设备 id,缺失时回退 mac,避免空 id 导致 ValueKey('') 重复触发断言。
String _rowKey(Device d) => '${d.id.isNotEmpty ? d.id : ''}__${d.mac}';
/// 滑动后的删除确认:弹出对话框,确认才调用后端删除,取消保留。
Future<bool> _confirmDeleteDevice(Device device) async {
final t = AppLocalizations.of(context);
final name = device.macRoomLabel;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.deleteConfirmContent(name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return false;
// 先本地隐藏,避免删除过程中行回弹再消失造成闪烁。
final key = _rowKey(device);
_pendingDeleteIds.add(key);
if (mounted) setState(() {});
try {
await ref.read(deviceServiceProvider).deleteDevice(device.id);
if (!mounted) return true;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.deleted)),
);
await _loadDevices();
_pendingDeleteIds.remove(key);
if (mounted) setState(() {});
return true;
} catch (e) {
_pendingDeleteIds.remove(key);
if (!mounted) return false;
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')),
);
return false;
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.deviceList),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/device/scan?mode=bind&activate=1'),
icon: const Icon(Icons.add_a_photo),
label: Text(t.scanActivateDevice),
),
body: Column(
children: [
// 顶部筛选区:与门店管理页保持一致。
// 第一行:门店名称搜索 + 独立组织筛选图标;第二行:设备关键字/型号搜索。
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
children: [
// 门店名称搜索 + 组织筛选图标(独立,不与其它筛选项混排)。
Row(
children: [
Expanded(
child: TextField(
controller: _storeNameController,
decoration: InputDecoration(
hintText: t.searchStoreHint,
prefixIcon: const Icon(Icons.search_outlined),
suffixIcon: _storeNameController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear_outlined),
onPressed: () {
_storeNameController.clear();
setState(() => _storeNameKeyword = '');
},
),
),
onSubmitted: (_) => _onStoreSearch(),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.account_tree_outlined),
tooltip: '组织筛选',
onPressed: _openOrgFilter,
),
],
),
const SizedBox(height: 8),
// 设备关键字 / 型号搜索。
Row(
children: [
Expanded(
flex: 3,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchDeviceHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onChanged: (value) => setState(() => _keyword = value),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: TextField(
controller: _modelController,
decoration: InputDecoration(
hintText: t.searchModelHint,
prefixIcon: const Icon(Icons.memory),
suffixIcon: _modelKeyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_modelController.clear();
setState(() => _modelKeyword = '');
},
),
),
onChanged: (value) =>
setState(() => _modelKeyword = value),
),
),
],
),
if (_selectedOrgIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
children: [
Chip(
label: Text(_orgFilterLabel()),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() {
_selectedOrgIds = {};
});
_loadDevices();
},
),
],
),
),
],
),
),
const SizedBox(height: 8),
Expanded(child: _buildDeviceList()),
],
),
);
}
/// 根据页面态([_devices]/[_loadingDevices]/[_deviceError])渲染设备列表。
Widget _buildDeviceList() {
final t = AppLocalizations.of(context);
if (_loadingDevices) {
return const Center(child: CircularProgressIndicator());
}
if (_deviceError != null) {
// 登录态失效:拦截器已清空令牌并触发路由重定向到登录页,
// 此处不再展示底层错误,直接显示加载占位(路由会接管跳转)。
if (isUnauthorizedError(_deviceError!)) {
return const Center(child: CircularProgressIndicator());
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(t.deviceListLoadFailed(formatApiError(_deviceError!))),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadDevices,
child: Text(t.retry),
),
],
),
);
}
final filtered = _filter(_devices);
if (filtered.isEmpty) {
return Center(child: Text(t.noMatchedDevices));
}
return RefreshIndicator(
onRefresh: _loadDevices,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filtered.length,
itemBuilder: (context, index) {
final device = filtered[index];
// 向左滑动(end side)揭示右侧红色「删除」按钮;点击按钮才弹确认框。
// 不用 Dismissible:后者松手过阈值即自动弹窗,无法满足「先露按钮再点击」的交互。
return Slidable(
key: ValueKey(_rowKey(device)),
endActionPane: ActionPane(
motion: const ScrollMotion(),
// 不自动 dismiss,删除以「确认弹窗 + 后端成功」为准。
dismissible: null,
children: [
SlidableAction(
onPressed: (_) => _confirmDeleteDevice(device),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete_outline,
label: t.delete,
),
],
),
child: DeviceListTile(
device: device,
onTap: () => context.push('/device/${device.id}'),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/device.dart';
import '../../providers/dashboard_provider.dart';
import '../../providers/devices_provider.dart';
import '../../app/theme.dart';
import '../../utils/i10n.dart';
import '../../utils/responsive.dart';
import '../../providers/auth_provider.dart';
import '../../utils/app_update.dart';
import '../../utils/permissions.dart';
import '../../widgets/cloud_card.dart';
/// 首页:仪表盘概览(底部导航第 1 个 tab)。
///
/// 展示设备统计卡片与各模块快捷入口。
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
// 加载仪表盘统计(设备/门店总数)。
Future.microtask(
() => ref.read(dashboardStatsProvider.notifier).load(),
);
// 启动后静默检测新版本(无更新/失败均不打扰,仅会话内首次有更新时弹窗)。
final orgId = ref.read(authProvider).user?.orgId;
final ctx = context;
Future.microtask(() {
if (!ctx.mounted) return;
checkAndPromptUpdate(
ctx,
silentIfNoUpdate: true,
orgId: orgId,
);
});
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(devicesProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.appTitle)),
body: devicesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _buildDashboard(context, null),
data: (devices) => _buildDashboard(context, devices),
),
);
}
Widget _buildDashboard(BuildContext context, List<Device>? devices) {
final stats = ref.watch(dashboardStatsProvider);
final totalCount = stats.deviceTotal;
final storeTotal = stats.storeTotal;
// 在线/离线统计改为后端真实聚合值(按 status 拉 totalCount),
// 不再用首屏列表客户端统计,避免与设备总数(12 万级)严重不符。
final onlineCount = stats.onlineCount;
final offlineCount = stats.offlineCount;
final t = AppLocalizations.of(context);
// 统计卡配色纳入橙白语义体系(仅改色,图标图形与 size 冻结不动)。
final scheme = Theme.of(context).colorScheme;
final semantic = Theme.of(context).extension<AppSemanticColors>();
return RefreshIndicator(
onRefresh: () async {
final _ = await ref.refresh(devicesProvider.future);
await ref.read(dashboardStatsProvider.notifier).load();
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_buildBrandBanner(context),
const SizedBox(height: 16),
// 统计卡片
GridView.count(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.4,
children: [
_StatCard(
title: t.totalDevices,
value: '$totalCount',
icon: Icons.devices,
color: scheme.primary,
),
_StatCard(
title: t.totalStores,
value: '$storeTotal',
icon: Icons.store,
color: scheme.tertiary,
),
_StatCard(
title: t.onlineDevices,
value: '$onlineCount',
icon: Icons.check_circle_outline,
color: semantic?.success ?? scheme.tertiary,
),
_StatCard(
title: t.offlineDevices,
value: '$offlineCount',
icon: Icons.highlight_off,
color: scheme.onSurfaceVariant,
),
],
),
const SizedBox(height: 24),
// 快捷操作
Text(
t.quickActions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _QuickAction(
label: t.deviceManagement,
icon: Icons.devices,
onTap: () => context.go('/device'),
),
),
Expanded(
child: _QuickAction(
label: t.storeManagement,
icon: Icons.store,
onTap: () => context.go('/store'),
),
),
if (SessionPermissions.canViewUserManagement(
ref.read(authProvider).user))
Expanded(
child: _QuickAction(
label: t.userManagement,
icon: Icons.people,
onTap: () => context.push('/admin/sys/user'),
),
),
],
),
],
),
);
}
/// 品牌云卡横幅:首页招牌区块(橙→金渐变)。
Widget _buildBrandBanner(BuildContext context) {
final t = AppLocalizations.of(context);
return CloudCard(
child: Row(
children: [
const Icon(Icons.cloud, size: 36),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.appHeroTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const Text(
'终端管控·内容运营·门店管理',
style: TextStyle(fontSize: 13),
),
],
),
),
],
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
final String title;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(title, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.label,
required this.icon,
required this.onTap,
});
final String label;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(icon, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}

View File

@@ -0,0 +1,509 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../app/theme.dart';
import '../../services/auth_service.dart';
import '../../utils/constants.dart';
import '../../utils/credential_storage.dart';
import '../../utils/i10n.dart';
import '../../config/app_config.dart';
import '../../widgets/cloud_card.dart';
/// 登录页。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
final _captchaController = TextEditingController();
bool _obscurePassword = true;
/// 是否记住密码。
bool _rememberPassword = false;
/// 当前验证码图片数据。
Uint8List? _captchaBytes;
/// 当前验证码 UUID,登录时必须原样回传。
String _captchaUuid = '';
/// 是否正在加载验证码。
bool _captchaLoading = false;
/// 验证码加载/刷新错误提示。
String? _captchaError;
@override
void initState() {
super.initState();
_safeLoadSavedCredentials();
if (AppConfig.enableCaptcha) {
_safeRefreshCaptcha();
}
}
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
///
/// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。
void _safeLoadSavedCredentials() {
try {
_loadSavedCredentials();
} catch (_) {
// 读取本地凭据失败时使用空表单,不阻塞登录。
}
}
/// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。
void _safeRefreshCaptcha() {
try {
_refreshCaptcha();
} catch (_) {
// 极端情况下同步抛错,确保页面仍可渲染。
if (mounted) {
setState(() {
_captchaError = '验证码加载失败,请重试';
});
}
}
}
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
void _loadSavedCredentials() {
final cs = CredentialStorage.instance;
if (cs.rememberPassword) {
_rememberPassword = true;
_accountController.text = cs.savedAccount ?? '';
_passwordController.text = cs.savedPassword ?? '';
}
}
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
_captchaController.dispose();
super.dispose();
}
Future<void> _refreshCaptcha() async {
if (!mounted) return;
setState(() {
_captchaLoading = true;
_captchaError = null;
});
try {
final captcha = await AuthService().fetchCaptcha();
if (!mounted) return;
setState(() {
_captchaUuid = captcha.uuid;
_captchaBytes = Uint8List.fromList(captcha.imageBytes);
_captchaLoading = false;
});
} on AuthException catch (e) {
if (!mounted) return;
setState(() {
_captchaError = e.message;
_captchaLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_captchaError = '验证码加载失败,点击重试';
_captchaLoading = false;
});
}
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
final success = await ref.read(authProvider.notifier).login(
account: _accountController.text.trim(),
password: _passwordController.text,
captcha: _captchaController.text.trim(),
uuid: _captchaUuid,
rememberPassword: _rememberPassword,
);
if (!success && mounted && AppConfig.enableCaptcha) {
// 登录失败后刷新验证码,并清空输入。
_captchaController.clear();
await _refreshCaptcha();
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final authState = ref.watch(authProvider);
// 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme,
// 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。
final scheme = Theme.of(context).colorScheme;
final inputFillColor = scheme.surfaceContainerHighest;
final inputBorderColor = scheme.outline;
final inputTextColor = scheme.onSurface;
final inputLabelColor = scheme.onSurfaceVariant;
final cardColor = scheme.surfaceContainerLow;
return Scaffold(
backgroundColor: scheme.surface,
body: Container(
decoration: BoxDecoration(
// 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。
gradient: scheme.brightness == Brightness.light
? AppBrand.cloudGradient
: null,
color: scheme.brightness == Brightness.light ? null : scheme.surface,
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
color: cardColor,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/images/logo.png',
width: 112,
height: 112,
),
const SizedBox(height: 16),
CloudCard(
child: Row(
children: [
const Icon(Icons.cloud, size: 36),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppConstants.appName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
t.appSubtitle,
style: const TextStyle(fontSize: 13),
),
],
),
),
],
),
),
const SizedBox(height: 32),
TextFormField(
controller: _accountController,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '账号',
labelStyle: TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.person_outline),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? '请输入账号'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '密码',
labelStyle: TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
suffixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
color: inputLabelColor,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
validator: (value) =>
(value == null || value.isEmpty) ? '请输入密码' : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
if (AppConfig.enableCaptcha) ...[
const SizedBox(height: 16),
// 验证码区域
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 5,
child: TextFormField(
controller: _captchaController,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '验证码',
labelStyle:
TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide:
BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide:
BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.security_outlined),
),
textInputAction: TextInputAction.done,
validator: (value) =>
(value == null || value.trim().isEmpty)
? '请输入验证码'
: null,
onFieldSubmitted: (_) => _submit(),
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: SizedBox(
height: 52,
child: InkWell(
onTap: _captchaLoading
? null
: _refreshCaptcha,
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: inputFillColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: inputBorderColor,
),
),
alignment: Alignment.center,
child: _buildCaptchaImage(),
),
),
),
),
],
),
if (_captchaError != null) ...[
const SizedBox(height: 8),
Text(
_captchaError!,
style: TextStyle(
color: scheme.error,
fontSize: 12,
),
),
],
],
if (authState.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
authState.errorMessage!,
style: TextStyle(
color: scheme.error,
),
),
],
const SizedBox(height: 12),
// 记住密码
Row(
children: [
Checkbox(
value: _rememberPassword,
onChanged: (v) => setState(
() => _rememberPassword = v ?? false,
),
fillColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.primary
: inputBorderColor,
),
checkColor: scheme.onPrimary,
),
GestureDetector(
onTap: () => setState(
() => _rememberPassword = !_rememberPassword,
),
child: Text(
'记住密码',
style: TextStyle(color: inputTextColor),
),
),
],
),
const SizedBox(height: 12),
FilledButton(
onPressed: authState.isLoading ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
disabledBackgroundColor:
scheme.onSurface.withOpacity(0.12),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: authState.isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onPrimary,
),
)
: const Text('登 录'),
),
const SizedBox(height: 16),
// 仅 mock 模式显示演示账号;真实模式下提示无意义
if (AppConstants.useMock)
Text(
'演示账号:${AppConstants.mockAccount} / '
'${AppConstants.mockPassword}',
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 8),
Text(
'v6-fix68',
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurfaceVariant.withOpacity(0.5),
fontSize: 10),
),
],
),
),
),
),
),
),
),
),
),
);
}
Widget _buildCaptchaImage() {
if (_captchaLoading) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
final bytes = _captchaBytes;
if (bytes != null && bytes.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
bytes,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
size: 20,
color: Color(0xFFB0B0B0),
),
SizedBox(height: 2),
Text(
'点击刷新',
style: TextStyle(
fontSize: 10,
color: Color(0xFFB0B0B0),
),
),
],
);
},
),
);
}
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.refresh,
size: 20,
color: Color(0xFFB0B0B0),
),
SizedBox(height: 2),
Text(
'点击刷新',
style: TextStyle(
fontSize: 10,
color: Color(0xFFB0B0B0),
),
),
],
);
}
}

View File

@@ -0,0 +1,430 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/locale_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/display_provider.dart';
import '../../services/api_client.dart';
import '../../services/sys_service.dart';
import '../../utils/constants.dart';
import '../../utils/i10n.dart';
import '../../utils/app_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// 设置页(底部导航第 3 个 tab):主题切换 / 语言占位 / 关于 / 退出登录。
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
Future<void> _showChangePasswordDialog(BuildContext context, WidgetRef ref) async {
final t = AppLocalizations.of(context);
final oldCtrl = TextEditingController();
final newCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
bool saving = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: Text(t.passwordDialogTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: oldCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.oldPassword,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: newCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.newPassword,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: confirmCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.confirmNewPassword,
border: const OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: saving ? null : () => Navigator.pop(ctx),
child: Text(t.cancel),
),
FilledButton(
onPressed: saving
? null
: () async {
final oldPwd = oldCtrl.text.trim();
final newPwd = newCtrl.text.trim();
final confirmPwd = confirmCtrl.text.trim();
if (oldPwd.isEmpty || newPwd.isEmpty) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.pleaseFillPassword)),
);
return;
}
if (newPwd != confirmPwd) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.passwordsDoNotMatch)),
);
return;
}
setState(() => saving = true);
try {
await SystemService().updatePassword(oldPwd, newPwd);
if (!ctx.mounted) return;
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.passwordChangeSuccess)),
);
// 修改成功后安全登出,要求用新密码重新登录。
Future.delayed(const Duration(seconds: 1), () {
if (context.mounted) {
ref.read(authProvider.notifier).logout();
}
});
} on ApiException catch (e) {
setState(() => saving = false);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e.message)),
);
} catch (e) {
setState(() => saving = false);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.passwordChangeFailed(formatApiError(e)))),
);
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.save),
),
],
),
),
);
oldCtrl.dispose();
newCtrl.dispose();
confirmCtrl.dispose();
}
String _themeModeLabel(ThemeMode mode, AppLocalizations t) {
switch (mode) {
case ThemeMode.dark:
return t.themeDark;
case ThemeMode.light:
return t.themeLight;
case ThemeMode.system:
return t.themeSystem;
}
}
String _orientationLabel(DisplayOrientation mode, AppLocalizations t) {
switch (mode) {
case DisplayOrientation.system:
return t.orientationSystem;
case DisplayOrientation.portrait:
return t.orientationPortrait;
case DisplayOrientation.landscape:
return t.orientationLandscape;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
final displayOrientation = ref.watch(displayOrientationProvider);
final authState = ref.watch(authProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.settings)),
body: ListView(
children: [
if (authState.user != null)
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(authState.user!.displayName),
subtitle: Text(
authState.user!.role.isEmpty
? '${t.account}:${authState.user!.account}'
: authState.user!.role,
),
),
const Divider(),
// 主题切换:深色 / 浅色 / 跟随系统
_AppSettingsTile(
leading: const Icon(Icons.dark_mode_outlined),
title: t.themeMode,
subtitle: _themeModeLabel(themeMode, t),
control: SegmentedButton<ThemeMode>(
segments: [
ButtonSegment(
value: ThemeMode.dark,
icon: const Icon(Icons.dark_mode),
label: Text(t.themeDark),
),
ButtonSegment(
value: ThemeMode.light,
icon: const Icon(Icons.light_mode),
label: Text(t.themeLight),
),
ButtonSegment(
value: ThemeMode.system,
icon: const Icon(Icons.settings_suggest),
label: Text(t.themeSystem),
),
],
selected: {themeMode},
onSelectionChanged: (selection) => ref
.read(themeModeProvider.notifier)
.setThemeMode(selection.first),
),
),
// 语言切换:简体中文 / English
_AppSettingsTile(
leading: const Icon(Icons.language_outlined),
title: t.language,
subtitle: appLocale.label,
control: SegmentedButton<AppLocale>(
segments: const [
ButtonSegment(value: AppLocale.zh, label: Text('中文')),
ButtonSegment(value: AppLocale.en, label: Text('EN')),
],
selected: {appLocale},
onSelectionChanged: (selection) {
try {
ref.read(localeProvider.notifier).setLocale(selection.first);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('语言切换失败:$e')),
);
}
},
),
),
const Divider(),
// 显示方向:跟随系统 / 竖屏 / 横屏(手动切换屏幕方向,布局自动适配)。
_AppSettingsTile(
leading: const Icon(Icons.screen_rotation_outlined),
title: t.orientationMode,
subtitle: _orientationLabel(displayOrientation, t),
control: SegmentedButton<DisplayOrientation>(
segments: [
ButtonSegment(
value: DisplayOrientation.system,
icon: const Icon(Icons.screen_rotation),
label: Text(t.orientationSystem),
),
ButtonSegment(
value: DisplayOrientation.portrait,
icon: const Icon(Icons.stay_current_portrait),
label: Text(t.orientationPortrait),
),
ButtonSegment(
value: DisplayOrientation.landscape,
icon: const Icon(Icons.stay_current_landscape),
label: Text(t.orientationLandscape),
),
],
selected: {displayOrientation},
onSelectionChanged: (selection) => ref
.read(displayOrientationProvider.notifier)
.set(selection.first),
),
),
const Divider(),
// 修改密码
ListTile(
leading: const Icon(Icons.lock_outline),
title: Text(t.changePassword),
onTap: () => _showChangePasswordDialog(context, ref),
),
const Divider(),
// 检查更新
ListTile(
leading: const Icon(Icons.system_update_alt_outlined),
title: Text(t.checkUpdate),
subtitle: _VersionSubtitle(t: t),
onTap: () => checkAndPromptUpdate(
context,
silentIfNoUpdate: false,
orgId: ref.read(authProvider).user?.orgId,
),
),
const Divider(),
// 关于
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(t.about),
subtitle: _VersionSubtitle(t: t, includeName: true),
onTap: () async {
final pkg = await currentPackageInfo;
if (!context.mounted) return;
showAboutDialog(
context: context,
applicationName: AppConstants.appName,
applicationVersion: '${pkg.version} (${pkg.buildNumber})',
applicationIcon: Image.asset(
'assets/images/logo.png',
width: 64,
height: 64,
),
applicationLegalese: 'Device/system operation admin mobile demo',
);
},
),
const Divider(),
// 退出登录
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
title: Text(
t.logout,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.logout),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.logout),
),
],
),
);
if (confirmed == true && context.mounted) {
ref.read(authProvider.notifier).logout();
}
},
),
],
),
);
}
}
/// 设置项:图标 + 标题 + 副标题在上,控件整行铺满在下。
///
/// 用于 [SegmentedButton] 等较宽的控件,避免塞进 [ListTile.trailing]
/// 在窄屏溢出。
class _AppSettingsTile extends StatelessWidget {
const _AppSettingsTile({
required this.title,
required this.control,
this.subtitle,
this.leading,
});
final String title;
final Widget control;
final String? subtitle;
final Widget? leading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Padding(
padding: EdgeInsets.only(left: leading != null ? 32 : 0),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 12),
SizedBox(width: double.infinity, child: control),
],
),
);
}
}
/// 当前 App 版本副标题:异步读取 [PackageInfo] 展示版本名与构建号。
///
/// [includeName]=true 时前缀应用名(用于「关于」项)。
class _VersionSubtitle extends StatelessWidget {
const _VersionSubtitle({required this.t, this.includeName = false});
final AppLocalizations t;
final bool includeName;
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: currentPackageInfo,
builder: (ctx, snap) {
final info = snap.data;
if (includeName) {
// 「关于」项:副标题只展示应用名,版本详情交由「关于」弹窗承载,
// 避免与应用名 + 版本号 + 构建号叠加成冗余且暴露内部构建号的文案。
return const Text(AppConstants.appName);
}
// 「检查更新」项:展示当前已安装版本名(不暴露内部构建号)。
final version = info != null ? info.version : t.loading;
return Text('${t.currentVersion} $version');
},
);
}
}

View File

@@ -0,0 +1,554 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/org_node.dart';
import '../../services/org_service.dart';
import '../../utils/session_org_scope.dart';
/// 组织架构树页:递归树展示、颜色编码、新增/删除/详情。
class OrgTreePage extends ConsumerStatefulWidget {
const OrgTreePage({super.key});
@override
ConsumerState<OrgTreePage> createState() => _OrgTreePageState();
}
class _OrgTreePageState extends ConsumerState<OrgTreePage> {
final OrgService _orgService = OrgService();
List<OrgNode> _tree = [];
bool _loading = false;
String? _error;
OrgNode? _selectedNode;
/// 受限账号(带 orgId 的渠道/代理商等)仅有只读查看权限,隐藏新增/删除等写操作入口。
bool get _canEdit => !SessionOrgScope.instance.enabled;
@override
void initState() {
super.initState();
_loadTree();
}
Future<void> _loadTree() async {
setState(() {
_loading = true;
_error = null;
});
try {
final tree = await _orgService.getOrgTree();
if (!mounted) return;
setState(() {
_tree = tree;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
Future<void> _deleteNode(OrgNode node) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除组织'),
content: Text('确定要删除「${node.name}」吗?子节点也会一并删除。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _orgService.deleteOrg(node.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('组织已删除')),
);
_loadTree();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
void _showAddSheet({OrgNode? parent}) {
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => _OrgFormSheet(
orgService: _orgService,
parentId: parent?.id,
parentName: parent?.name,
onSaved: () {
Navigator.pop(ctx);
_loadTree();
},
),
);
}
void _showDetailSheet(OrgNode node) {
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => _OrgDetailSheet(
node: node,
canEdit: _canEdit,
onAddChild: () {
Navigator.pop(ctx);
_showAddSheet(parent: node);
},
onDelete: () {
Navigator.pop(ctx);
_deleteNode(node);
},
),
);
}
@override
Widget build(BuildContext context) {
final canEdit = _canEdit;
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('组织架构')),
body: _buildBody(),
floatingActionButton: canEdit
? FloatingActionButton(
onPressed: () => _showAddSheet(parent: _selectedNode),
child: const Icon(Icons.add),
)
: null,
);
}
Widget _buildBody() {
final canEdit = _canEdit;
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('加载失败:$_error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadTree,
child: const Text('重试'),
),
],
),
);
}
if (_tree.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.account_tree_outlined,
size: 64, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
const Text('暂无组织数据'),
const SizedBox(height: 8),
if (canEdit) const Text('点击 + 添加组织'),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadTree,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
physics: const AlwaysScrollableScrollPhysics(),
children: _tree.map((node) => _buildTreeNode(node, 0)).toList(),
),
);
}
Widget _buildTreeNode(OrgNode node, int depth) {
final hasChildren = node.children.isNotEmpty;
final typeInfo = _orgTypeInfo(node.type);
final canEdit = _canEdit;
if (hasChildren) {
return ExpansionTile(
key: ValueKey('org-${node.id}'),
leading: _typeDot(typeInfo.color),
title: _nodeTitle(node, typeInfo),
subtitle: Text('${node.children.length} 个子节点',
style: const TextStyle(fontSize: 12)),
children: [
...node.children.map((child) => _buildTreeNode(child, depth + 1)),
if (canEdit)
Padding(
padding: const EdgeInsets.only(left: 56, top: 4, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => _showAddSheet(parent: node),
icon: const Icon(Icons.add, size: 18),
label: const Text('添加子节点'),
),
),
),
],
);
}
return ListTile(
key: ValueKey('org-${node.id}'),
leading: _typeDot(typeInfo.color),
title: _nodeTitle(node, typeInfo),
onLongPress: () => _selectNode(node),
onTap: () => _showDetailSheet(node),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'add') {
_showAddSheet(parent: node);
} else if (value == 'detail') {
_showDetailSheet(node);
} else if (value == 'delete') {
_deleteNode(node);
}
},
itemBuilder: (ctx) => [
if (canEdit)
const PopupMenuItem(value: 'add', child: Text('添加子节点')),
const PopupMenuItem(value: 'detail', child: Text('查看详情')),
if (canEdit)
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
);
}
void _selectNode(OrgNode node) {
setState(() => _selectedNode = node);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已选择「${node.name}」为父节点,点击 + 添加子组织')),
);
}
Widget _nodeTitle(OrgNode node, _TypeInfo info) {
return Row(
children: [
Expanded(
child: Text(
node.name,
style: TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: info.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: info.color.withOpacity(0.6)),
),
child: Text(
info.label,
style: TextStyle(
color: info.color,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _typeDot(Color color) {
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
_TypeInfo _orgTypeInfo(int type) {
switch (type) {
case 0:
return const _TypeInfo('平台', Colors.purple);
case 1:
return const _TypeInfo('渠道', Color(0xFF0B6397)); // 语义信息蓝
case 2:
return const _TypeInfo('代理商', Colors.orange);
case 3:
return const _TypeInfo('门店', Colors.green);
case 4:
return const _TypeInfo('设备分组', Colors.grey);
default:
return const _TypeInfo('未知', Colors.grey);
}
}
}
class _TypeInfo {
const _TypeInfo(this.label, this.color);
final String label;
final Color color;
}
/// 组织详情底部弹窗。
class _OrgDetailSheet extends StatelessWidget {
const _OrgDetailSheet({
required this.node,
required this.onAddChild,
required this.onDelete,
this.canEdit = true,
});
final OrgNode node;
final VoidCallback onAddChild;
final VoidCallback onDelete;
final bool canEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
node.name,
style: theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const Divider(height: 24),
_row(theme, '节点ID', node.id),
_row(theme, '父级ID', node.parentId),
_row(theme, '地址', node.address),
_row(theme, '联系电话', node.contactPhone),
_row(theme, '激活码', node.activationCode),
const SizedBox(height: 20),
if (canEdit)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: onAddChild,
child: const Text('添加子节点'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: onDelete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('删除'),
),
),
],
),
],
),
),
);
}
Widget _row(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(value.isEmpty ? '—' : value),
),
],
),
);
}
}
/// 新增组织表单。
class _OrgFormSheet extends StatefulWidget {
const _OrgFormSheet({
required this.orgService,
this.parentId,
this.parentName,
required this.onSaved,
});
final OrgService orgService;
final String? parentId;
final String? parentName;
final VoidCallback onSaved;
@override
State<_OrgFormSheet> createState() => _OrgFormSheetState();
}
class _OrgFormSheetState extends State<_OrgFormSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
int _type = 1;
bool _saving = false;
static const _typeOptions = [
_TypeOption(0, '平台'),
_TypeOption(1, '渠道'),
_TypeOption(2, '代理商'),
_TypeOption(3, '门店'),
_TypeOption(4, '设备分组'),
];
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final data = <String, dynamic>{
'name': _nameController.text.trim(),
'type': _type,
'parentId': widget.parentId ?? '',
};
await widget.orgService.saveOrUpdateOrg(data);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('组织已创建')),
);
widget.onSaved();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'新增组织',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
if (widget.parentName != null) ...[
const SizedBox(height: 8),
Text(
'父级组织:${widget.parentName}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
const SizedBox(height: 20),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '组织名称',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.label),
),
validator: (v) =>
v == null || v.trim().isEmpty ? '请输入组织名称' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _type,
decoration: const InputDecoration(
labelText: '组织类型',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: _typeOptions
.map((opt) => DropdownMenuItem(
value: opt.type,
child: Text(opt.label),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _type = v);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('创建'),
),
),
],
),
),
),
);
}
}
class _TypeOption {
const _TypeOption(this.type, this.label);
final int type;
final String label;
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
/// 门店详情页(桩页面)。
class StoreDetailPage extends StatelessWidget {
const StoreDetailPage({super.key, required this.storeId});
final String storeId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('门店详情')),
body: Center(child: Text('门店 $storeId\n功能开发中')),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,667 @@
import 'package:dio/dio.dart';
import '../utils/constants.dart';
import '../utils/credential_storage.dart';
import '../config/app_config.dart';
/// 内存态令牌存储。
///
/// token / refreshToken 仅保存在内存中(不引入 shared_preferences,
/// 避免鸿蒙端插件兼容问题),应用重启后需重新登录。
class TokenStore {
TokenStore._();
/// 全局单例。
static final TokenStore instance = TokenStore._();
/// 访问令牌。
String? token;
/// 刷新令牌。
String? refreshToken;
/// 保存登录 / 刷新后的令牌对。
void save({required String token, String? refreshToken}) {
this.token = token;
if (refreshToken != null && refreshToken.isNotEmpty) {
this.refreshToken = refreshToken;
}
}
/// 清空令牌(登出或会话失效时调用)。
void clear() {
token = null;
refreshToken = null;
}
/// 是否持有访问令牌。
bool get hasToken => token != null && token!.isNotEmpty;
}
/// 业务异常:后端统一响应 `{code, msg, data}` 中 code 非成功值时抛出。
class ApiException implements Exception {
const ApiException(this.code, this.message);
/// 业务错误码。
final int code;
/// 错误信息。
final String message;
@override
String toString() => message;
}
/// 把 DioException / 业务异常转成用户可读的简短文本。
///
/// 优先展示后端返回的 msg,其次网络状态,最后兜底提示。
String formatApiError(Object? error, {String fallback = '请求失败,请稍后重试'}) {
if (error is ApiException && error.message.isNotEmpty) {
return error.message;
}
if (error is DioException) {
final inner = error.error;
if (inner is ApiException && inner.message.isNotEmpty) {
return inner.message;
}
final response = error.response;
if (response != null && response.statusCode != null) {
return '服务器响应异常:HTTP ${response.statusCode}';
}
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return '连接超时,请检查网络';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.cancel:
return '请求已取消';
case DioExceptionType.badCertificate:
return '服务器证书校验失败';
default:
break;
}
}
final text = error?.toString();
if (text != null && text.isNotEmpty && text != 'null') {
return text;
}
return fallback;
}
/// 判断异常是否表示登录态失效(token 过期 / 401)。
///
/// 后端常以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」而非 HTTP 401,
/// 拦截器已统一把这类场景包装为 [ApiException](401, '登录已过期,请重新登录')。
///
/// UI 层捕获到此类异常时,应跳过普通错误提示、改由路由重定向引导重新登录,
/// 避免把「登录已过期」之类的底层文案直接暴露给用户(如「查询失败:登录已过期」)。
bool isUnauthorizedError(Object? error) {
ApiException? apiEx;
if (error is ApiException) {
apiEx = error;
} else if (error is DioException && error.error is ApiException) {
apiEx = error.error as ApiException;
}
if (apiEx == null) return false;
return apiEx.code == 401 || apiEx.message.contains('登录已过期');
}
/// HTTP 客户端(Dio 单例)。
///
/// - [AppConstants.useMock] = false:挂载 [ApiInterceptor],对接真实「爱视后台」,
/// 自动附加 Bearer 令牌与防缓存时间戳、解包 `{code,msg,data}`、
/// 401 时用 refreshToken 刷新并重试一次;
/// - [AppConstants.useMock] = true:挂载 [MockInterceptor] 本地返回示例数据。
class ApiClient {
ApiClient._() {
_dio = _buildDio();
}
/// 构建 Dio 实例(拦截器配置集中于此,[reconfigure] 复用)。
///
/// baseUrl 取自 [AppConfig],支持运行时切换环境后端地址。
Dio _buildDio() {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiFullBaseUrl,
connectTimeout: AppConstants.apiTimeout,
receiveTimeout: AppConstants.apiTimeout,
headers: {'Content-Type': 'application/json; charset=utf-8'},
),
);
if (AppConstants.useMock) {
// Mock 在前:直接本地 resolve,后续 ApiInterceptor 仍负责解包信封。
dio.interceptors.add(MockInterceptor());
}
dio.interceptors.add(ApiInterceptor(dio));
return dio;
}
/// 运行时切换后端地址后重建 Dio 单例(保留拦截器配置)。
///
/// 允许重复调用,用于环境切换、语言切换后刷新网络层等场景。
/// 返回重建结果与可能的错误信息,避免直接抛异常导致 UI 崩溃。
({bool success, String? error}) reconfigure() {
try {
_dio = _buildDio();
return (success: true, error: null);
} catch (e) {
return (success: false, error: e.toString());
}
}
/// 全局单例。
static final ApiClient instance = ApiClient._();
/// Dio 实例。使用 [late](非 [late final])以支持 [reconfigure] 重复重建。
late Dio _dio;
/// 会话失效回调(401 刷新失败后触发),由认证层注册以清空登录态。
void Function()? onUnauthorized;
/// 获取 Dio 实例。
Dio get dio => _dio;
}
/// 真实接口拦截器:鉴权头、防缓存时间戳、信封解包、401 刷新重试。
class ApiInterceptor extends Interceptor {
ApiInterceptor(this._dio);
final Dio _dio;
/// 刷新接口路径(避免 401 处理时对自身重试)。
static const String _refreshPath = '/sys/refresh';
/// 标记已重试过的请求,防止循环刷新。
static const String _retriedFlag = '__retried_after_refresh';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// 鉴权:Bearer token(不使用 Cookie)。
final token = TokenStore.instance.token;
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
// 防缓存:仅 GET 在 query 上附加毫秒时间戳 t。
// 注意:POST/PUT 写入请求不缓存,且绝不能把 t 注入业务 body——
// 后端(如 CodeIgniter)按全字段 insert 时会因未知列 `t` 报 500,
// 导致「列表能读、保存失败」。故写入请求保持 body 纯净。
if (options.method == 'GET') {
options.queryParameters['t'] =
DateTime.now().millisecondsSinceEpoch.toString();
}
handler.next(options);
}
@override
Future<void> onResponse(
Response response,
ResponseInterceptorHandler handler,
) async {
final body = response.data;
// 非信封格式(如二进制流)直接放行。
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
handler.next(response);
return;
}
final code = _asInt(body['code']);
final msg = body['msg']?.toString() ?? '请求失败';
// 成功码:0 / 200,解包 data。
if (code == 0 || code == 200) {
response.data = body['data'];
handler.next(response);
return;
}
// 业务 401 或后端以普通业务码返回 token 失效:尝试刷新令牌并重试一次。
final isTokenExpired = _isSessionExpiredMessage(msg);
if ((code == 401 || isTokenExpired) &&
response.requestOptions.path != _refreshPath &&
response.requestOptions.extra[_retriedFlag] != true) {
final retried = await _refreshAndRetry(response.requestOptions, handler);
if (retried) {
return;
}
// 刷新失败:已触发 _notifyUnauthorized() 清空登录态并跳转登录页,
// 将错误信息统一为更友好的文案,避免把原始 DioException 抛给 UI。
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: ApiException(401, '登录已过期,请重新登录'),
),
);
return;
}
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: ApiException(code, msg),
),
);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// HTTP 401:同样尝试刷新令牌并重试一次。
if (err.response?.statusCode == 401 &&
err.requestOptions.path != _refreshPath &&
err.requestOptions.extra[_retriedFlag] != true) {
final retried = await _refreshAndRetry(err.requestOptions, handler);
if (retried) {
return;
}
// _refreshAndRetry 失败路径内部已调用 _notifyUnauthorized,此处不重复调用。
handler.next(err);
return;
}
// 已解包出的业务异常直接透传,其余包装网络错误信息。
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err);
}
/// 刷新令牌并重试原请求;成功返回 true。
Future<bool> _refreshAndRetry(
RequestOptions options,
dynamic handler,
) async {
final refreshed = await _tryRefreshToken();
if (!refreshed) {
_notifyUnauthorized();
return false;
}
try {
options.extra[_retriedFlag] = true;
// 重新附加新 token。
final token = TokenStore.instance.token;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
final retryResponse = await _dio.fetch<dynamic>(options);
if (handler is ResponseInterceptorHandler) {
handler.resolve(retryResponse);
} else if (handler is ErrorInterceptorHandler) {
handler.resolve(retryResponse);
}
return true;
} catch (_) {
_notifyUnauthorized();
return false;
}
}
/// 进行中的刷新请求:并发 401 只发起一次 /sys/refresh,其余共享结果。
Future<bool>? _refreshing;
/// 用 refreshToken 换新 token(带并发锁)。
Future<bool> _tryRefreshToken() {
return _refreshing ??= _doRefreshToken().whenComplete(() {
_refreshing = null;
});
}
/// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。
Future<bool> _doRefreshToken() async {
final refreshToken = TokenStore.instance.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
return false;
}
try {
final rawDio = Dio(
BaseOptions(
baseUrl: AppConfig.apiFullBaseUrl,
connectTimeout: AppConstants.apiTimeout,
receiveTimeout: AppConstants.apiTimeout,
headers: {'Content-Type': 'application/json; charset=utf-8'},
),
);
final response = await rawDio.post<Map<String, dynamic>>(
_refreshPath,
data: {
'refreshToken': refreshToken,
't': DateTime.now().millisecondsSinceEpoch.toString(),
},
);
final body = response.data;
if (body == null) {
return false;
}
final code = _asInt(body['code']);
if (code != 0 && code != 200) {
return false;
}
final data = body['data'];
if (data is Map<String, dynamic> && data['token'] != null) {
final newToken = data['token'].toString();
final newRefreshToken = data['refreshToken']?.toString();
TokenStore.instance.save(
token: newToken,
refreshToken: newRefreshToken,
);
// 刷新后的新令牌必须同步持久化,否则切换语言/进程重启后
// 会恢复到旧令牌,导致「token 失效」报错。
await CredentialStorage.instance.saveSession(
token: newToken,
refreshToken: newRefreshToken,
);
return true;
}
return false;
} catch (_) {
return false;
}
}
/// 会话失效:清空令牌并通知认证层(跳转登录页)。
void _notifyUnauthorized() {
TokenStore.instance.clear();
ApiClient.instance.onUnauthorized?.call();
}
/// 判断后端返回的 msg 是否为 token/会话失效类提示。
///
/// 爱视后台有时以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」,
/// 而非 HTTP 401;此处统一识别并走刷新/登出流程。
static bool _isSessionExpiredMessage(String msg) {
final lower = msg.toLowerCase();
if (!lower.contains('token') && !lower.contains('登录')) return false;
return lower.contains('失效') ||
lower.contains('过期') ||
lower.contains('无效') ||
lower.contains('请重新登录') ||
lower.contains('未登录');
}
static int _asInt(dynamic value) {
if (value is int) {
return value;
}
return int.tryParse(value?.toString() ?? '') ?? -1;
}
}
/// Mock 拦截器(仅 [AppConstants.useMock] = true 时挂载)。
///
/// 返回与真实后端一致的 `{code, msg, data}` 信封,由 [ApiInterceptor] 统一解包。
class MockInterceptor extends Interceptor {
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
// 模拟网络延迟。
Future.delayed(const Duration(milliseconds: 300), () {
handler.resolve(
Response(
requestOptions: options,
statusCode: 200,
data: _mockResponse(options),
),
);
});
}
Map<String, dynamic> _ok(Object? data) {
return {'code': 0, 'msg': 'success', 'data': data};
}
Map<String, dynamic> _mockResponse(RequestOptions options) {
final path = options.path;
// 登录:POST /sys/login
if (path == '/sys/login') {
final body = options.data;
if (body is Map &&
body['username'] == AppConstants.mockAccount &&
body['password'] == AppConstants.mockPassword) {
return _ok({
'token': 'mock-token-20240520',
'expire': 7200,
'refreshToken': 'mock-refresh-token',
'refreshExpire': 604800,
'orgId': 1,
'orgType': 1,
'superAdmin': true,
'roleNames': ['运维管理员'],
});
}
return {'code': 500, 'msg': '账号或密码错误', 'data': null};
}
// 登出:POST /sys/logout
if (path == '/sys/logout') {
return _ok(null);
}
// 验证码图片:GET /captcha.jpg?uuid=xxx
// mock 模式下返回一个 1x1 透明 PNG 占位图。
if (path == '/captcha.jpg') {
return {'code': 0, 'msg': 'success', 'data': _transparentPng};
}
// 当前用户:GET /sys/user/info
if (path == '/sys/user/info') {
return _ok({
'username': AppConstants.mockAccount,
'realName': '运维管理员',
'roleNames': ['运维管理员'],
});
}
// 设备列表:GET /device/info/list
if (path == '/device/info/list') {
return _ok({'list': _mockDevices, 'totalCount': _mockDevices.length});
}
// 设备详情:GET /device/info/info/{id}
final deviceDetailMatch =
RegExp(r'^/device/info/info/([A-Za-z0-9\-]+)$').firstMatch(path);
if (deviceDetailMatch != null) {
final id = deviceDetailMatch.group(1);
for (final device in _mockDevices) {
if (device['id'] == id) {
return _ok(device);
}
}
return {'code': 404, 'msg': '未找到设备:$id', 'data': null};
}
// 设备指令:POST /device/command/sendByMac | sendByOrg
if (path == '/device/command/sendByMac' ||
path == '/device/command/sendByOrg') {
return _ok(null);
}
// 告警列表(仅 mock 模式使用;真实模式由设备列表推导):GET /alerts
if (path == '/alerts') {
return _ok(_mockAlerts);
}
return {'code': 404, 'msg': '接口不存在:$path', 'data': null};
}
/// mock 验证码占位图:1x1 透明 PNG(Base64)。
static const String _transparentPng =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
/// 示例设备数据(真实 DeviceInfo 结构 + 演示用扩展字段:
/// ip / location / os / uptimeSeconds / metrics)。
static final List<Map<String, dynamic>> _mockDevices = [
{
'id': 'dev-001',
'mac': 'AA:BB:CC:00:00:11',
'name': '核心交换机-SW01',
'deviceSn': 'SN-SW01-2024',
'deviceUid': 'UID-0001',
'status': 'online',
'groupId': 'g-core',
'lastSyncTime': '2024-05-20 09:30:00',
'createTime': '2024-01-15 10:00:00',
'ip': '10.0.1.11',
'location': '华南机房-A区',
'os': 'Huawei VRP V8',
'uptimeSeconds': 1861272,
'metrics': {
'cpuUsage': 0.32,
'memoryUsage': 0.41,
'diskUsage': 0.55,
'networkRxKbps': 1280.5,
'networkTxKbps': 986.2,
},
},
{
'id': 'dev-002',
'mac': 'AA:BB:CC:00:00:21',
'name': '应用服务器-APP01',
'deviceSn': 'SN-APP01-2024',
'deviceUid': 'UID-0002',
'status': 'warning',
'groupId': 'g-app',
'lastSyncTime': '2024-05-20 09:28:40',
'createTime': '2024-01-16 14:20:00',
'ip': '10.0.2.21',
'location': '华南机房-B区',
'os': 'CentOS Stream 9',
'uptimeSeconds': 952133,
'metrics': {
'cpuUsage': 0.87,
'memoryUsage': 0.92,
'diskUsage': 0.63,
'networkRxKbps': 4520.8,
'networkTxKbps': 3890.1,
},
},
{
'id': 'dev-003',
'mac': 'AA:BB:CC:00:00:31',
'name': '数据库服务器-DB01',
'deviceSn': 'SN-DB01-2024',
'deviceUid': 'UID-0003',
'status': 'online',
'groupId': 'g-db',
'lastSyncTime': '2024-05-20 09:29:55',
'createTime': '2024-01-18 08:00:00',
'ip': '10.0.2.31',
'location': '华南机房-B区',
'os': 'openEuler 22.03 LTS',
'uptimeSeconds': 3654820,
'metrics': {
'cpuUsage': 0.45,
'memoryUsage': 0.68,
'diskUsage': 0.78,
'networkRxKbps': 2310.4,
'networkTxKbps': 1980.6,
},
},
{
'id': 'dev-004',
'mac': 'AA:BB:CC:00:00:42',
'name': '存储节点-NAS02',
'deviceSn': 'SN-NAS02-2024',
'deviceUid': 'UID-0004',
'status': 'offline',
'groupId': 'g-store',
'lastSyncTime': '2024-05-20 07:58:00',
'createTime': '2024-02-01 09:00:00',
'ip': '10.0.3.42',
'location': '华北机房-C区',
'os': 'TrueNAS SCALE 23.10',
'uptimeSeconds': 0,
'metrics': {
'cpuUsage': 0.0,
'memoryUsage': 0.0,
'diskUsage': 0.0,
'networkRxKbps': 0.0,
'networkTxKbps': 0.0,
},
},
{
'id': 'dev-005',
'mac': 'AA:BB:CC:00:00:107',
'name': '边缘网关-EDGE07',
'deviceSn': 'SN-EDGE07-2024',
'deviceUid': 'UID-0005',
'status': 'online',
'groupId': 'g-edge',
'lastSyncTime': '2024-05-20 09:31:10',
'createTime': '2024-03-05 16:40:00',
'ip': '172.16.8.107',
'location': '华东边缘节点',
'os': 'OpenHarmony 4.1',
'uptimeSeconds': 612345,
'metrics': {
'cpuUsage': 0.21,
'memoryUsage': 0.35,
'diskUsage': 0.48,
'networkRxKbps': 320.7,
'networkTxKbps': 288.3,
},
},
];
/// 示例告警数据(仅 mock 模式使用)。
static final List<Map<String, dynamic>> _mockAlerts = [
{
'id': 'alert-001',
'deviceId': 'dev-002',
'deviceName': '应用服务器-APP01',
'title': '内存使用率过高',
'message': '内存使用率达到 92%,超过阈值 85%,请尽快扩容或排查内存泄漏。',
'level': 'critical',
'occurredAt': '2024-05-20T09:12:30.000',
'acknowledged': false,
},
{
'id': 'alert-002',
'deviceId': 'dev-002',
'deviceName': '应用服务器-APP01',
'title': 'CPU 使用率过高',
'message': 'CPU 使用率达到 87%,超过阈值 80%。',
'level': 'warning',
'occurredAt': '2024-05-20T08:45:10.000',
'acknowledged': true,
},
{
'id': 'alert-003',
'deviceId': 'dev-004',
'deviceName': '存储节点-NAS02',
'title': '设备离线',
'message': '存储节点连续 10 分钟无心跳,请检查电源与网络连接。',
'level': 'critical',
'occurredAt': '2024-05-20T07:58:00.000',
'acknowledged': false,
},
{
'id': 'alert-004',
'deviceId': 'dev-003',
'deviceName': '数据库服务器-DB01',
'title': '磁盘空间不足',
'message': '磁盘使用率达到 78%,接近阈值 80%,建议清理归档日志。',
'level': 'warning',
'occurredAt': '2024-05-19T22:30:45.000',
'acknowledged': true,
},
{
'id': 'alert-005',
'deviceId': 'dev-001',
'deviceName': '核心交换机-SW01',
'title': '端口流量波动',
'message': '上联端口 GE0/0/1 出现短时流量尖峰,已自动恢复。',
'level': 'info',
'occurredAt': '2024-05-19T16:05:22.000',
'acknowledged': true,
},
];
}

View File

@@ -0,0 +1,212 @@
import 'package:dio/dio.dart';
import '../models/app_version.dart';
import '../models/tv_app.dart';
import 'api_client.dart';
import '../utils/session_org_scope.dart';
/// 应用管理服务:APP 版本管理与 TV 应用管理。
///
/// 对接真实「爱视后台」:
/// - APP 版本:/api/device/orgGroupAppVersion/*
/// - TV 应用:/api/theme/tvapp/*
class AppService {
AppService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ APP 版本管理 ================
/// 获取 APP 版本列表(分页,可按组织/分组筛选)。
///
/// GET /device/orgGroupAppVersion/list?page=&limit=&orgId=&groupId=
Future<List<AppVersion>> getAppVersionList({
int page = 1,
int limit = 20,
String? orgId,
String? groupId,
}) async {
final effectiveOrgId = orgId ??
(SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
final response = await _dio.get<dynamic>(
'/device/orgGroupAppVersion/list',
queryParameters: {
'page': page,
'limit': limit,
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
if (groupId != null) 'groupId': groupId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(AppVersion.fromJson)
.toList();
}
/// 按 orgId 查询该组织直接配置的版本(单体)。
///
/// 对齐 Web 端 `searchAppVersion`:每个 scope=ORG 的组织至多一条版本配置,
/// 有则返回当前版本(进入编辑流程)、无则返回 null(走新增流程)。
///
/// GET /device/orgGroupAppVersion/search?orgId=
///
/// 注意:拦截器已解包 `{code,msg,data}` 信封,`response.data` 即内层 data;
/// 兼容 data 为 `{appVersion: {...}}`、版本对象直接返回、或 null(无配置)三种形态。
Future<Map<String, dynamic>?> searchAppVersion({
required String orgId,
String? groupId,
}) async {
if (orgId.isEmpty && groupId == null) {
throw const ApiException(400, 'orgId 与 groupId 需二选一');
}
final response = await _dio.get<dynamic>(
'/device/orgGroupAppVersion/search',
queryParameters: {
if (orgId.isNotEmpty) 'orgId': orgId,
if (groupId != null) 'groupId': groupId,
},
);
final data = response.data;
if (data == null) return null;
if (data is Map<String, dynamic>) {
final appVersion = data['appVersion'];
if (appVersion is Map) {
return Map<String, dynamic>.from(appVersion);
}
// 直接是版本对象(含 id 或 versionName)。
if (data.containsKey('id') || data.containsKey('versionName')) {
return data;
}
}
return null;
}
/// 获取 APP 版本详情。
///
/// GET /device/orgGroupAppVersion/info/{id}
Future<AppVersion> getAppVersionInfo(String id) async {
final response =
await _dio.get<dynamic>('/device/orgGroupAppVersion/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, 'APP 版本信息加载失败:$id');
}
return AppVersion.fromJson(data);
}
/// 新增 APP 版本。
///
/// POST /device/orgGroupAppVersion/save,body 为版本数据。
Future<void> saveAppVersion(Map<String, dynamic> data) async {
await _dio.post<dynamic>(
'/device/orgGroupAppVersion/save',
data: data,
);
}
/// 修改 APP 版本。
///
/// POST /device/orgGroupAppVersion/update,body 为版本数据(含 id)。
Future<void> updateAppVersion(Map<String, dynamic> data) async {
await _dio.post<dynamic>(
'/device/orgGroupAppVersion/update',
data: data,
);
}
/// 批量删除 APP 版本。
///
/// POST /device/orgGroupAppVersion/delete,body 为 `{ids: [...]}`。
Future<void> deleteAppVersion(List<int> ids) async {
await _dio.post<dynamic>(
'/device/orgGroupAppVersion/delete',
data: {'ids': ids},
);
}
// ================ TV 应用管理 ================
/// 获取 TV 应用列表(分页,可按名称/组织/分组筛选)。
///
/// GET /theme/tvapp/list?page=&limit=&appName=&orgId=&groupId=
Future<List<TvApp>> getTvAppList({
int page = 1,
int limit = 20,
String? appName,
String? orgId,
String? groupId,
}) async {
final effectiveOrgId = orgId ??
(SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
final response = await _dio.get<dynamic>(
'/theme/tvapp/list',
queryParameters: {
'page': page,
'limit': limit,
if (appName != null) 'appName': appName,
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
if (groupId != null) 'groupId': groupId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(TvApp.fromJson)
.toList();
}
/// 获取 TV 应用详情。
///
/// GET /theme/tvapp/info/{id}
Future<TvApp> getTvAppInfo(String id) async {
final response = await _dio.get<dynamic>('/theme/tvapp/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, 'TV 应用信息加载失败:$id');
}
return TvApp.fromJson(data);
}
/// 新增或修改 TV 应用。
///
/// POST /theme/tvapp/save,body 为应用数据。
Future<void> saveTvApp(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/theme/tvapp/save', data: data);
}
/// 批量删除 TV 应用。
///
/// POST /theme/tvapp/delete,body 为 `{ids: [...]}`。
Future<void> deleteTvApp(List<int> ids) async {
await _dio.post<dynamic>(
'/theme/tvapp/delete',
data: {'ids': ids},
);
}
// ================ 工具方法 ================
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,264 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/user.dart';
import '../utils/constants.dart';
import '../utils/uuid.dart';
import '../config/app_config.dart';
import 'api_client.dart';
/// 认证服务:对接真实「爱视后台」/api/sys/* 接口。
///
/// mock 模式下([MockInterceptor] 挂载)请求路径与真实一致,
/// 由拦截器本地返回示例数据,service 层代码两种模式完全统一。
class AuthService {
AuthService({Dio? dio, Dio? rawDio})
: _dio = dio ?? ApiClient.instance.dio,
_rawDio = rawDio ?? _createRawDio();
final Dio _dio;
final Dio _rawDio;
/// 用于拉取验证码图片的独立 Dio 实例(不经过 ApiInterceptor 信封解包)。
static Dio _createRawDio() {
return Dio(
BaseOptions(
baseUrl: AppConfig.apiFullBaseUrl,
connectTimeout: AppConstants.apiTimeout,
receiveTimeout: AppConstants.apiTimeout,
responseType: ResponseType.bytes,
),
);
}
/// 获取验证码图片字节与对应 UUID。
///
/// 接口:GET /captcha.jpg?uuid=xxx,返回图片二进制。
/// web 端生成 UUID 规则:[generateUuid]。
Future<Captcha> fetchCaptcha() async {
final uuid = generateUuid();
// mock 模式直接返回占位图,不发起真实请求。
if (AppConstants.useMock) {
return Captcha(uuid: uuid, imageBytes: _mockCaptchaBytes);
}
final response = await _rawDio.get<List<int>>(
'/captcha.jpg',
queryParameters: {'uuid': uuid},
);
final bytes = response.data;
if (bytes == null || bytes.isEmpty) {
throw const AuthException('验证码加载失败');
}
return Captcha(uuid: uuid, imageBytes: bytes);
}
/// 登录。
///
/// 请求:POST /sys/login。
/// 当 [AppConfig.enableCaptcha] 为真时,body 携带 `{username, password,
/// captcha, uuid}`;否则仅发送 `{username, password}`,由后端会话策略决定
/// 是否仍需校验验证码(前端已不再展示/收集验证码)。
/// 成功后令牌写入 [TokenStore](内存态),失败抛出 [AuthException]。
Future<User> login({
required String account,
required String password,
String captcha = '',
String uuid = '',
}) async {
try {
// 仅当全局启用验证码时才把 captcha/uuid 填入请求体,
// 关闭后登录界面不展示验证码,请求体也不携带这两个字段。
final body = <String, dynamic>{
'username': account,
'password': password,
};
if (AppConfig.enableCaptcha) {
body['captcha'] = captcha;
body['uuid'] = uuid;
}
// 响应已被 ApiInterceptor 解包,data 即业务数据。
final response = await _dio.post<dynamic>(
'/sys/login',
data: body,
);
final data = response.data;
if (data is! Map<String, dynamic> || data['token'] == null) {
throw const AuthException('登录响应格式异常');
}
final token = data['token'].toString();
TokenStore.instance.save(
token: token,
refreshToken: data['refreshToken']?.toString(),
);
// 尝试获取当前用户信息以补充显示名称、组织与角色 ID;失败不影响登录。
final profile = await _tryFetchUserInfo(fallbackName: account);
// 登录响应中直接取 orgId/roleId;若不存在则使用 /sys/user/info 结果。
final loginOrgId = data['orgId']?.toString();
final loginRoleId = data['roleId']?.toString() ??
(data['roles'] is List && (data['roles'] as List).isNotEmpty
? (data['roles'] as List).first['roleId']?.toString()
: null);
final loginOrgType = data['orgType'];
final loginSuperAdmin = data['superAdmin'];
return User(
id: loginOrgId ?? account,
account: account,
displayName: profile.displayName,
role: _joinRoleNames(data['roleNames']) ?? profile.role ?? '',
token: token,
orgId: loginOrgId ?? profile.orgId ?? '',
roleId: loginRoleId ?? profile.roleId ?? '',
orgType: loginOrgType is int
? loginOrgType
: int.tryParse(loginOrgType?.toString() ?? '') ??
profile.orgType,
superAdmin: loginSuperAdmin is bool
? loginSuperAdmin
: profile.superAdmin,
);
} on AuthException {
rethrow;
} on DioException catch (e) {
throw AuthException(_errorMessage(e, fallback: '账号或密码错误'));
}
}
/// 登出:通知后端(失败忽略),并清空内存令牌。
Future<void> logout() async {
try {
await _dio.post<dynamic>('/sys/logout');
} catch (_) {
// 登出接口失败不阻塞本地登出。
} finally {
TokenStore.instance.clear();
}
}
/// 拉取用户资料(GET /sys/user/info);失败时返回兜底值。
Future<_UserProfile> _tryFetchUserInfo({required String fallbackName}) async {
try {
final response = await _dio.get<dynamic>('/sys/user/info');
final data = response.data;
if (data is Map<String, dynamic>) {
final roleId = data['roleId']?.toString() ??
_extractFirstRoleId(data['roles'] ?? data['role']);
final orgId = data['orgId']?.toString() ??
_extractNestedId(data['org'] ?? data['dept']);
final rawOrgType = data['orgType'];
final rawSuperAdmin = data['superAdmin'];
return _UserProfile(
displayName: (data['realName'] ??
data['nickname'] ??
data['username'] ??
fallbackName)
.toString(),
role: _joinRoleNames(data['roleNames']),
orgId: orgId,
roleId: roleId,
orgType: rawOrgType is int
? rawOrgType
: int.tryParse(rawOrgType?.toString() ?? '') ?? 0,
superAdmin: rawSuperAdmin is bool ? rawSuperAdmin : false,
);
}
} catch (_) {
// 用户信息接口异常时使用兜底资料。
}
return _UserProfile(displayName: fallbackName, role: null);
}
/// 从角色对象或列表中提取第一个 roleId。
static String? _extractFirstRoleId(dynamic value) {
if (value is List && value.isNotEmpty) {
return _extractFirstRoleId(value.first);
}
if (value is Map<String, dynamic>) {
return value['roleId']?.toString() ?? value['id']?.toString();
}
return null;
}
/// 从组织/部门对象中提取 ID。
static String? _extractNestedId(dynamic value) {
if (value is Map<String, dynamic>) {
return value['orgId']?.toString() ??
value['id']?.toString() ??
value['deptId']?.toString();
}
return null;
}
/// roleNames 列表拼接为展示字符串。
static String? _joinRoleNames(dynamic roleNames) {
if (roleNames is List && roleNames.isNotEmpty) {
return roleNames.map((e) => e.toString()).join('/');
}
return null;
}
/// 从 Dio 异常中提取业务错误信息。
static String _errorMessage(DioException e, {required String fallback}) {
final error = e.error;
if (error is ApiException && error.message.isNotEmpty) {
return error.message;
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError) {
return '网络异常,请检查网络后重试';
}
return fallback;
}
/// mock 模式下的占位验证码图片:1x1 透明 PNG。
static final List<int> _mockCaptchaBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
);
}
/// 用户资料中间结构(service 内部使用)。
class _UserProfile {
const _UserProfile({
required this.displayName,
this.role,
this.orgId,
this.roleId,
this.orgType = 0,
this.superAdmin = false,
});
final String displayName;
final String? role;
final String? orgId;
final String? roleId;
final int orgType;
final bool superAdmin;
}
/// 验证码数据。
class Captcha {
const Captcha({required this.uuid, required this.imageBytes});
/// 与该验证码图片绑定的 UUID,登录时必须原样回传。
final String uuid;
/// 验证码 PNG/JPEG 图片字节。
final List<int> imageBytes;
}
/// 认证异常。
class AuthException implements Exception {
const AuthException(this.message);
final String message;
@override
String toString() => message;
}

View File

@@ -0,0 +1,355 @@
// 腾讯云 COS 直传客户端(纯 Dart 实现,对齐 Web 端 cos-js-sdk-v5 的 STS 临时密钥签名)。
//
// 来源:flutter_cos_client (MIT) 的本地化副本。
// 之所以 vendoring 而非直接依赖 pub 包:
// - 原 pub 包 (0.0.1) 在生成签名时硬编码 `expires = now + 3600`,
// 当后端下发的 STS 临时密钥有效期短于 1 小时时,COS 会以 403 拒绝
// (q-sign-time 末尾超出凭证 ExpiredTime)。此处改为尊重凭证自身的 ExpiredTime。
// - 移除对未维护 0.0.1 包的依赖,便于长期维护。
// - 去掉 mime 依赖,用内置扩展名→Content-Type 映射。
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
/// 临时云 API 凭据。
class CosCredential {
/// 临时密钥 Id。
final String tmpSecretId;
/// 临时密钥 Key。
final String tmpSecretKey;
/// 请求时需要用的 token 字符串。
final String sessionToken;
CosCredential({
required this.tmpSecretId,
required this.tmpSecretKey,
required this.sessionToken,
});
}
/// COS 临时凭据数据。
class CosCredentialData {
/// 密钥的起始时间,UNIX 时间戳(秒)。
final int startTime;
/// 密钥的失效时间,UNIX 时间戳(秒)。
final int expiredTime;
/// 临时云 API 凭据。
final CosCredential credentials;
/// 请求 ID。
final String requestId;
CosCredentialData({
required this.startTime,
required this.expiredTime,
required this.credentials,
required this.requestId,
});
/// 检查凭据是否已过期(提前 5 分钟判断为过期)。
bool get isExpired {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return now >= (expiredTime - 300);
}
}
/// 临时秘钥提供者。
class TemporaryCredentialProvider {
/// 获取临时凭据的异步函数。
final Future<CosCredentialData> Function() fetchCredentials;
/// 缓存的临时凭据。
CosCredentialData? _cachedCredentials;
TemporaryCredentialProvider({required this.fetchCredentials});
/// 获取有效的临时凭据(缓存过期则重新获取)。
Future<CosCredentialData> getCredentials() async {
if (_cachedCredentials == null || _cachedCredentials!.isExpired) {
_cachedCredentials = await fetchCredentials();
}
return _cachedCredentials!;
}
/// 清除缓存的凭据。
void clearCache() {
_cachedCredentials = null;
}
}
/// 腾讯云 COS 客户端。
class CosClient {
/// 地域,例如:ap-beijing。
final String region;
/// 存储桶名称,格式:bucketname-appid。
final String bucket;
/// 临时秘钥提供者。
final TemporaryCredentialProvider? temporaryCredentialProvider;
late final Dio _dio;
/// 构造函数(使用临时密钥提供者)。
CosClient.withTemporaryCredential({
required this.temporaryCredentialProvider,
required this.region,
required this.bucket,
BaseOptions? dioOptions,
}) {
_dio = Dio(dioOptions ?? BaseOptions());
}
/// 获取存储桶的 Host。
String get _host => '$bucket.cos.$region.myqcloud.com';
/// 上传对象(字节数据)。
Future<CosUploadResult> putObject({
required String objectKey,
required List<int> fileBytes,
String? fileName,
String? contentType,
void Function(int count, int total)? onProgress,
}) async {
// 确保 objectKey 以 / 开头。
if (!objectKey.startsWith('/')) {
objectKey = '/$objectKey';
}
final fileLength = fileBytes.length;
// 计算 Content-MD5。
final md5Digest = md5.convert(fileBytes);
final contentMd5 = base64.encode(md5Digest.bytes);
// 确定 Content-Type。
contentType ??= _getContentType(fileName ?? '');
// 获取密钥(临时)。
String currentSecretId;
String currentSecretKey;
String? sessionToken;
int signExpires;
if (temporaryCredentialProvider != null) {
final credentials = await temporaryCredentialProvider!.getCredentials();
currentSecretId = credentials.credentials.tmpSecretId;
currentSecretKey = credentials.credentials.tmpSecretKey;
sessionToken = credentials.credentials.sessionToken;
// 关键修复:签名有效期不得超过 STS 凭证自身的 ExpiredTime,
// 否则 COS 会以 403 拒绝(q-sign-time 末尾超出凭证有效期)。
signExpires = credentials.expiredTime;
} else {
throw ArgumentError('CosClient 仅支持临时密钥(STS)方式');
}
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 构建请求头(临时密钥需包含 x-cos-security-token)。
final headers = <String, String>{
'content-length': fileLength.toString(),
'content-md5': contentMd5,
'content-type': contentType,
'host': _host,
};
if (sessionToken != null) {
headers['x-cos-security-token'] = sessionToken;
}
// 生成签名。
final authorization = _generateAuthorization(
method: 'put',
uri: objectKey,
headers: headers,
queryParams: {},
now: now,
expires: signExpires,
secretId: currentSecretId,
secretKey: currentSecretKey,
);
// 构建请求 URL。
final url = 'https://$_host$objectKey';
// 构建实际请求头。
final requestHeaders = <String, String>{
'Authorization': authorization,
'Content-Type': contentType,
'Content-Length': fileLength.toString(),
'Content-MD5': contentMd5,
'Host': _host,
};
if (sessionToken != null) {
requestHeaders['x-cos-security-token'] = sessionToken;
}
try {
final response = await _dio.put(
url,
data: Stream.fromIterable(fileBytes.map((e) => [e])),
options: Options(
headers: requestHeaders,
contentType: contentType,
responseType: ResponseType.plain,
validateStatus: (status) => status != null && status < 500,
),
onSendProgress: onProgress,
);
if (response.statusCode == 200) {
return CosUploadResult(
success: true,
statusCode: response.statusCode!,
etag: response.headers.value('etag'),
message: 'Upload successful',
url: url,
);
}
return CosUploadResult(
success: false,
statusCode: response.statusCode!,
message: 'Upload failed: ${response.statusMessage}, ${response.data}',
);
} catch (e) {
return CosUploadResult(
success: false,
statusCode: -1,
message: 'Upload error: $e',
);
}
}
/// 从文件上传对象(便捷方法)。
Future<CosUploadResult> putObjectFromFile({
required String objectKey,
required File file,
String? contentType,
void Function(int count, int total)? onProgress,
}) async {
final fileBytes = await file.readAsBytes();
return putObject(
objectKey: objectKey,
fileBytes: fileBytes,
fileName: file.path,
contentType: contentType,
onProgress: onProgress,
);
}
/// 生成 Authorization 签名(腾讯云 q-sign-algorithm=sha1)。
String _generateAuthorization({
required String method,
required String uri,
required Map<String, String> headers,
required Map<String, String> queryParams,
required int now,
required int expires,
required String secretId,
required String secretKey,
}) {
final keyTime = '$now;$expires';
final signKey = _hmacSha1(secretKey, keyTime);
final urlParamList = _generateParamList(queryParams);
final httpParameters = _generateHttpParameters(queryParams);
final headerList = _generateParamList(headers);
final httpHeaders = _generateHttpParameters(headers);
final httpString =
'${method.toLowerCase()}\n$uri\n$httpParameters\n$httpHeaders\n';
final httpStringSha1 = sha1.convert(utf8.encode(httpString)).toString();
final stringToSign = 'sha1\n$keyTime\n$httpStringSha1\n';
final signature = _hmacSha1(signKey, stringToSign);
return 'q-sign-algorithm=sha1'
'&q-ak=$secretId'
'&q-sign-time=$keyTime'
'&q-key-time=$keyTime'
'&q-header-list=$headerList'
'&q-url-param-list=$urlParamList'
'&q-signature=$signature';
}
/// HMAC-SHA1 加密。
String _hmacSha1(String key, String data) {
final hmac = Hmac(sha1, utf8.encode(key));
final digest = hmac.convert(utf8.encode(data));
return digest.toString();
}
/// 生成参数列表(key 排序后用 ; 分隔)。
String _generateParamList(Map<String, String> params) {
if (params.isEmpty) return '';
final keys = params.keys.toList()..sort();
return keys.map((k) => k.toLowerCase()).join(';');
}
/// 生成 HTTP 参数字符串(key=value 格式,用 & 分隔,需 URL 编码)。
String _generateHttpParameters(Map<String, String> params) {
if (params.isEmpty) return '';
final keys = params.keys.toList()..sort();
return keys
.map((k) =>
'${Uri.encodeComponent(k.toLowerCase())}=${Uri.encodeComponent(params[k]!)}')
.join('&');
}
/// 根据文件扩展名获取 Content-Type(替代 mime 依赖)。
String _getContentType(String filePath) {
final lower = filePath.toLowerCase();
final dot = lower.lastIndexOf('.');
final ext = dot >= 0 ? lower.substring(dot + 1) : '';
const map = <String, String>{
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'mp4': 'video/mp4',
'webm': 'video/webm',
'mov': 'video/quicktime',
'm4v': 'video/x-m4v',
'pdf': 'application/pdf',
};
return map[ext] ?? 'application/octet-stream';
}
}
/// COS 上传结果。
class CosUploadResult {
/// 是否成功。
final bool success;
/// HTTP 状态码。
final int statusCode;
/// ETag(文件的唯一标识)。
final String? etag;
/// 消息。
final String? message;
/// 对象的 COS 访问地址。
final String? url;
CosUploadResult({
required this.success,
required this.statusCode,
this.etag,
this.message,
this.url,
});
@override
String toString() {
return 'CosUploadResult{success: $success, statusCode: $statusCode, '
'etag: $etag, message: $message, url: $url}';
}
}

View File

@@ -0,0 +1,612 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../models/alert.dart';
import '../models/device.dart';
import '../models/org_node.dart';
import '../utils/constants.dart';
import '../utils/session_org_scope.dart';
import '../utils/store_local_cache.dart';
import 'org_service.dart';
import 'api_client.dart';
/// 设备与告警数据服务:对接真实「爱视后台」/api/device/* 接口。
///
/// - 设备列表:GET /device/info/list(响应三种形态均兼容,见 [_extractList]);
/// - 设备详情:GET /device/info/info/{id};
/// - 设备指令:POST /device/command/sendByMac | sendByOrg(service 已实现,
/// 下发 UI 属下一轮迭代);
/// - 告警:真实后端无 alerts 端点,real 模式下由设备列表推导
/// (offline→critical、warning→warning);mock 模式使用示例告警。
/// - 设备分组:GET/POST /device/group/*;
/// - 激活码:POST/GET /device/activationCode/*;
/// - 门店配置:GET/POST /device/storeConfig/*。
class DeviceService {
DeviceService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
/// 获取设备总数量(后端分页响应中的 totalCount)。
///
/// GET /device/info/list?page=1&limit=1
Future<int> fetchDeviceTotalCount() async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {'page': 1, 'limit': 1},
);
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['totalCount'];
if (total is int) return total;
if (total is String) return int.tryParse(total) ?? 0;
// 部分后端把 total 放到 page.totalCount。
final page = data['page'];
if (page is Map<String, dynamic>) {
final pageTotal = page['totalCount'];
if (pageTotal is int) return pageTotal;
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
}
}
return 0;
}
/// 按状态统计设备数量(O(1):limit=1 仅取 totalCount,不下拉全量)。
///
/// [status] 取值:'online' / 'offline' / 'warning';传 null 表示全部。
/// GET /device/info/list?page=1&limit=1&status=xxx
Future<int> fetchDeviceCountByStatus({String? status}) async {
final query = <String, dynamic>{'page': 1, 'limit': 1};
if (status != null) query['status'] = status;
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: query,
);
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['totalCount'];
if (total is int) return total;
if (total is String) return int.tryParse(total) ?? 0;
final page = data['page'];
if (page is Map<String, dynamic>) {
final pageTotal = page['totalCount'];
if (pageTotal is int) return pageTotal;
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
}
}
return 0;
}
/// 获取设备列表。
Future<List<Device>> fetchDevices({
int page = 1,
int limit = AppConstants.deviceListPageSize,
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
String? storeOrgId,
}) async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {
'page': page,
'limit': limit,
if (status != null) 'status': status,
if (mac != null) 'mac': mac,
if (deviceSn != null) 'deviceSn': deviceSn,
if (deviceUid != null) 'deviceUid': deviceUid,
if (groupId != null) 'groupId': groupId,
if (storeOrgId != null) 'storeOrgId': storeOrgId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(Device.fromJson)
.toList();
}
/// 按多个门店(组织节点)批量拉取并合并设备列表。
///
/// 后端 `/device/info/list` 仅接受单个 `storeOrgId`,故对每个门店 orgId 并发
/// 请求 [fetchDevices],再把结果合并、按 [Device.id] 去重(同一设备不会因归属
/// 多个选中组织而重复出现)。
///
/// [storeOrgIds] 为空时直接走 [fetchDevices](等效于「全部门店」),保持原行为。
Future<List<Device>> fetchDevicesByOrgSet(
Set<String> storeOrgIds, {
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
}) async {
if (storeOrgIds.isEmpty) {
return fetchDevices(
status: status,
mac: mac,
deviceSn: deviceSn,
deviceUid: deviceUid,
groupId: groupId,
);
}
final lists = await Future.wait(
storeOrgIds.map(
(id) => _fetchDevicesForStore(
id,
status: status,
mac: mac,
deviceSn: deviceSn,
deviceUid: deviceUid,
groupId: groupId,
),
),
);
final map = <String, Device>{};
for (final d in lists.expand((x) => x)) {
map[d.id] = d;
}
return map.values.toList();
}
/// 按单个门店 orgId 拉取设备列表(构造查询参数 + 解析)。
///
/// 抽取自 [fetchDevices] 的单店请求逻辑,供 [fetchDevicesByOrgSet] 复用,
/// 避免多门店合并时重复拼装 queryParameters。
Future<List<Device>> _fetchDevicesForStore(
String storeOrgId, {
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
}) async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {
'page': 1,
'limit': AppConstants.deviceListPageSize,
if (status != null) 'status': status,
if (mac != null) 'mac': mac,
if (deviceSn != null) 'deviceSn': deviceSn,
if (deviceUid != null) 'deviceUid': deviceUid,
if (groupId != null) 'groupId': groupId,
'storeOrgId': storeOrgId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(Device.fromJson)
.toList();
}
/// 按 ID 获取设备详情;不存在时抛出 [DeviceNotFoundException]。
///
/// 注意:详情接口 /device/info/info/{id} 返回的 `status` / `lastSyncTime`
/// 可能滞后于列表接口。Web 端设备列表走 /device/info/list 并显示最新在线状态,
/// 因此本方法在拿到详情后,再用 [Device.mac] 到列表接口取一次最新状态并合并,
/// 使手机端详情页与 Web 端列表/心跳保持一致。
Future<Device> fetchDeviceById(String id) async {
try {
final response =
await _dio.get<dynamic>('/device/info/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('未找到设备:$id');
}
final detail = Device.fromJson(data);
return _mergeWithFreshListStatus(detail);
} on DioException catch (e) {
final error = e.error;
if (error is ApiException) {
throw DeviceNotFoundException('${error.message}($id)');
}
throw DeviceNotFoundException('设备详情加载失败:$id');
}
}
/// 用 [detail.mac] 从列表接口拉取最新状态并合并。
///
/// 列表接口 /device/info/list 与 Web 端同源,状态/心跳刷新更及时;
/// 合并时以列表的 [status] 与 [lastSyncTime] 覆盖详情,其余字段保留详情。
Future<Device> _mergeWithFreshListStatus(Device detail) async {
if (detail.mac.isEmpty) return detail;
try {
final freshList = await fetchDevices(mac: detail.mac, limit: 1);
if (freshList.isEmpty) return detail;
final fresh = freshList.first;
return detail.copyWith(
status: fresh.status,
lastSyncTime: fresh.lastSyncTime ?? detail.lastSyncTime,
);
} on Object {
// 列表接口失败时不阻断详情展示,保持原样。
return detail;
}
}
/// 更新设备信息(修改分组、房号等)。
///
/// POST /device/info/update,body 至少含 `id` 与需修改字段。
Future<void> updateDevice(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/info/update', data: data);
}
/// 新增设备。
///
/// POST /device/info/save,必填字段参考 Web 端:groupId、mac。
Future<void> saveDeviceInfo(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/info/save', data: data);
}
/// 删除单个设备。
///
/// 后端 `POST /device/info/delete` 接收的是**裸 JSON 数组** `[id]`,与 Web 端
/// 设备列表批量删除保持一致(Web 调用 `Ao(t)` → `$.adornData(t,!1)` →
/// `JSON.stringify([id,...])`)。
/// 旧实现误传 `{'ids': [id]}`(对象),后端无法解析请求体 → 返回「未知异常」。
/// id 在可转为数字时按数字发送(复刻 Web 端 `Yo()` 的 `Number(id)` 行为),
/// 否则保留原字符串,保证数字/字符串两种主键都能正确命中。
Future<void> deleteDevice(String id) async {
final idValue = int.tryParse(id) ?? id;
await _dio.post<dynamic>('/device/info/delete', data: [idValue]);
}
/// 按 MAC 查询设备(最多返回 1 条,用于判断设备是否已存在)。
Future<Device?> fetchDeviceByMac(String mac) async {
if (mac.isEmpty) return null;
final devices = await fetchDevices(mac: mac, limit: 1);
return devices.isNotEmpty ? devices.first : null;
}
/// 扫码激活/绑定设备:把设备真正写入后端。
///
/// 逻辑:
/// 1. 先按 MAC 查询设备是否已存在;
/// 2. 存在则走 `/device/info/update` 更新归属(门店/分组/房号);
/// 3. 不存在则走 `/device/info/save` 新增设备。
///
/// 这样手机端扫码激活后,Web 端设备列表能立即看到该设备。
Future<void> activateDeviceByScan({
required String mac,
required String storeId,
String? groupId,
String? roomNo,
}) async {
final existing = await fetchDeviceByMac(mac);
if (existing != null && existing.id.isNotEmpty) {
final data = <String, dynamic>{
'id': existing.id,
'mac': mac,
};
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
await updateDevice(data);
return;
}
final data = <String, dynamic>{
'mac': mac,
// 门店在后端设备表中通常映射为 orgId / storeOrgId;同时携带两个别名,
// 兼容不同后端版本。
'orgId': storeId,
'storeOrgId': storeId,
};
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
await saveDeviceInfo(data);
}
/// 按 MAC 下发设备指令。
///
/// [command] 取值见 [DeviceCommand];[data] 为指令参数(可为空)。
Future<void> sendCommandByMac({
required String mac,
required String command,
Map<String, dynamic>? data,
}) async {
await _dio.post<dynamic>(
'/device/command/sendByMac',
data: {'mac': mac, 'command': command, 'data': data ?? <String, dynamic>{}},
);
}
/// 按组织下发设备指令(批量)。
Future<void> sendCommandByOrg({
required String orgId,
required String command,
Map<String, dynamic>? data,
}) async {
await _dio.post<dynamic>(
'/device/command/sendByOrg',
data: {
'orgId': orgId,
'command': command,
'data': data ?? <String, dynamic>{},
},
);
}
/// 获取告警列表。
///
/// 真实后端无 alerts 端点:real 模式下由设备列表推导——
/// 仅为异常设备生成告警(offline→critical、warning→warning);
/// 在线设备的级别映射为 info(见 [alertLevelFor]),但不产生告警条目。
/// mock 模式下返回内置示例告警。
Future<List<Alert>> fetchAlerts() async {
if (AppConstants.useMock) {
final response = await _dio.get<dynamic>('/alerts');
final data = response.data;
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(Alert.fromJson)
.toList();
}
return <Alert>[];
}
final devices = await fetchDevices(limit: AppConstants.deviceListPageSize);
// 受限账号:仅保留其组织子树内门店所属设备的告警,避免泄露跨组织设备告警。
final scope = SessionOrgScope.instance;
List<Device> scopedDevices = devices;
if (scope.enabled) {
final ids = await OrgService().scopedOrgIdSet();
if (ids.isNotEmpty) {
scopedDevices = devices.where((d) => ids.contains(d.storeId)).toList();
}
}
final alerts = <Alert>[];
for (final device in scopedDevices) {
if (device.status == DeviceStatus.online) {
continue;
}
alerts.add(_deriveAlert(device));
}
// 按级别严重程度排序:critical 在前。
alerts.sort((a, b) => a.level.index.compareTo(b.level.index));
return alerts;
}
/// 设备状态 → 告警级别映射(offline→critical、warning→warning、online→info)。
static AlertLevel alertLevelFor(DeviceStatus status) {
switch (status) {
case DeviceStatus.offline:
return AlertLevel.critical;
case DeviceStatus.warning:
return AlertLevel.warning;
case DeviceStatus.online:
return AlertLevel.info;
}
}
/// 由异常设备推导告警条目。
static Alert _deriveAlert(Device device) {
final offline = device.status == DeviceStatus.offline;
return Alert(
id: 'derived-${device.id}',
deviceId: device.id,
deviceName: device.name,
title: offline ? '设备离线' : '设备指标异常',
message: offline
? '设备 ${device.mac} 最近无心跳上报,请检查电源与网络连接。'
: '设备 ${device.mac} 上报状态为「告警」,请登录后台查看详情。',
level: alertLevelFor(device.status),
occurredAt: device.lastSyncTime ?? DateTime.now(),
acknowledged: false,
);
}
// ================ 设备分组管理 ================
/// 获取设备分组列表(分页,按门店筛选)。
///
/// GET /device/group/list?page=&limit=&orgId=&storeId=
///
/// 注意:Web 端该接口使用 `orgId` 作为门店过滤参数,移动端同时携带
/// `orgId` 与 `storeId`,避免后端因参数名差异而忽略门店过滤,导致
/// 返回全部门店分组。
Future<List<OrgNode>> getDeviceGroupList({
int page = 1,
int limit = 20,
required String storeId,
}) async {
final response = await _dio.get<dynamic>(
'/device/group/list',
queryParameters: {
'page': page,
'limit': limit,
'orgId': storeId,
'storeId': storeId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取设备分组详情。
///
/// GET /device/group/info/{id}
Future<OrgNode> getDeviceGroupInfo(String id) async {
final response = await _dio.get<dynamic>('/device/group/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('设备分组信息加载失败:$id');
}
return OrgNode.fromJson(data);
}
/// 新增设备分组。
///
/// POST /device/group/save,body 为分组数据。
Future<void> saveDeviceGroup(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/group/save', data: data);
}
/// 修改设备分组。
///
/// POST /device/group/update,body 为分组数据(含 id)。
Future<void> updateDeviceGroup(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/group/update', data: data);
}
/// 批量删除设备分组。
///
/// POST /device/group/delete,body 为 `{ids: [...]}`。
Future<void> deleteDeviceGroups(List<String> ids) async {
await _dio.post<dynamic>(
'/device/group/delete',
data: {'ids': ids},
);
}
// ================ 激活码管理 ================
/// 生成门店激活码。
///
/// POST /device/activationCode/generate,body 为 `{orgId, maxActivateCount}`。
Future<void> generateActivationCode(
String orgId,
int maxActivateCount,
) async {
await _dio.post<dynamic>(
'/device/activationCode/generate',
data: {
'orgId': orgId,
'maxActivateCount': maxActivateCount,
},
);
}
/// 获取激活码信息。
///
/// GET /device/activationCode/info/{orgId}
Future<Map<String, dynamic>> getActivationCodeInfo(String orgId) async {
final response =
await _dio.get<dynamic>('/device/activationCode/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('激活码信息加载失败:$orgId');
}
return data;
}
/// 重置激活码。
///
/// POST /device/activationCode/reset/{orgId}
Future<void> resetActivationCode(String orgId) async {
await _dio.post<dynamic>('/device/activationCode/reset/$orgId');
}
// ================ 门店配置 ================
/// 获取门店配置。
///
/// GET /device/storeConfig/info/{orgId}(与 Web 后台门店配置弹窗同源,
/// 见 screen-front src/api/device.ts `getStoreConfig`)。
Future<Map<String, dynamic>> getStoreConfig(String orgId) async {
final response =
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('门店配置加载失败:$orgId');
}
return data;
}
/// 新增或修改门店配置。
///
/// POST /device/storeConfig/createOrUpdate(与 Web 后台 `createOrUpdateStoreConfig`
/// 严格一致;早期误写 /saveOrUpdate 会导致后端路由不匹配、保存失效)。
Future<void> createOrUpdateStoreConfig(Map<String, dynamic> data) async {
await _dio.post<dynamic>(
'/device/storeConfig/createOrUpdate',
data: data,
);
}
/// 重置门店配置密码。
///
/// POST /device/storeConfig/resetPassword/{orgId}
Future<void> resetStoreConfigPassword(String orgId) async {
await _dio.post<dynamic>(
'/device/storeConfig/resetPassword/$orgId',
);
}
// ================ 扫码绑定(前端占位) ================
/// 扫码绑定设备到门店/分组。
///
/// 当前后端无扫码绑定接口,前端占位:本地记录绑定关系并写回设备归属,
/// 不发起真实请求。
///
/// [scanPayload] 为扫码解析出的设备标识(mac/sn/二维码内容);
/// [storeId]/[storeName] 为目标门店;[groupId] 为分组(可空)。
///
/// TODO(后端): POST /device/bind {scanPayload, storeId, groupId},
/// 由后台按 scanPayload 定位设备并写归属。
Future<void> bindDeviceByScan(
String scanPayload,
String storeId,
String storeName,
String? groupId,
) async {
// 占位:把绑定关系写入本地缓存(key = deviceId,此处以 scanPayload 作为标识)。
await StoreLocalCache.instance.saveDeviceBinding(
scanPayload,
storeId,
storeName,
groupId,
);
// 占位:本地 updateDevice 写 groupId/storeName,便于详情页回显。
try {
await updateDevice({
'id': storeId,
'groupId': groupId ?? '',
'storeName': storeName,
});
} on Object {
// 真实后台可能没有该设备/接口,占位阶段忽略失败。
}
// TODO(后端): POST /device/bind {scanPayload, storeId, groupId}。
debugPrint('[DeviceService.bindDeviceByScan] 占位未上报: '
'payload=$scanPayload storeId=$storeId');
}
// ================ 工具方法 ================
/// 兼容设备列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}
/// 设备不存在异常。
class DeviceNotFoundException implements Exception {
const DeviceNotFoundException(this.message);
final String message;
@override
String toString() => message;
}

View File

@@ -0,0 +1,259 @@
import 'package:dio/dio.dart';
import '../models/intro_category.dart';
import 'api_client.dart';
import '../utils/session_org_scope.dart';
/// 介绍页管理服务:分类、子分类、详情。
///
/// 对接真实「爱视后台」/api/company/* 接口。
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
class IntroService {
IntroService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ 分类管理 ================
/// 获取介绍页分类列表(分页)。
///
/// GET /company/introCategory/list?page=&limit=&orgId=
Future<List<IntroCategory>> getIntroCategoryList({
int page = 1,
int limit = 20,
dynamic orgId,
}) async {
// 受限账号(SessionOrgScope 启用)未显式传入 orgId 时,自动带上当前组织,
// 避免列表接口忽略组织过滤而返回全量跨组织分类。
final effectiveOrgId = orgId ??
(SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
final params = <String, dynamic>{'page': page, 'limit': limit};
if (effectiveOrgId != null && effectiveOrgId.toString().isNotEmpty) {
params['orgId'] = effectiveOrgId;
}
final response = await _dio.get<dynamic>(
'/company/introCategory/list',
queryParameters: params,
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroCategory.fromJson)
.toList();
}
/// 获取全部分类(不分页,用于下拉选择)。
///
/// GET /company/introCategory/all
Future<List<IntroCategory>> getIntroCategoryAll() async {
final response = await _dio.get<dynamic>('/company/introCategory/all');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroCategory.fromJson)
.toList();
}
/// 获取分类详情。
///
/// GET /company/introCategory/info/{id}
Future<IntroCategory> getIntroCategoryInfo(String id) async {
final response =
await _dio.get<dynamic>('/company/introCategory/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '分类信息加载失败:$id');
}
return IntroCategory.fromJson(data);
}
/// 新增或修改分类。
///
/// 有 id 则 POST /company/introCategory/update,否则 POST /company/introCategory/save。
Future<void> saveIntroCategory(Map<String, dynamic> data) async {
final hasId = data['id'] != null &&
data['id'].toString().isNotEmpty &&
data['id'].toString() != '0';
final url = hasId
? '/company/introCategory/update'
: '/company/introCategory/save';
await _dio.post<dynamic>(url, data: data);
}
/// 批量删除分类。
///
/// POST /company/introCategory/delete,body 为 `{ids: [...]}`。
Future<void> deleteIntroCategory(List<int> ids) async {
await _dio.post<dynamic>(
'/company/introCategory/delete',
data: {'ids': ids},
);
}
// ================ 子分类管理 ================
/// 获取子分类列表(分页)。
///
/// GET /company/introSubcategory/list?page=&limit=
Future<List<IntroSubcategory>> getIntroSubcategoryList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/company/introSubcategory/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroSubcategory.fromJson)
.toList();
}
/// 按分类 ID 获取子分类列表。
///
/// GET /company/introSubcategory/listByCategory/{categoryId}
Future<List<IntroSubcategory>> getIntroSubcategoryListByCategory(
String categoryId,
) async {
final response = await _dio.get<dynamic>(
'/company/introSubcategory/listByCategory/$categoryId',
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroSubcategory.fromJson)
.toList();
}
/// 获取子分类详情。
///
/// GET /company/introSubcategory/info/{id}
Future<IntroSubcategory> getIntroSubcategoryInfo(String id) async {
final response =
await _dio.get<dynamic>('/company/introSubcategory/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '子分类信息加载失败:$id');
}
return IntroSubcategory.fromJson(data);
}
/// 新增或修改子分类。
///
/// 有 id 则 POST /company/introSubcategory/update,否则 POST /company/introSubcategory/save。
Future<void> saveIntroSubcategory(Map<String, dynamic> data) async {
final hasId = data['id'] != null &&
data['id'].toString().isNotEmpty &&
data['id'].toString() != '0';
final url = hasId
? '/company/introSubcategory/update'
: '/company/introSubcategory/save';
await _dio.post<dynamic>(url, data: data);
}
/// 批量删除子分类。
///
/// POST /company/introSubcategory/delete,body 为 `{ids: [...]}`。
Future<void> deleteIntroSubcategory(List<int> ids) async {
await _dio.post<dynamic>(
'/company/introSubcategory/delete',
data: {'ids': ids},
);
}
// ================ 详情管理 ================
/// 获取介绍页详情列表(分页)。
///
/// GET /company/introDetail/list?page=&limit=
Future<List<IntroDetail>> getIntroDetailList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/company/introDetail/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroDetail.fromJson)
.toList();
}
/// 按子分类 ID 获取详情列表。
///
/// GET /company/introDetail/listBySubcategory/{subcategoryId}
Future<List<IntroDetail>> getIntroDetailListBySubcategory(
String subcategoryId,
) async {
final response = await _dio.get<dynamic>(
'/company/introDetail/listBySubcategory/$subcategoryId',
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(IntroDetail.fromJson)
.toList();
}
/// 获取详情信息。
///
/// GET /company/introDetail/info/{id}
Future<IntroDetail> getIntroDetailInfo(String id) async {
final response =
await _dio.get<dynamic>('/company/introDetail/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '详情信息加载失败:$id');
}
return IntroDetail.fromJson(data);
}
/// 新增或修改详情。
///
/// 有 id 则 POST /company/introDetail/update,否则 POST /company/introDetail/save。
Future<void> saveIntroDetail(Map<String, dynamic> data) async {
final hasId = data['id'] != null &&
data['id'].toString().isNotEmpty &&
data['id'].toString() != '0';
final url = hasId
? '/company/introDetail/update'
: '/company/introDetail/save';
await _dio.post<dynamic>(url, data: data);
}
/// 批量删除详情。
///
/// POST /company/introDetail/delete,body 为 `{ids: [...]}`。
Future<void> deleteIntroDetail(List<int> ids) async {
await _dio.post<dynamic>(
'/company/introDetail/delete',
data: {'ids': ids},
);
}
// ================ 工具方法 ================
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,278 @@
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/marquee_message.dart';
import '../models/store_info.dart';
import 'api_client.dart';
import 'marquee_web_source.dart';
import 'org_service.dart';
/// 滚动字幕管理服务。
///
/// 列表/新增/编辑/删除均优先对接 Web 后台 `/theme/scrollingText/*` 接口,
/// 同时保留本地 `SharedPreferences` 缓存作为离线兜底。
class MarqueeService {
MarqueeService({
Dio? dio,
OrgService? orgService,
MarqueeWebSource? webSource,
}) : _dio = dio ?? ApiClient.instance.dio,
_orgService = orgService ?? OrgService(),
_webSource = webSource ?? MarqueeWebSource(dio: ApiClient.instance.dio);
final Dio _dio;
final OrgService _orgService;
final MarqueeWebSource _webSource;
static const _storageKey = 'cloudhome_ops_marquee_messages';
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
/// 获取滚动字幕列表。
///
/// 优先从 Web 端 `/theme/scrollingText/list` 获取;若失败则回退本地缓存。
Future<List<MarqueeMessage>> getMarqueeList({
String? storeId,
String? keyword,
String method = 'GET',
String path = MarqueeWebSource.defaultPath,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
}) async {
final webResult = await _webSource.fetchMarquees(
method: method,
path: path,
queryParameters: queryParameters,
body: body,
storeId: storeId,
keyword: keyword,
);
if (webResult.isSuccess) {
await _saveAll(webResult.data);
return _applyFiltersAndSort(webResult.data, storeId: storeId, keyword: keyword);
}
final localList = await _loadAll();
return _applyFiltersAndSort(localList, storeId: storeId, keyword: keyword);
}
List<MarqueeMessage> _applyFiltersAndSort(
List<MarqueeMessage> list, {
String? storeId,
String? keyword,
}) {
var result = list;
if (storeId != null && storeId.isNotEmpty) {
result = result.where((m) => m.storeId == storeId).toList();
}
if (keyword != null && keyword.isNotEmpty) {
final lower = keyword.toLowerCase();
result = result.where((m) {
return m.content.toLowerCase().contains(lower) ||
m.storeName.toLowerCase().contains(lower);
}).toList();
}
result.sort((a, b) {
// 启用的排在前面,其次按更新时间倒序。
final statusCompare = b.status.compareTo(a.status);
if (statusCompare != 0) return statusCompare;
final bTime = b.updateTime;
final aTime = a.updateTime;
if (bTime == null || aTime == null) return 0;
return bTime.compareTo(aTime);
});
return result;
}
/// 新增或修改滚动字幕。
///
/// 编辑(data 含 id)调用 `/theme/scrollingText/update`,
/// 新增调用 `/theme/scrollingText/save`。
/// 成功后刷新本地缓存。
Future<void> saveMarquee(Map<String, dynamic> data) async {
final id = data['id']?.toString();
final payload = _buildWebPayload(data);
try {
if (id != null && id.isNotEmpty) {
await _dio.post<dynamic>('/theme/scrollingText/update', data: payload);
} else {
await _dio.post<dynamic>('/theme/scrollingText/save', data: payload);
}
} on DioException {
// 写操作失败时仅更新本地缓存,保证移动端仍可展示。
}
final list = await _loadAll();
final now = DateTime.now();
final message = MarqueeMessage(
id: id ?? _generateId(),
storeId: data['storeId']?.toString() ?? '',
storeName: data['storeName']?.toString() ?? '',
content: data['content']?.toString() ?? '',
status: data['status'] is int ? data['status'] as int : 0,
speed: int.tryParse(data['speed']?.toString() ?? '50') ?? 50,
fontColor: data['fontColor']?.toString(),
backgroundColor: data['backgroundColor']?.toString(),
fontSize: int.tryParse(data['fontSize']?.toString() ?? ''),
level: int.tryParse(data['level']?.toString() ?? ''),
startTime: _tryParseDateTime(data['startTime']),
endTime: _tryParseDateTime(data['endTime']),
createTime: now,
updateTime: now,
);
final index = list.indexWhere((m) => m.id == message.id);
if (index >= 0) {
list[index] = message;
} else {
list.add(message);
}
await _saveAll(list);
}
/// 删除滚动字幕。
///
/// 优先调用 Web 端 `/theme/scrollingText/delete`,失败也移除本地缓存。
Future<void> deleteMarquee(String id) async {
final numericId = int.tryParse(id);
if (numericId != null) {
try {
await _dio.post<dynamic>(
'/theme/scrollingText/delete',
data: {'ids': [numericId]},
);
} on DioException {
// 网络失败时继续移除本地记录。
}
}
final list = await _loadAll();
list.removeWhere((m) => m.id == id);
await _saveAll(list);
}
/// 启用/禁用单条滚动字幕。
///
/// 直接调用 Web 端 `/theme/scrollingText/update` 修改 status。
Future<void> setMarqueeStatus(String id, {required int status}) async {
final list = await _loadAll();
final index = list.indexWhere((m) => m.id == id);
if (index < 0) return;
final item = list[index];
final numericId = int.tryParse(id);
if (numericId != null) {
try {
await _dio.post<dynamic>(
'/theme/scrollingText/update',
data: item.copyWith(status: status).toWebJson(),
);
} on DioException {
// 接口失败时仅更新本地状态。
}
}
list[index] = item.copyWith(
status: status,
updateTime: DateTime.now(),
);
await _saveAll(list);
}
/// 获取当前用户可见的门店列表(用于新增/编辑时选择目标门店)。
Future<List<StoreInfo>> getStores() async {
return _orgService.getMyStores();
}
/// 构造 Web 后台可接收的请求体。
Map<String, dynamic> _buildWebPayload(Map<String, dynamic> data) {
final storeId = data['storeId']?.toString() ?? '';
final storeName = data['storeName']?.toString() ?? '';
final content = data['content']?.toString() ?? '';
final speed = int.tryParse(data['speed']?.toString() ?? '50') ?? 50;
final fontSize = int.tryParse(data['fontSize']?.toString() ?? '');
final level = int.tryParse(data['level']?.toString() ?? '');
final status = data['status'] is int ? data['status'] as int : 0;
final fontColor = data['fontColor']?.toString();
final backgroundColor = data['backgroundColor']?.toString();
final startTime = _tryParseDateTime(data['startTime']);
final endTime = _tryParseDateTime(data['endTime']);
final payload = <String, dynamic>{
'orgId': int.tryParse(storeId) ?? storeId,
'orgName': storeName,
'content': content,
'scrollSpeed': speed,
'status': status,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
if (fontColor != null && fontColor.isNotEmpty) 'fontColor': fontColor,
if (backgroundColor != null && backgroundColor.isNotEmpty)
'bgColor': backgroundColor,
if (startTime != null)
'startTime': _formatTime(startTime),
if (endTime != null)
'endTime': _formatTime(endTime),
};
final id = data['id']?.toString();
if (id != null && id.isNotEmpty) {
payload['id'] = int.tryParse(id) ?? id;
}
return payload;
}
Future<List<MarqueeMessage>> _loadAll() async {
final prefs = await _prefs;
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) return <MarqueeMessage>[];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.whereType<Map<String, dynamic>>()
.map(MarqueeMessage.fromJson)
.toList();
} catch (_) {
return <MarqueeMessage>[];
}
}
Future<void> _saveAll(List<MarqueeMessage> list) async {
final prefs = await _prefs;
final raw = jsonEncode(list.map((m) => m.toJson()).toList());
await prefs.setString(_storageKey, raw);
}
String _generateId() {
final now = DateTime.now().millisecondsSinceEpoch;
final rand = Random().nextInt(10000).toString().padLeft(4, '0');
return '${now}_$rand';
}
DateTime? _tryParseDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
final text = value.toString().trim();
if (text.isEmpty) return null;
// 兼容 Web 端纯时间字符串 `HH:mm:ss`。
if (RegExp(r'^\d{2}:\d{2}:\d{2}$').hasMatch(text)) {
return DateTime.tryParse('2000-01-01 $text');
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
String _formatTime(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,343 @@
import 'package:dio/dio.dart';
import '../models/marquee_message.dart';
import '../utils/constants.dart';
import '../config/app_config.dart';
import 'api_client.dart';
/// Web 端滚动字幕数据源。
///
/// 专门负责从 Web 后台 `/theme/scrollingText/*` 接口请求、接收并解析滚动字幕数据。
/// 支持通过参数配置请求地址(URL / path)与请求方式(GET / POST),
/// 内置网络异常、超时、HTTP 错误、解析失败等错误处理,
/// 将原始响应封装为结构化的 [MarqueeFetchResult]。
class MarqueeWebSource {
/// [baseUrl] 可为空;为空时使用 [AppConfig] 当前环境的默认地址。
/// [dio] 可注入,便于测试或复用已挂载拦截器的实例。
MarqueeWebSource({
Dio? dio,
String? baseUrl,
this.timeout = AppConstants.apiTimeout,
}) : _dio = dio ?? _createDio(baseUrl: baseUrl, timeout: timeout);
final Dio _dio;
/// 请求超时时间。
final Duration timeout;
/// 默认列表接口路径。与 Web 后台滚动字幕管理页保持一致。
static const String defaultPath = '/theme/scrollingText/list';
/// 默认请求方式。
static const String defaultMethod = 'GET';
static Dio _createDio({String? baseUrl, required Duration timeout}) {
return Dio(
BaseOptions(
baseUrl: baseUrl ?? AppConfig.apiFullBaseUrl,
connectTimeout: timeout,
receiveTimeout: timeout,
sendTimeout: timeout,
headers: {'Content-Type': 'application/json; charset=utf-8'},
responseType: ResponseType.json,
),
);
}
/// 从 Web 端获取滚动字幕列表。
///
/// [method] 支持 'GET' / 'POST' / 'PUT' 等;默认 'GET'。
/// [path] 支持绝对 URL(以 http:// 或 https:// 开头)或相对路径。
/// [queryParameters] 用于 GET / POST 的查询参数(如 page、limit、orgId)。
/// [body] 用于 POST / PUT 的请求体。
/// [storeId] 与 [keyword] 为业务常用过滤条件,会自动合并到 queryParameters。
/// [page] / [limit] 默认 1 / 100。
///
/// 返回 [MarqueeFetchResult],内部包含解析后的 [MarqueeMessage] 列表、总数、错误码等。
Future<MarqueeFetchResult> fetchMarquees({
String method = defaultMethod,
String path = defaultPath,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
String? storeId,
String? keyword,
int page = 1,
int limit = 100,
}) async {
final params = <String, dynamic>{
'page': page,
'limit': limit,
if (storeId != null && storeId.isNotEmpty) 'orgId': storeId,
if (keyword != null && keyword.isNotEmpty) 'content': keyword,
...?queryParameters,
};
final options = Options(method: method.toUpperCase());
try {
final upperMethod = method.toUpperCase();
final Response<dynamic> response;
if (upperMethod == 'GET') {
response = await _dio.get<dynamic>(
path,
queryParameters: params,
options: options,
);
} else {
response = await _dio.request<dynamic>(
path,
queryParameters: params,
data: body,
options: options,
);
}
return _parseResponse(response.data);
} on DioException catch (e) {
return MarqueeFetchResult.failure(
error: _formatDioError(e),
errorCode: _errorCodeFromDio(e),
);
} on FormatException catch (e) {
return MarqueeFetchResult.failure(
error: '数据解析失败:${e.message}',
errorCode: MarqueeErrorCode.parseFailed,
);
} on Exception catch (e) {
return MarqueeFetchResult.failure(
error: '请求异常:$e',
errorCode: MarqueeErrorCode.unknown,
);
}
}
/// 通用单条数据请求封装(用于新增/编辑/删除后回显或详情查询)。
Future<MarqueeFetchResult> fetchMarqueeDetail({
String method = defaultMethod,
required String path,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
}) async {
return fetchMarquees(
method: method,
path: path,
queryParameters: queryParameters,
body: body,
page: 1,
limit: 1,
);
}
/// 解析 Web 端响应为结构化结果。
///
/// 兼容三种常见响应形态:
/// 1. 后端信封 `{code:0, msg:'ok', data:{list:[...], totalCount:5}}`
/// 2. 后端信封 `{code:0, msg:'ok', data:[...]}`
/// 3. 原生数组 `[...]`
/// 4. 解包后的分页对象 `{list:[...], totalCount:5}`
MarqueeFetchResult _parseResponse(dynamic raw) {
if (raw == null) {
return MarqueeFetchResult.success(
data: const <MarqueeMessage>[],
total: 0,
);
}
Map<String, dynamic>? envelope;
dynamic payload;
if (raw is Map<String, dynamic>) {
envelope = raw;
payload = raw['data'] ?? raw;
} else {
payload = raw;
}
// 处理业务错误码(信封中存在 code 且非成功)。
if (envelope != null && envelope.containsKey('code')) {
final code = envelope['code'];
final success = code == 0 || code == 200 || code == '0' || code == '200';
if (!success) {
final msg = envelope['msg']?.toString() ??
envelope['message']?.toString() ??
'请求失败(业务码:$code)';
return MarqueeFetchResult.failure(
error: msg,
errorCode: MarqueeErrorCode.businessError,
businessCode: code?.toString(),
);
}
}
// 解析列表与总数。
List<dynamic> listRaw;
int? total;
if (payload is Map<String, dynamic>) {
if (payload['list'] is List) {
listRaw = payload['list'] as List<dynamic>;
final t = payload['totalCount'] ?? payload['total'];
if (t is int) total = t;
if (t is String) total = int.tryParse(t);
} else if (payload['rows'] is List) {
listRaw = payload['rows'] as List<dynamic>;
final t = payload['totalCount'] ?? payload['total'];
if (t is int) total = t;
if (t is String) total = int.tryParse(t);
} else {
// 兜底:整个 data 就是单条对象。
listRaw = [payload];
}
} else if (payload is List) {
listRaw = payload;
} else {
return MarqueeFetchResult.failure(
error: '返回数据格式不正确',
errorCode: MarqueeErrorCode.parseFailed,
);
}
final List<MarqueeMessage> data = [];
for (final item in listRaw) {
if (item is! Map<String, dynamic>) {
continue;
}
try {
data.add(MarqueeMessage.fromJson(item));
} on FormatException catch (e) {
return MarqueeFetchResult.failure(
error: '单条字幕解析失败:${e.message}',
errorCode: MarqueeErrorCode.parseFailed,
);
} on Exception catch (e) {
return MarqueeFetchResult.failure(
error: '单条字幕解析异常:$e',
errorCode: MarqueeErrorCode.parseFailed,
);
}
}
return MarqueeFetchResult.success(
data: data,
total: total ?? data.length,
);
}
String _formatDioError(DioException e) {
final inner = e.error;
if (inner is ApiException && inner.message.isNotEmpty) {
return inner.message;
}
final response = e.response;
if (response != null && response.statusCode != null) {
return '服务器响应异常:HTTP ${response.statusCode}';
}
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return '连接超时,请检查网络';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.cancel:
return '请求已取消';
case DioExceptionType.badCertificate:
return '服务器证书校验失败';
case DioExceptionType.badResponse:
return '服务器返回异常响应';
case DioExceptionType.unknown:
default:
final text = e.message;
if (text != null && text.isNotEmpty) return text;
return '网络请求失败';
}
}
MarqueeErrorCode _errorCodeFromDio(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return MarqueeErrorCode.timeout;
case DioExceptionType.connectionError:
return MarqueeErrorCode.networkError;
case DioExceptionType.badCertificate:
return MarqueeErrorCode.certificateError;
case DioExceptionType.badResponse:
return MarqueeErrorCode.httpError;
case DioExceptionType.cancel:
return MarqueeErrorCode.cancelled;
default:
return MarqueeErrorCode.unknown;
}
}
}
/// Web 端滚动字幕请求结果。
class MarqueeFetchResult {
const MarqueeFetchResult({
required this.data,
this.total = 0,
this.hasMore = false,
this.error,
this.errorCode,
this.businessCode,
});
factory MarqueeFetchResult.success({
required List<MarqueeMessage> data,
int total = 0,
bool hasMore = false,
}) {
return MarqueeFetchResult(
data: data,
total: total,
hasMore: hasMore,
);
}
factory MarqueeFetchResult.failure({
required String error,
MarqueeErrorCode errorCode = MarqueeErrorCode.unknown,
String? businessCode,
}) {
return MarqueeFetchResult(
data: const <MarqueeMessage>[],
error: error,
errorCode: errorCode,
businessCode: businessCode,
);
}
/// 解析后的字幕列表。
final List<MarqueeMessage> data;
/// 服务端总数(用于分页)。
final int total;
/// 是否还有下一页。
final bool hasMore;
/// 错误描述(成功时为 null)。
final String? error;
/// 错误码(分类)。
final MarqueeErrorCode? errorCode;
/// 后端业务错误码(非 HTTP 状态码)。
final String? businessCode;
bool get isSuccess => error == null && errorCode == null;
}
/// Web 端滚动字幕错误分类。
enum MarqueeErrorCode {
networkError,
timeout,
httpError,
businessError,
parseFailed,
certificateError,
cancelled,
unknown,
}

View File

@@ -0,0 +1,146 @@
import 'package:dio/dio.dart';
import '../models/media_asset.dart';
import 'api_client.dart';
/// 媒体资源管理服务:媒体资产与排行榜。
///
/// 对接真实「爱视后台」:
/// - 媒体资产:/api/sys/mediaAsset/*
/// - 媒体排行榜:/api/sys/mediaAssetRank/*
class MediaService {
MediaService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ 媒体资产管理 ================
/// 获取媒体资产列表(分页)。
///
/// GET /sys/mediaAsset/list?page=&limit=
Future<List<MediaAsset>> getMediaAssetList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/mediaAsset/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(MediaAsset.fromJson)
.toList();
}
/// 获取媒体资产详情。
///
/// GET /sys/mediaAsset/info/{id}
Future<MediaAsset> getMediaAssetInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/mediaAsset/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '媒体资产信息加载失败:$id');
}
return MediaAsset.fromJson(data);
}
/// 新增或修改媒体资产。
///
/// POST /sys/mediaAsset/save,body 为媒体数据。
Future<void> saveMediaAsset(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/sys/mediaAsset/save', data: data);
}
/// 批量删除媒体资产。
///
/// POST /sys/mediaAsset/delete,body 为 `{ids: [...]}`。
Future<void> deleteMediaAsset(List<int> ids) async {
await _dio.post<dynamic>(
'/sys/mediaAsset/delete',
data: {'ids': ids},
);
}
// ================ 媒体排行榜 ================
/// 获取媒体排行榜 Top 10。
///
/// GET /sys/mediaAssetRank/top10
Future<List<MediaRankItem>> getMediaAssetRankTop10() async {
final response = await _dio.get<dynamic>('/sys/mediaAssetRank/top10');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(MediaRankItem.fromJson)
.toList();
}
/// 设置排行榜(批量设置,覆盖原有列表)。
///
/// POST /sys/mediaAssetRank/set,body 为 `{codeList: [...]}`。
Future<void> setMediaAssetRank(List<String> codeList) async {
await _dio.post<dynamic>(
'/sys/mediaAssetRank/set',
data: {'codeList': codeList},
);
}
/// 从排行榜移除指定媒体。
///
/// POST /sys/mediaAssetRank/remove,body 为 `{code: ...}`。
Future<void> removeMediaAssetRank(String code) async {
await _dio.post<dynamic>(
'/sys/mediaAssetRank/remove',
data: {'code': code},
);
}
/// 将指定媒体上移一位。
///
/// POST /sys/mediaAssetRank/moveUp,body 为 `{code: ...}`。
Future<void> moveUpMediaAssetRank(String code) async {
await _dio.post<dynamic>(
'/sys/mediaAssetRank/moveUp',
data: {'code': code},
);
}
/// 将指定媒体下移一位。
///
/// POST /sys/mediaAssetRank/moveDown,body 为 `{code: ...}`。
Future<void> moveDownMediaAssetRank(String code) async {
await _dio.post<dynamic>(
'/sys/mediaAssetRank/moveDown',
data: {'code': code},
);
}
/// 清空排行榜。
///
/// POST /sys/mediaAssetRank/clear
Future<void> clearMediaAssetRank() async {
await _dio.post<dynamic>('/sys/mediaAssetRank/clear');
}
// ================ 工具方法 ================
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,747 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart';
import '../models/org_node.dart';
import '../models/store_config.dart';
import '../models/store_info.dart';
import '../utils/session_org_scope.dart';
import '../utils/store_local_cache.dart';
import 'api_client.dart';
import 'upload_service.dart';
/// 组织与门店管理服务:组织树、门店、设备分组查询。
///
/// 对接真实「爱视后台」/api/sys/org/* 与 /api/sys/store/* 接口。
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
class OrgService {
OrgService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ 组织管理 ================
/// 获取组织树(全量树形结构,当前用户可见范围)。
///
/// GET /sys/org/tree
///
/// 注:后端该接口在部分部署下会返回**全量**组织树,不做数据权限收敛,
/// 因此这里在客户端按 [SessionOrgScope] 把树裁剪到「当前登录用户所属组织
/// 子树」(含自身与全部后代)。[SessionOrgScope] 未启用(管理员 / orgId 为空)
/// 时原样返回,行为与后端保持一致。
Future<List<OrgNode>> getOrgTree() async {
final response = await _dio.get<dynamic>('/sys/org/tree');
final list = _extractList(response.data);
final tree = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
return _scopeTree(tree);
}
/// 获取组织列表(分页)。
///
/// GET /sys/org/list?page=&limit=
Future<List<OrgNode>> getOrgList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/org/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取组织扁平列表(不分页,用于下拉选择)。
///
/// GET /sys/org/flatList
///
/// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点,
/// 避免下拉选项出现其他组织的越权数据。
Future<List<OrgNode>> getOrgFlatList() async {
final response = await _dio.get<dynamic>('/sys/org/flatList');
final list = _extractList(response.data);
final all = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
final scope = SessionOrgScope.instance;
if (!scope.enabled) return all;
final ids = await scopedOrgIdSet();
if (ids.isEmpty) return all;
return all.where((n) => ids.contains(n.id)).toList();
}
/// 获取组织下拉选项(同扁平列表,语义区分)。
///
/// GET /sys/org/select
///
/// 同样受 [SessionOrgScope] 收敛,仅返回当前用户组织子树内的节点。
Future<List<OrgNode>> getOrgSelect() async {
final response = await _dio.get<dynamic>('/sys/org/select');
final list = _extractList(response.data);
final all = list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
final scope = SessionOrgScope.instance;
if (!scope.enabled) return all;
final ids = await scopedOrgIdSet();
if (ids.isEmpty) return all;
return all.where((n) => ids.contains(n.id)).toList();
}
/// 获取组织详情。
///
/// GET /sys/org/info/{id}
Future<OrgNode> getOrgInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/org/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '组织信息加载失败:$id');
}
return OrgNode.fromJson(data);
}
/// 获取渠道列表(type=1 的组织节点)。
///
/// GET /sys/org/channels
Future<List<OrgNode>> getOrgChannels() async {
final response = await _dio.get<dynamic>('/sys/org/channels');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。
///
/// GET /sys/org/channelAgentTree?channelId={channelId}
///
/// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走
/// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到
/// 渠道、代理树加载为空)。
Future<List<OrgNode>> getChannelAgentTree(String channelId) async {
final response = await _dio.get<dynamic>(
'/sys/org/channelAgentTree',
queryParameters: {'channelId': channelId},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取「渠道商管理」统一树(渠道 → 代理/子代理)。
///
/// 后端无单一渠道商全量树接口,此处组合两个既有接口:
/// 1. GET /sys/org/channels 拉顶层渠道列表;
/// 2. 对每个渠道 GET /sys/org/channelAgentTree?channelId=xxx 拉其下代理/子代理树。
///
/// **降级策略**:若 /sys/org/channels 失败(部分部署下该端点可能返回服务端异常
/// 或权限错误),自动回退到 GET /sys/org/tree(全量组织树,已在门店列表中验证可用),
/// 从中提取 type=1(渠道)与 type=2(代理)节点及其子树,保证页面不白屏。
///
/// 单渠道 agentTree 请求失败不影响整体(该渠道降级为无子节点),其余渠道正常展示。
/// 结果按渠道 orderNo 升序、渠道内子节点递归按 orderNo 升序,便于稳定展示。
Future<List<OrgNode>> getChannelTree() async {
// 主路径:/sys/org/channels + /sys/org/channelAgentTree
try {
final channels = await getOrgChannels();
final tree = <OrgNode>[];
for (final channel in channels) {
List<OrgNode> agents = const <OrgNode>[];
try {
agents = await getChannelAgentTree(channel.id);
} catch (_) {
// 单渠道拉取失败:降级为无子节点,继续后续渠道。
}
tree.add(channel.copyWith(children: _sortTree(agents)));
}
tree.sort(_byOrderNo);
if (tree.isNotEmpty) return tree;
} catch (e) {
debugPrint('[OrgService.getChannelTree] /sys/org/channels 主路径失败: $e');
}
// 降级路径:从全量组织树递归提取渠道/代理节点。
try {
final orgTree = await getOrgTree();
final fallback = _collectChannelNodes(orgTree);
if (fallback.isNotEmpty) {
debugPrint(
'[OrgService.getChannelTree] 已从 /sys/org/tree 降级加载 '
'${fallback.length} 个渠道/代理节点',
);
return _sortTree(fallback);
}
} catch (e) {
debugPrint('[OrgService.getChannelTree] 降级路径也失败: $e');
}
// 双路径均失败:抛出原始异常让 UI 展示错误。
throw ApiException(500, '无法加载渠道数据,请检查网络或联系管理员');
}
/// 从组织树中**递归**收集渠道(type=1)/代理(type=2) 节点,保留它们之间的层级关系。
///
/// 平台(type=0)/门店(type=3)/分组(type=4) 等非渠道节点作为「过路节点」被跳过
/// (不单独展示),但其下方若含有渠道/代理则继续下钻——确保即使组织树的
/// 顶层是平台节点(type=0),其下的渠道与代理仍能被正确提取。
///
/// 例:平台 → 渠道A → 代理B → 门店C → 代理D
/// 提取结果:[渠道A(子=[代理B(子=[代理D])])]
static List<OrgNode> _collectChannelNodes(List<OrgNode> nodes) {
final result = <OrgNode>[];
for (final node in nodes) {
if (node.type == 1 || node.type == 2) {
// 渠道/代理:保留自身,递归过滤子孙仅保留渠道/代理支系。
result.add(node.copyWith(children: _collectChannelNodes(node.children)));
} else {
// 非渠道节点:跳过自身,但透传其下方可能含渠道/代理的分支。
if (node.children.isNotEmpty) {
result.addAll(_collectChannelNodes(node.children));
}
}
}
return result;
}
/// 递归按 orderNo 升序排序树(节点及各级子节点)。
static List<OrgNode> _sortTree(List<OrgNode> nodes) {
final sorted = [...nodes]..sort(_byOrderNo);
return sorted
.map((n) => n.copyWith(children: _sortTree(n.children)))
.toList();
}
/// 按 orderNo 升序的比较器(orderNo 相同再按 id 兜底,保证稳定)。
static int _byOrderNo(OrgNode a, OrgNode b) {
if (a.orderNo != b.orderNo) return a.orderNo.compareTo(b.orderNo);
return a.id.compareTo(b.id);
}
/// 新增门店 / 组织。
///
/// POST /sys/org/save,body **不带 id/orgId**(带 id 会走 insert 分支,
/// 报「数据库中已存在该记录」;只带 orgId 会被忽略而误建新记录)。
/// 门店为 type='STORE',且**必填 province**,否则报「门店省份不能为空」。
/// 返回后端 data 中的 orgId(新增门店时可用于后续创建激活码等)。
Future<String?> createOrg(Map<String, dynamic> data) async {
final payload = Map<String, dynamic>.from(data);
// 新增必须剥离主键,防止后端误判为更新或重复插入。
payload.remove('id');
payload.remove('orgId');
final response = await _dio.post<dynamic>('/sys/org/save', data: payload);
final result = response.data;
if (result is Map<String, dynamic>) {
return result['orgId']?.toString() ?? result['id']?.toString();
}
if (result is String) return result;
return null;
}
/// 修改门店 / 组织。
///
/// POST /sys/org/update,后端**只认 id**(传 orgId 会报「组织不存在」),
/// 因此强制以 id 为主键并剥离 orgId,避免编辑被误判。
/// 返回后端 data 中的 orgId。
Future<String?> updateOrg(Map<String, dynamic> data) async {
final payload = Map<String, dynamic>.from(data);
// 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。
final id = (data['id'] ?? data['orgId'])?.toString() ?? '';
payload.remove('orgId');
payload['id'] = id;
final response = await _dio.post<dynamic>('/sys/org/update', data: payload);
final result = response.data;
if (result is Map<String, dynamic>) {
return result['orgId']?.toString() ?? result['id']?.toString();
}
if (result is String) return result;
return null;
}
/// 新增或修改组织(兼容旧调用方,如组织树页面)。
///
/// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回
/// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到
/// [createOrg](新增)或 [updateOrg](编辑)。
Future<String?> saveOrUpdateOrg(Map<String, dynamic> data) async {
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
return isEdit ? updateOrg(data) : createOrg(data);
}
/// 删除组织(含子节点校验)。
///
/// POST /sys/org/delete/{id}
Future<void> deleteOrg(String id) async {
await _dio.post<dynamic>('/sys/org/delete/$id');
}
// ================ 门店管理 ================
/// 获取当前登录用户可见的门店列表。
///
/// 排序 / 展示需与 Web 后台「门店管理」保持一致:Web 后台门店管理读取的是
/// 组织树(门店专用接口 /sys/store/* 在该账号下多返回 500,不可用),故
/// 第 1 步直接读组织树并递归收集 type=3 门店,同时透传节点的 [OrgNode.createdAt]
/// 作为 [StoreInfo.createTime] 供「建店时间倒序」使用;门店专用接口仅作兜底。
///
/// 回退链(任一成功且非空即用):
/// 1. 组织树 GET /sys/org/tree,递归收集 type=3 门店(透传 createdAt);
/// 2. 门店专用 GET /sys/store/myStores;
/// 3. 登录组织门店 GET /sys/store/listByLoginOrg。
Future<List<StoreInfo>> getMyStores() async {
// 第 1 步:组织树(与 Web 后台门店管理同源,且透传 createdAt)。
try {
final tree = await getOrgTree();
final stores = _collectStores(tree);
if (stores.isNotEmpty) return stores;
} catch (_) {
// 继续回退。
}
// 第 2 步:myStores。
try {
final response = await _dio.get<dynamic>('/sys/store/myStores');
final list = _extractList(response.data);
final stores = list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
if (stores.isNotEmpty) return stores;
} catch (_) {
// 继续回退。
}
// 第 3 步:按登录组织查门店。
try {
final stores = await getStoreListByLoginOrg();
if (stores.isNotEmpty) return stores;
} catch (_) {
// 全部失败,返回空。
}
return const <StoreInfo>[];
}
/// 统计门店数量(type=3)。
///
/// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致,
/// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。
Future<int> fetchStoreCount() async {
try {
return (await getMyStores()).length;
} catch (_) {
return 0;
}
}
/// 递归把组织树扁平化,并过滤出门店(type=3 或 type='STORE')。
///
/// 新接口 /sys/org/tree 返回的 type 为字符串(CHANNEL/SERVICE_PROVIDER/STORE/GROUP),
/// 经 [OrgNode.fromJson] 已映射为 int,但此处兼容判断字符串更保险。
///
/// 组织树节点通常不携带 [parentId],而是通过 [children] 嵌套表达层级;
/// 因此递归时把当前节点 [id] 作为子节点的 [parentId] 传入,确保门店的
/// [StoreInfo.parentId] 能正确指向其在树中的直接父组织(渠道/代理商)。
static List<StoreInfo> _collectStores(List<OrgNode> nodes, {String parentId = ''}) {
final stores = <StoreInfo>[];
for (final node in nodes) {
final isStore = node.type == 3 || node.type.toString().toUpperCase() == 'STORE';
if (isStore) {
final province = node.province.trim();
final city = node.city.trim();
final address = node.address.trim();
final fullAddress = <String>[];
if (province.isNotEmpty) fullAddress.add(province);
if (city.isNotEmpty) fullAddress.add(city);
if (address.isNotEmpty) fullAddress.add(address);
// 优先使用节点自身携带的 parentId;树接口未返回时,使用递归上下文中的父节点 ID。
final effectiveParentId = node.parentId.isNotEmpty ? node.parentId : parentId;
stores.add(
StoreInfo(
orgId: node.id,
parentId: effectiveParentId.isEmpty ? null : effectiveParentId,
name: node.name,
address: fullAddress.join(''),
contactPhone: node.contactPhone,
activationCode: node.activationCode,
status: 0,
// 透传组织树节点的到期时间(部分部署的 /sys/org/tree 会返回
// expireTime/expire_time;缺失则为 null,列表项将据此显示「无到期时间」)。
expireTime: node.expireTime,
createTime: node.createdAt,
deviceLimit: node.groupLimit,
// 新接口 /sys/org/tree 返回的 themeId(如 35/36/37),
// 转为字符串供前端 StoreThemePresets 匹配或回显。
themeId: node.themeId?.toString(),
),
);
}
if (node.children.isNotEmpty) {
stores.addAll(_collectStores(node.children, parentId: node.id));
}
}
return stores;
}
/// 按组织 ID 获取门店列表。
///
/// GET /sys/store/byOrgId/{orgId}
Future<List<StoreInfo>> getStoresByOrgId(String orgId) async {
final response =
await _dio.get<dynamic>('/sys/store/byOrgId/$orgId');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 按门店 ID 获取设备分组列表。
///
/// GET /sys/store/deviceGroups/{storeId}
Future<List<OrgNode>> getDeviceGroupsByStoreId(String storeId) async {
final response =
await _dio.get<dynamic>('/sys/store/deviceGroups/$storeId');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 门店搜索(分页,支持多条件筛选)。
///
/// GET /sys/store/search?page=&limit=&orgId=&name=&status=
///
/// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织,
/// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时
/// 不附加,保留管理员全量查询能力)。
Future<List<StoreInfo>> searchStore({
int page = 1,
int limit = 20,
String? orgId,
String? name,
int? status,
}) async {
final effectiveOrgId = orgId ??
(SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
final response = await _dio.get<dynamic>(
'/sys/store/search',
queryParameters: {
'page': page,
'limit': limit,
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
if (name != null) 'name': name,
if (status != null) 'status': status,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 获取当前登录组织下的门店列表(可按名称模糊查询)。
///
/// GET /sys/store/listByLoginOrg?name=
Future<List<StoreInfo>> getStoreListByLoginOrg({String? name}) async {
final response = await _dio.get<dynamic>(
'/sys/store/listByLoginOrg',
queryParameters: {
if (name != null) 'name': name,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(StoreInfo.fromJson)
.toList();
}
/// 查询门店配置(到期时间 / 禁用状态等)。
///
/// GET /device/storeConfig/info/{orgId}
///
/// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。
/// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此
/// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。
Future<StoreConfig> getStoreConfig(String orgId) async {
try {
final response =
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) return StoreConfig.empty();
return StoreConfig.fromJson(data);
} catch (_) {
return StoreConfig.empty();
}
}
// ================ 门店扩展字段(前端占位) ================
/// 新增或更新门店(含主题/品牌/省市区等扩展字段)。
///
/// 门店本质上是 type='STORE' 的组织节点。按是否携带 id/orgId 分流:
/// - 新增 → [createOrg](POST /sys/org/save,不带主键,必填 province);
/// - 编辑 → [updateOrg](POST /sys/org/update,只认 id)。
/// 返回后端生成的 orgId(新增门店时可用于创建激活码)。
Future<String?> saveStore(Map<String, dynamic> data) async {
// 真实上报后端(新增/编辑分流到正确路由)。
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
final result = isEdit ? updateOrg(data) : createOrg(data);
// 扩展字段写本地缓存:用真实 orgId 回填,避免新建时因 orgId 为空而丢失
// 主题/品牌/省市区等建店配置(Web 端同源,移动端本地兜底回显)。
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
if (realOrgId.isNotEmpty) {
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
await _persistStoreExtension(ext);
}
return result;
}
/// 更新门店(含扩展字段)。
///
/// 固定走 [updateOrg](POST /sys/org/update,只认 id)。
Future<String?> updateStore(Map<String, dynamic> data) async {
final result = updateOrg(data);
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
if (realOrgId.isNotEmpty) {
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
await _persistStoreExtension(ext);
}
return result;
}
/// 设置门店主题皮肤。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。
/// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略,
/// 不影响门店本身已保存的结果)。
Future<void> setStoreTheme(String orgId, String themeId) async {
// 本地缓存兜底:即使上报失败,本机回显仍可用。
await StoreLocalCache.instance.saveTheme(orgId, themeId);
await _dio.post<dynamic>(
'/sys/store/setTheme',
data: {'orgId': orgId, 'themeId': themeId},
);
}
/// 设置门店启动图/Logo。
///
/// [splashUrl]/[logoUrl] 可以是:
/// - 远程 URL(http/https 开头)→ 直接上报;
/// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。
Future<void> setStoreBranding(
String orgId,
String splashUrl,
String logoUrl,
) async {
// 本地缓存兜底。
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
final upload = UploadService();
Future<String> resolve(String raw) async {
if (raw.isEmpty) return raw;
// 已是远程 URL 直接复用;否则当作本地路径上传拿 CDN URL。
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
return upload.uploadImage(XFile(raw), mediaType: 'image');
}
final splash = await resolve(splashUrl);
final logo = await resolve(logoUrl);
await _dio.post<dynamic>(
'/sys/store/setBranding',
data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo},
);
}
/// 设置门店省市区归属与详细地址。
///
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。
Future<void> setRegion(
String orgId,
String provinceCode,
String cityCode,
String districtCode,
String addressDetail,
) async {
await StoreLocalCache.instance.saveRegion(
orgId,
provinceCode,
cityCode,
districtCode,
addressDetail,
);
await _dio.post<dynamic>(
'/sys/store/setRegion',
data: {
'orgId': orgId,
'provinceCode': provinceCode,
'cityCode': cityCode,
'districtCode': districtCode,
'addressDetail': addressDetail,
},
);
}
/// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。
Future<void> _persistStoreExtension(Map<String, dynamic> data) async {
final orgId = (data['orgId'] ?? data['id'])?.toString() ?? '';
if (orgId.isEmpty) return;
final themeId = data['themeId']?.toString();
if (themeId != null && themeId.isNotEmpty) {
await StoreLocalCache.instance.saveTheme(orgId, themeId);
}
final splashUrl = data['splashUrl']?.toString() ?? '';
final logoUrl = data['logoUrl']?.toString() ?? '';
if (splashUrl.isNotEmpty || logoUrl.isNotEmpty) {
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
}
final provinceCode = data['provinceCode']?.toString() ?? '';
final cityCode = data['cityCode']?.toString() ?? '';
final districtCode = data['districtCode']?.toString() ?? '';
final addressDetail = data['addressDetail']?.toString() ?? '';
if (provinceCode.isNotEmpty ||
cityCode.isNotEmpty ||
districtCode.isNotEmpty ||
addressDetail.isNotEmpty) {
await StoreLocalCache.instance.saveRegion(
orgId,
provinceCode,
cityCode,
districtCode,
addressDetail,
);
}
}
// ================ 工具方法 ================
/// 在组织树中按节点 ID 深度优先查找节点。
static OrgNode? _findNode(List<OrgNode> nodes, String target) {
for (final n in nodes) {
if (n.id == target) return n;
final found = _findNode(n.children, target);
if (found != null) return found;
}
return null;
}
/// 收集某节点(含自身)的全部后代 ID(深度优先,含 root 本身)。
///
/// 用于组织树筛选:选中某个渠道 / 代理商 / 分组后,命中其下所有层级的门店。
/// 找不到 [rootId] 对应节点时返回空集合。
///
/// [nodes] 为已按 [SessionOrgScope] 收敛的组织树([getOrgTree] 返回值)。
static Set<String> collectDescendantIds(List<OrgNode> nodes, String rootId) {
final root = _findNode(nodes, rootId);
final ids = <String>{};
void walk(List<OrgNode> ns) {
for (final n in ns) {
ids.add(n.id);
walk(n.children);
}
}
if (root != null) walk([root]);
return ids;
}
/// 收集某节点(含自身)的全部后代中「门店(type=3)」的 ID。
///
/// 与 [collectDescendantIds] 的区别:仅收集门店节点,过滤掉渠道/代理/分组等
/// 非门店节点,便于直接作为 `/device/info/list` 的 `storeOrgId` 入参集合使用。
/// 找不到 [rootId] 对应节点时返回空集合。
static Set<String> collectDescendantStoreIds(
List<OrgNode> nodes,
String rootId,
) {
final root = _findNode(nodes, rootId);
final ids = <String>{};
void walk(List<OrgNode> ns) {
for (final n in ns) {
if (n.type == 3) ids.add(n.id);
walk(n.children);
}
}
if (root != null) walk([root]);
return ids;
}
/// 将组织树收敛到「当前登录用户所属组织子树」(含自身与全部后代)。
///
/// - 未启用 [SessionOrgScope](管理员 / orgId 为空):原样返回。
/// - 已启用但树中找不到对应节点:兜底原样返回,避免误清空。
///
/// 这样即便后端 `/sys/org/tree` 返回全量树,非管理员用户也只能看到
/// 自己组织及下属门店的数据。
static List<OrgNode> _scopeTree(List<OrgNode> tree) {
final scope = SessionOrgScope.instance;
if (!scope.enabled) return tree;
final root = _findNode(tree, scope.currentOrgId);
// 兜底:找不到当前组织节点时不过滤(避免误清空导致白屏),
// 但打印告警以便排查「用户 orgId 与组织树节点不匹配」的数据异常。
if (root == null) {
debugPrint(
'[OrgService._scopeTree] 未在组织树中找到当前组织节点 '
'(orgId=${scope.currentOrgId}),放弃客户端收敛',
);
return tree;
}
return [root];
}
/// 收集「当前已收敛组织树」包含的全部节点 ID(用于裁剪扁平/下拉列表与用户列表)。
///
/// 仅在 [SessionOrgScope] 启用时返回非空集合([getOrgTree] 已按范围收敛);
/// 未启用时返回空集合,调用方据此跳过过滤、保持原样。
Future<Set<String>> scopedOrgIdSet() async {
final scope = SessionOrgScope.instance;
if (!scope.enabled) return const {};
final tree = await getOrgTree();
final ids = <String>{};
void collect(List<OrgNode> nodes) {
for (final n in nodes) {
ids.add(n.id);
collect(n.children);
}
}
collect(tree);
return ids;
}
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,444 @@
import 'package:dio/dio.dart';
import '../models/schedule.dart';
import '../models/sys_config.dart';
import '../models/sys_log.dart';
import '../models/sys_menu.dart';
import '../models/sys_role.dart';
import '../models/sys_user.dart';
import 'api_client.dart';
/// 系统管理服务:用户、角色、菜单、参数配置、日志、定时任务、密码修改。
///
/// 对接真实「爱视后台」/api/sys/* 接口。
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
class SystemService {
SystemService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
// ================ 用户管理 ================
/// 获取用户列表。
///
/// GET /sys/user/list?page=&limit=&username=
Future<List<SysUser>> getUserList({
int page = 1,
int limit = 20,
String? username,
String? orgId,
}) async {
final response = await _dio.get<dynamic>(
'/sys/user/list',
queryParameters: {
'page': page,
'limit': limit,
if (username != null) 'username': username,
if (orgId != null) 'orgId': orgId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysUser.fromJson)
.toList();
}
/// 获取用户详情。
///
/// GET /sys/user/info/{id}
Future<SysUser> getUserInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/user/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '用户信息加载失败:$id');
}
return SysUser.fromJson(data);
}
/// 新增或修改用户。
///
/// 与 Web 端一致:携带 id 走 /sys/user/update(修改,密码留空表示不修改),
/// 否则走 /sys/user/save(新增,密码必填)。
Future<void> saveUser(Map<String, dynamic> data) async {
final hasId = data['id'] != null && data['id'].toString().isNotEmpty;
final url = hasId ? '/sys/user/update' : '/sys/user/save';
await _dio.post<dynamic>(url, data: data);
}
/// 批量删除用户。
///
/// POST /sys/user/delete,body 为 `{ids: [...]}`。
Future<void> deleteUser(List<int> ids) async {
await _dio.post<dynamic>(
'/sys/user/delete',
data: {'ids': ids},
);
}
// ================ 角色管理 ================
/// 获取角色列表(分页)。
///
/// GET /sys/role/list?page=&limit=
Future<List<SysRole>> getRoleList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/role/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysRole.fromJson)
.toList();
}
/// 获取角色下拉选项(不分页,用于用户表单选择角色)。
///
/// GET /sys/role/select
Future<List<SysRole>> getRoleSelect() async {
final response = await _dio.get<dynamic>('/sys/role/select');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysRole.fromJson)
.toList();
}
/// 获取角色详情。
///
/// GET /sys/role/info/{id}
Future<SysRole> getRoleInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/role/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '角色信息加载失败:$id');
}
return SysRole.fromJson(data);
}
/// 新增或修改角色。
///
/// POST /sys/role/save,body 为角色数据(可含 menuIdList)。
Future<void> saveRole(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/sys/role/save', data: data);
}
/// 批量删除角色。
///
/// POST /sys/role/delete,body 为 `{ids: [...]}`。
Future<void> deleteRole(List<int> ids) async {
await _dio.post<dynamic>(
'/sys/role/delete',
data: {'ids': ids},
);
}
// ================ 菜单管理 ================
/// 获取当前用户可见的导航菜单树。
///
/// GET /sys/menu/nav
Future<List<SysMenu>> getMenuNav() async {
final response = await _dio.get<dynamic>('/sys/menu/nav');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysMenu.fromJson)
.toList();
}
/// 获取菜单列表(分页或全量,后端可能返回平铺列表)。
///
/// GET /sys/menu/list
Future<List<SysMenu>> getMenuList() async {
final response = await _dio.get<dynamic>('/sys/menu/list');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysMenu.fromJson)
.toList();
}
/// 获取菜单下拉选项(用于角色权限树选择)。
///
/// GET /sys/menu/select
Future<List<SysMenu>> getMenuSelect() async {
final response = await _dio.get<dynamic>('/sys/menu/select');
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysMenu.fromJson)
.toList();
}
/// 获取菜单详情。
///
/// GET /sys/menu/info/{id}
Future<SysMenu> getMenuInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/menu/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '菜单信息加载失败:$id');
}
return SysMenu.fromJson(data);
}
/// 新增或修改菜单。
///
/// POST /sys/menu/save,body 为菜单数据。
Future<void> saveMenu(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/sys/menu/save', data: data);
}
/// 删除菜单(单个,含子菜单校验)。
///
/// POST /sys/menu/delete/{id}
Future<void> deleteMenu(String id) async {
await _dio.post<dynamic>('/sys/menu/delete/$id');
}
// ================ 参数配置 ================
/// 获取参数配置列表(分页)。
///
/// GET /sys/config/list?page=&limit=
Future<List<SysConfig>> getConfigList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/config/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysConfig.fromJson)
.toList();
}
/// 获取参数配置详情。
///
/// GET /sys/config/info/{id}
Future<SysConfig> getConfigInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/config/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '参数配置加载失败:$id');
}
return SysConfig.fromJson(data);
}
/// 新增或修改参数配置。
///
/// POST /sys/config/save,body 为配置数据。
Future<void> saveConfig(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/sys/config/save', data: data);
}
/// 批量删除参数配置。
///
/// POST /sys/config/delete,body 为 `{ids: [...]}`。
Future<void> deleteConfig(List<int> ids) async {
await _dio.post<dynamic>(
'/sys/config/delete',
data: {'ids': ids},
);
}
// ================ 日志管理 ================
/// 获取操作日志列表(分页)。
///
/// GET /sys/log/list?page=&limit=
Future<List<SysLog>> getLogList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/log/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysLog.fromJson)
.toList();
}
/// 获取审计日志列表(分页)。
///
/// GET /sys/auditLog/list?page=&limit=
Future<List<SysLog>> getAuditLogList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/auditLog/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysLog.fromJson)
.toList();
}
/// 获取 API 调用日志列表(分页)。
///
/// GET /sys/scheduleLog/list?page=&limit=
/// 注意:接口路径为 scheduleLog,用于定时任务调用日志查询。
Future<List<SysLog>> getApiJstCallLogList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/scheduleLog/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysLog.fromJson)
.toList();
}
// ================ 定时任务 ================
/// 获取定时任务列表(分页)。
///
/// GET /sys/schedule/list?page=&limit=
Future<List<Schedule>> getScheduleList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/schedule/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(Schedule.fromJson)
.toList();
}
/// 获取定时任务详情。
///
/// GET /sys/schedule/info/{id}
Future<Schedule> getScheduleInfo(String id) async {
final response = await _dio.get<dynamic>('/sys/schedule/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiException(500, '定时任务信息加载失败:$id');
}
return Schedule.fromJson(data);
}
/// 新增或修改定时任务。
///
/// POST /sys/schedule/save,body 为任务数据。
Future<void> saveSchedule(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/sys/schedule/save', data: data);
}
/// 批量删除定时任务。
///
/// POST /sys/schedule/delete,body 为 `{ids: [...]}`。
Future<void> deleteSchedule(List<int> ids) async {
await _dio.post<dynamic>(
'/sys/schedule/delete',
data: {'ids': ids},
);
}
/// 暂停定时任务。
///
/// POST /sys/schedule/pause,body 为 `{jobId: ...}`。
Future<void> pauseSchedule(String jobId) async {
await _dio.post<dynamic>(
'/sys/schedule/pause',
data: {'jobId': jobId},
);
}
/// 恢复定时任务。
///
/// POST /sys/schedule/resume,body 为 `{jobId: ...}`。
Future<void> resumeSchedule(String jobId) async {
await _dio.post<dynamic>(
'/sys/schedule/resume',
data: {'jobId': jobId},
);
}
/// 立即执行一次定时任务。
///
/// POST /sys/schedule/run,body 为 `{jobId: ...}`。
Future<void> runSchedule(String jobId) async {
await _dio.post<dynamic>(
'/sys/schedule/run',
data: {'jobId': jobId},
);
}
/// 获取定时任务日志列表(分页)。
///
/// GET /sys/scheduleLog/list?page=&limit=
///
/// 与 [getApiJstCallLogList] 相同路径,保留独立方法以区分语义。
Future<List<SysLog>> getScheduleLogList({
int page = 1,
int limit = 20,
}) async {
final response = await _dio.get<dynamic>(
'/sys/scheduleLog/list',
queryParameters: {'page': page, 'limit': limit},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(SysLog.fromJson)
.toList();
}
// ================ 密码修改 ================
/// 修改当前用户密码。
///
/// POST /sys/user/password,body 为 `{password, newPassword}`。
Future<void> updatePassword(
String oldPassword,
String newPassword,
) async {
await _dio.post<dynamic>(
'/sys/user/password',
data: {'password': oldPassword, 'newPassword': newPassword},
);
}
// ================ 工具方法 ================
/// 兼容列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,72 @@
import 'package:dio/dio.dart';
import '../models/theme_group.dart';
import '../models/theme_template.dart';
import 'api_client.dart';
/// 主题模板服务:对接 web 后台 /theme/themetemplate/* 与 /theme/page/* 接口。
class ThemeService {
ThemeService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
/// 获取全部启用的主题模板。
///
/// GET /theme/themetemplate/all?status=ENABLED
Future<List<ThemeTemplate>> getThemeTemplateAll() async {
final response = await _dio.get<dynamic>(
'/theme/themetemplate/all',
queryParameters: const {'status': 'ENABLED'},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(ThemeTemplate.fromJson)
.where((t) => t.id.isNotEmpty && t.isEnabled)
.toList();
}
/// 查询门店下各设备分组的主题预览列表(界面配置)。
///
/// GET /theme/page/org/{orgId}/photos
Future<List<ThemeGroup>> getStoreThemeGroups(String orgId) async {
final response = await _dio.get<dynamic>(
'/theme/page/org/$orgId/photos',
);
final data = response.data;
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(ThemeGroup.fromJson)
.where((g) => g.groupId > 0)
.toList();
}
if (data is Map<String, dynamic>) {
final code = data['code'];
final isSuccess = code == null || code == 200 || code == 0;
if (!isSuccess) return [];
final list = data['data'] ?? data['list'];
if (list is List) {
return list
.whereType<Map<String, dynamic>>()
.map(ThemeGroup.fromJson)
.where((g) => g.groupId > 0)
.toList();
}
}
return [];
}
/// 兼容列表响应的三种形态:
/// `{list: [...]}` / `{data: [...]}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) return data;
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) return list;
final innerData = data['data'];
if (innerData is List) return innerData;
}
return const <dynamic>[];
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../models/app_version.dart';
import '../utils/session_org_scope.dart';
import 'api_client.dart';
/// 版本更新检测结果。
///
/// [hasUpdate] 是否检测到更高版本;[latest] 服务端最新可用版本(无更新时为 null)。
class UpdateCheckResult {
const UpdateCheckResult({required this.hasUpdate, this.latest});
final bool hasUpdate;
final AppVersion? latest;
}
/// 版本更新服务:检测当前 App 是否有新版本可更新。
///
/// 对齐 Web 端 `searchAppVersion`:按当前登录用户所属组织查询该组织配置的
/// APP 版本(`GET /device/orgGroupAppVersion/search?orgId=...`),再与本地
/// versionCode 比较。
///
/// 注意:这里不再调用 `/device/orgGroupAppVersion/list`。`/list` 是管理后台
/// 全量枚举接口,普通用户常因权限不足收到「未知异常,请联系管理员」;
/// `/search` 才是「查询某组织当前应使用版本」的语义入口。
class UpdateService {
UpdateService({required this.packageName, ApiClient? client})
: _client = client ?? ApiClient.instance;
/// 当前 App 包名(运行时由 package_info_plus 取得,如 com.tongshi.cloudhome)。
final String packageName;
final ApiClient _client;
/// 查询当前用户所属组织配置的版本,并判定是否有更新。
///
/// - 优先使用传入的 [orgId],未传入时回退到 [SessionOrgScope.instance.currentOrgId];
/// - 若最终没有可用 orgId(管理员/未识别),直接返回「无更新」,避免向后端
/// 发起缺少必要参数的请求;
/// - 调用 `/device/orgGroupAppVersion/search?orgId=xxx` 获取该组织配置的版本;
/// - 兼容返回体为 `null` / `{appVersion: {...}}` / 直接版本对象三种形态;
/// - 仅当返回版本 `status==0` 且 versionCode 大于当前版本时才认为有更新;
/// - 若后端返回了 packageName 且与当前 App 包名不一致,也认为无更新,防止
/// 不同包被错误提示。
///
/// 任一异常均向上抛出,由调用方决定是否静默忽略(启动时检测失败不应阻断使用)。
Future<UpdateCheckResult> checkForUpdate(
int currentVersionCode, {
String? orgId,
}) async {
final effectiveOrgId = (orgId != null && orgId.isNotEmpty)
? orgId
: (SessionOrgScope.instance.enabled
? SessionOrgScope.instance.currentOrgId
: null);
if (effectiveOrgId == null || effectiveOrgId.isEmpty) {
if (kDebugMode) {
debugPrint('[UpdateService] 缺少 orgId,跳过更新检查');
}
return const UpdateCheckResult(hasUpdate: false);
}
if (kDebugMode) {
debugPrint(
'[UpdateService] orgId=$effectiveOrgId currentCode=$currentVersionCode',
);
}
final response = await _client.dio.get<dynamic>(
'/device/orgGroupAppVersion/search',
queryParameters: {'orgId': effectiveOrgId},
);
final latest = _parseVersion(response.data);
if (latest == null) {
if (kDebugMode) debugPrint('[UpdateService] 未查询到组织版本配置');
return const UpdateCheckResult(hasUpdate: false);
}
if (kDebugMode) {
debugPrint(
'[UpdateService] server versionCode=${latest.versionCode} '
'versionName=${latest.versionName} status=${latest.status} '
'packageName=${latest.packageName}',
);
}
if (latest.status != 0) {
return const UpdateCheckResult(hasUpdate: false);
}
// 后端配置了 packageName 但与当前 App 不匹配时,不提示更新
//(常见于同一组织下存在多个不同包的场景)。
if (latest.packageName.isNotEmpty && latest.packageName != packageName) {
return const UpdateCheckResult(hasUpdate: false);
}
return UpdateCheckResult(
hasUpdate: latest.versionCode > currentVersionCode,
latest: latest,
);
}
/// 解析 `/search` 返回的三种形态:
/// - `null`:该组织没有配置版本;
/// - `{appVersion: {...}}`:Web 端标准形态;
/// - 直接返回版本对象。
AppVersion? _parseVersion(dynamic data) {
if (data == null) return null;
if (data is! Map<String, dynamic>) return null;
final appVersion = data['appVersion'];
if (appVersion is Map<String, dynamic>) {
return AppVersion.fromJson(appVersion);
}
if (data.containsKey('id') || data.containsKey('versionName')) {
return AppVersion.fromJson(data);
}
return null;
}
}
/// 读取当前 App 的 [PackageInfo](包名 / versionCode / versionName)。
///
/// 集中封装以便多处复用,并在 [PackageInfo] 未初始化时提供兜底。
Future<PackageInfo> getCurrentPackageInfo() => PackageInfo.fromPlatform();

View File

@@ -0,0 +1,383 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'api_client.dart';
import 'cos/cos_client.dart';
/// 公司介绍(小类/详情)图片与视频上传服务。
///
/// 对齐 Web 端 `company-intro-subcategory-form.vue` 使用的上传通道:
/// 1. 计算文件 MD5;
/// 2. `GET /theme/resource/check-md5` 判断是否已存在(秒传,跳过 COS);
/// 3. `GET /theme/upload/credential` 获取腾讯云 STS 临时凭证(含 bucket/region/key);
/// 4. 用 STS 临时密钥直传 COS(putObject,原生签名 q-sign-algorithm=sha1);
/// 5. `POST /theme/resource/register` 注册 tv_resource;
/// 6. 解析访问 URL(`theme/` 走 ipscdn CDN)返回。
///
/// 复用 [ApiClient.instance.dio],自动携带 Bearer 令牌、解包 `{code,msg,data}` 信封。
class UploadService {
UploadService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
/// 主题上传资源 CDN 根地址(与 Web 端 themeCdnBase 一致)。
static const String _themeCdnBase = 'https://ipscdn.ainetv.cn';
/// 上传单张图片/视频,返回可访问的 CDN URL。
///
/// [mediaType] 资源类型:image | video | file,用于后端凭证与落库。
///
/// 失败统一抛出 [ApiException],message 为可展示的中文错误。
Future<String> uploadImage(XFile file, {String mediaType = 'image'}) async {
final ext = _suffixOf(file.path);
final bytes = await file.readAsBytes();
final fileMd5 = md5.convert(bytes).toString(); // 32 位小写十六进制
// 1. 秒传判断:已存在则直接返回 URL,跳过 COS 直传。
final instant = await _tryInstantUpload(fileMd5);
if (instant != null) return instant;
// 2. 获取 STS 临时凭证(含 bucket / region / key)。
final cred = await _fetchStsCredential(fileMd5, ext, mediaType);
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
}
// 3. 用 STS 临时密钥直传 COS。
final provider = TemporaryCredentialProvider(
fetchCredentials: () async => CosCredentialData(
startTime: cred.startTime,
expiredTime: cred.expiredTime,
credentials: CosCredential(
tmpSecretId: cred.tmpSecretId,
tmpSecretKey: cred.tmpSecretKey,
sessionToken: cred.securityToken,
),
requestId: '',
),
);
final cos = CosClient.withTemporaryCredential(
temporaryCredentialProvider: provider,
region: cred.region,
bucket: cred.bucket,
);
final result = await cos.putObjectFromFile(
objectKey: cred.key,
file: File(file.path),
);
if (!result.success) {
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
}
// 4. 解析可访问 URL(theme/ 走 ipscdn CDN)。
final url = _resolveUrl(cred.key, cred.url);
// 5. 注册 tv_resource(落库,供后续秒传与关联)。
await _registerResource(
fileName: file.name,
fileKey: cred.key,
fileUrl: url,
fileType: mediaType,
fileSize: bytes.length,
suffix: ext,
fileMd5: fileMd5,
);
return url;
}
/// 按原文件名上传 APK 文件(对齐 Web 端 `uploadThemeFileByOriginalName`)。
///
/// 流程:
/// 1. `GET /theme/upload/credential?fileName=<原始文件名>&suffix=apk&type=file` 获取 STS 凭证;
/// 2. 用 STS 临时密钥直传 COS(putObject);
/// 3. 返回可访问 URL(不注册 tv_resource,APK 不走 MD5 秒传)。
///
/// [onProgress] 上传进度回调(0~100)。
///
/// 失败抛出 [ApiException]。
Future<String> uploadApkByOriginalName(
PlatformFile file, {
void Function(int percent)? onProgress,
}) async {
final fileName = file.name.trim();
if (fileName.isEmpty) {
throw const ApiException(400, '文件名不能为空');
}
final ext = _suffixOf(fileName);
final safeExt = (ext == 'apk') ? 'apk' : (ext.isNotEmpty ? ext : 'apk');
// 1. 获取 STS 凭证(fileName = 原始文件名,非 MD5)。
final cred = await _fetchStsCredentialByName(
fileName: fileName,
suffix: safeExt,
type: 'file',
);
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
}
// 2. 直传 COS。
final provider = TemporaryCredentialProvider(
fetchCredentials: () async => CosCredentialData(
startTime: cred.startTime,
expiredTime: cred.expiredTime,
credentials: CosCredential(
tmpSecretId: cred.tmpSecretId,
tmpSecretKey: cred.tmpSecretKey,
sessionToken: cred.securityToken,
),
requestId: '',
),
);
final cos = CosClient.withTemporaryCredential(
temporaryCredentialProvider: provider,
region: cred.region,
bucket: cred.bucket,
);
// file_picker 的 PlatformFile 可能有 path(Android 通常有)。
final filePath = file.path;
if (filePath == null || filePath.isEmpty) {
throw const ApiException(400, '无法读取选中文件的路径');
}
final localFile = File(filePath);
if (!localFile.existsSync()) {
throw ApiException(400, '文件不存在:$filePath');
}
final result = await cos.putObjectFromFile(
objectKey: cred.key,
file: localFile,
contentType: 'application/vnd.android.package-archive',
onProgress: (count, total) {
if (total > 0 && onProgress != null) {
onProgress(((count / total) * 100).round());
}
},
);
if (!result.success) {
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
}
// 3. 解析访问 URL。
final url = _resolveUrl(cred.key, cred.url);
if (url.isEmpty) {
throw const ApiException(500, '未返回访问地址');
}
return url;
}
/// 打开系统文件选择器,仅允许 .apk 文件。
///
/// 返回选中的 [PlatformFile],用户取消返回 null。
static Future<PlatformFile?> pickApkFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['apk'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first;
}
/// 取文件扩展名(不含点,小写)。
String _suffixOf(String path) {
final name = path.split(Platform.pathSeparator).last;
final idx = name.lastIndexOf('.');
return idx > 0 ? name.substring(idx + 1).toLowerCase() : '';
}
/// 秒传判断:已存在同 MD5 资源则直接返回其访问 URL,否则返回 null。
///
/// 任何异常都不阻塞主流程(当作未命中秒传)。
Future<String?> _tryInstantUpload(String fileMd5) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/resource/check-md5',
queryParameters: {'fileMd5': fileMd5},
);
final data = resp.data;
if (data == null) return null;
bool uploaded = data['uploaded'] == true;
dynamic resourceRaw = data['resource'];
if (data['data'] is Map) {
final inner = data['data'] as Map;
if (inner['uploaded'] == true) {
uploaded = true;
resourceRaw ??= inner['resource'] ?? inner;
}
}
if (!uploaded) return null;
final r = resourceRaw is Map
? Map<String, dynamic>.from(resourceRaw as Map)
: null;
if (r == null) return null;
final id = r['id'] ?? r['resourceId'] ?? r['resource_id'];
if (id == null || id == 0) return null;
final key = (r['key'] ?? r['fileKey'] ?? '').toString();
final rawUrl = (r['url'] ?? r['fileUrl'] ?? '').toString();
final url = _resolveUrl(key, rawUrl);
return url.isNotEmpty ? url : null;
} catch (_) {
return null;
}
}
/// 获取 STS 临时凭证(以文件 MD5 作为 fileName,用于主题资源秒传)。
Future<_StsCred> _fetchStsCredential(
String fileMd5,
String ext,
String mediaType,
) async {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/upload/credential',
queryParameters: <String, dynamic>{
'fileName': fileMd5,
if (ext.isNotEmpty) 'suffix': ext,
'type': mediaType,
},
);
final data = resp.data;
if (data == null) {
throw const ApiException(500, '获取上传凭证失败:响应为空');
}
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
}
/// 获取 STS 临时凭证(以原始文件名作为 fileName,用于 APK 等需保留原名的场景)。
///
/// 对齐 Web 端 `fetchThemeCredentialByOriginalName`:
/// `GET /theme/upload/credential?fileName=<原始名>&suffix=<扩展名>&type=file`
Future<_StsCred> _fetchStsCredentialByName({
required String fileName,
required String suffix,
required String type,
}) async {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/upload/credential',
queryParameters: <String, dynamic>{
'fileName': fileName,
'suffix': suffix,
'type': type,
},
);
final data = resp.data;
if (data == null) {
throw const ApiException(500, '获取上传凭证失败:响应为空');
}
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
}
/// 注册 tv_resource。
Future<void> _registerResource({
required String fileName,
required String fileKey,
required String fileUrl,
required String fileType,
required int fileSize,
required String suffix,
required String fileMd5,
}) async {
try {
await _dio.post<Map<String, dynamic>>(
'/theme/resource/register',
data: <String, dynamic>{
'fileName': fileName,
'fileKey': fileKey,
'fileUrl': fileUrl,
'fileType': fileType,
'fileSize': fileSize,
'suffix': suffix,
'fileMd5': fileMd5,
},
);
} catch (e) {
// 直传已成功,但资源记录未落库:影响后续秒传与关联,明确提示。
throw ApiException(500, '资源注册失败:${formatApiError(e)}');
}
}
/// 解析访问 URL:theme/ 路径走 ipscdn CDN,其余沿用后端 url 或历史 CDN。
static String _resolveUrl(String key, String apiUrl) {
final k = key.replaceFirst(RegExp(r'^/+'), '');
if (k.startsWith('theme/')) {
return '$_themeCdnBase/$k';
}
if (apiUrl.startsWith('http')) return apiUrl;
if (k.startsWith('CloudMedia')) return 'https://metarialcdn.ainetv.cn/$k';
return apiUrl.isNotEmpty ? apiUrl : '$_themeCdnBase/$k';
}
}
/// 归一化后的 STS 临时凭证(兼容后端大小写字段名)。
class _StsCred {
_StsCred({
required this.tmpSecretId,
required this.tmpSecretKey,
required this.securityToken,
required this.startTime,
required this.expiredTime,
required this.bucket,
required this.region,
required this.key,
required this.url,
});
final String tmpSecretId;
final String tmpSecretKey;
final String securityToken;
final int startTime;
final int expiredTime;
final String bucket;
final String region;
final String key;
final String url;
static String _pick(Map m, List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString().trim();
}
return '';
}
static int _pickInt(Map m, List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v != null) {
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n > 0) return n;
}
}
return 0;
}
factory _StsCred.fromJson(Map<String, dynamic> m) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var start = _pickInt(m, ['StartTime', 'startTime']);
if (start == 0) start = now - 60;
var exp = _pickInt(m, ['ExpiredTime', 'expiredTime']);
if (exp == 0) exp = now + 1800;
return _StsCred(
tmpSecretId: _pick(m, ['TmpSecretId', 'tmpSecretId']),
tmpSecretKey: _pick(m, ['TmpSecretKey', 'tmpSecretKey']),
securityToken: _pick(m, ['SecurityToken', 'securityToken', 'sessionToken']),
startTime: start,
expiredTime: exp,
bucket: _pick(m, ['bucket', 'Bucket']),
region: _pick(m, ['region', 'Region']),
key: _pick(m, ['key', 'Key']),
url: _pick(m, ['url']),
);
}
}

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

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../utils/i10n.dart';
import '../utils/responsive.dart';
/// 底部导航栏(配合 [StatefulShellRoute] 使用)。
///
/// 五个 tab:首页、设备、门店、管理、我的。
class AppBottomNav extends StatelessWidget {
const AppBottomNav({super.key, required this.shell});
/// go_router 的导航壳。
final StatefulNavigationShell shell;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final railDestinations = <NavigationRailDestination>[
NavigationRailDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: Text(t.home),
),
NavigationRailDestination(
icon: const Icon(Icons.devices_outlined),
selectedIcon: const Icon(Icons.devices),
label: Text(t.devices),
),
NavigationRailDestination(
icon: const Icon(Icons.store_outlined),
selectedIcon: const Icon(Icons.store),
label: Text(t.stores),
),
NavigationRailDestination(
icon: const Icon(Icons.admin_panel_settings_outlined),
selectedIcon: const Icon(Icons.admin_panel_settings),
label: Text(t.admin),
),
NavigationRailDestination(
icon: const Icon(Icons.person_outline),
selectedIcon: const Icon(Icons.person),
label: Text(t.profile),
),
];
void onSelect(int index) => shell.goBranch(
index,
// 点击当前 tab 时回到该分支根路由。
initialLocation: index == shell.currentIndex,
);
// 横屏:改用左侧 NavigationRail,腾出底部 80dp 纵向空间。
if (context.responsive.isLandscape) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: onSelect,
labelType: NavigationRailLabelType.all,
destinations: railDestinations,
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(child: shell),
],
),
);
}
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: onSelect,
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.home,
),
NavigationDestination(
icon: const Icon(Icons.devices_outlined),
selectedIcon: const Icon(Icons.devices),
label: t.devices,
),
NavigationDestination(
icon: const Icon(Icons.store_outlined),
selectedIcon: const Icon(Icons.store),
label: t.stores,
),
NavigationDestination(
icon: const Icon(Icons.admin_panel_settings_outlined),
selectedIcon: const Icon(Icons.admin_panel_settings),
label: t.admin,
),
NavigationDestination(
icon: const Icon(Icons.person_outline),
selectedIcon: const Icon(Icons.person),
label: t.profile,
),
],
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../app/theme.dart';
/// 统一底部弹窗封装:一次性解决「无把手 / 无安全区 / 无限高 / 保存按钮埋在滚动区」。
///
/// - 顶部拖拽把手([BottomSheetThemeData.showDragHandle] 已全局兜底,这里显式再设一次)。
/// - 安全区(不被手势条遮挡)。
/// - 限宽 640 居中(横屏不铺满)。
/// - [footer] 吸底操作栏始终可见,[body] 内部独立滚动。
///
/// 详见 docs/手机端UI优化方案-v2.md §8.4。
Future<T?> showAppBottomSheet<T>({
required BuildContext context,
required Widget body,
Widget? footer,
String? title,
double heightFactor = 0.92,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * heightFactor,
),
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: Theme.of(ctx)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
),
Flexible(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
20 + MediaQuery.viewInsetsOf(ctx).bottom,
),
child: body,
),
),
if (footer != null)
Container(
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(ctx).colorScheme.outlineVariant,
),
),
boxShadow: AppShadows.lg,
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
// 吸底栏不受滚动影响,但底部手势条区域交给 SafeArea 处理。
child: SafeArea(top: false, child: footer),
),
],
),
);
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
/// 品牌配置编辑(启动图 + Logo)。
///
/// 通过 [ImagePicker] 选择本地图片,并用「手机外框」预览:
/// - 启动图为全屏背景;
/// - Logo 为右下角角标。
///
/// [splashUrl]/[logoUrl] 为本地图片路径(占位),由父级保存。
class BrandingEditor extends StatefulWidget {
const BrandingEditor({
super.key,
this.initialSplashUrl,
this.initialLogoUrl,
});
final String? initialSplashUrl;
final String? initialLogoUrl;
@override
State<BrandingEditor> createState() => BrandingEditorState();
}
/// 品牌编辑器状态(公开,供父级通过 GlobalKey 读取所选图片路径)。
class BrandingEditorState extends State<BrandingEditor> {
final ImagePicker _picker = ImagePicker();
String? _splashUrl;
String? _logoUrl;
@override
void initState() {
super.initState();
_splashUrl = widget.initialSplashUrl;
_logoUrl = widget.initialLogoUrl;
}
Future<void> _pickSplash() async {
final xFile = await _picker.pickImage(source: ImageSource.gallery);
if (xFile != null && mounted) {
setState(() => _splashUrl = xFile.path);
}
}
Future<void> _pickLogo() async {
final xFile = await _picker.pickImage(source: ImageSource.gallery);
if (xFile != null && mounted) {
setState(() => _logoUrl = xFile.path);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: _pickSplash,
child: const Text('选择启动图'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonal(
onPressed: _pickLogo,
child: const Text('选择 Logo'),
),
),
],
),
const SizedBox(height: 12),
// 手机外框预览。
Center(
child: Container(
width: 200,
height: 380,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.black87, width: 8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// 启动图全屏背景。
if (_splashUrl != null && _splashUrl!.isNotEmpty)
Positioned.fill(
child: Image.network(
_splashUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: theme.colorScheme.primaryContainer,
),
),
)
else
Positioned.fill(
child: Container(
color: theme.colorScheme.primaryContainer,
child: Center(
child: Text(
'启动图预览',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
),
),
// 顶部状态栏占位。
Positioned(
top: 8,
left: 0,
right: 0,
child: Container(
height: 18,
color: Colors.black.withOpacity(0.25),
),
),
// Logo 右下角角标。
if (_logoUrl != null && _logoUrl!.isNotEmpty)
Positioned(
right: 16,
bottom: 24,
child: Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
child: ClipOval(
child: Image.network(
_logoUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.image, size: 28),
),
),
),
),
],
),
),
),
],
);
}
/// 当前启动图本地路径(未选返回 null)。
String? get splashUrl => _splashUrl;
/// 当前 Logo 本地路径(未选返回 null)。
String? get logoUrl => _logoUrl;
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../app/theme.dart';
/// 品牌云卡:橙→金渐变表面 + 深棕文字,用于首页 / 概览的招牌区块。
///
/// 自动把内部文字与图标设为 [AppBrand.onCloud](亮橙底上的深棕),
/// 因此卡片内无需再单独指定前景色。
class CloudCard extends StatelessWidget {
const CloudCard({
super.key,
this.child,
this.padding = const EdgeInsets.all(20),
this.radius,
});
final Widget? child;
final EdgeInsetsGeometry padding;
/// 圆角覆盖;默认用 [AppRadius.lg]。
final double? radius;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
gradient: AppBrand.cloudGradient,
borderRadius: BorderRadius.circular(radius ?? AppRadius.lg),
boxShadow: AppShadows.md,
),
padding: padding,
foregroundDecoration: scheme.brightness == Brightness.dark
? BoxDecoration(
borderRadius: BorderRadius.circular(radius ?? AppRadius.lg),
color: Colors.black.withOpacity(0.28),
)
: null,
child: DefaultTextStyle(
style: const TextStyle(color: AppBrand.onCloud),
child: IconTheme(
data: const IconThemeData(color: AppBrand.onCloud),
child: child ?? const SizedBox.shrink(),
),
),
);
}
}

View File

@@ -0,0 +1,441 @@
import 'package:flutter/material.dart';
import '../models/device.dart';
import '../services/api_client.dart';
import '../services/device_service.dart';
import '../utils/i10n.dart';
/// 指令参数项。
///
/// [key] **必须与 Web 后台 `src/api/device.ts` 的 `commandTypeOptions`
/// 参数 key 完全一致**,否则后端取不到值,设备端会因缺少参数而忽略该指令。
/// 当前约定:
/// - `passwordUpdate` → `passwd`
/// - `mediaZoneVol` → `vol`
/// - `defaultVol` → `vol` + `maxVol`
class _CommandParam {
const _CommandParam(this.key, this.label, {this.numeric = false});
/// 提交给后端的参数名。
final String key;
/// 输入框标签。
final String label;
/// 是否为 0~100 的整数(音量类参数)。
final bool numeric;
}
/// 设备指令项。
class _CommandItem {
const _CommandItem(this.value, this.label, {this.params = const []});
final String value;
final String label;
/// 需要用户填写的参数;为空表示无需输入,直接确认后下发。
final List<_CommandParam> params;
bool get needsInput => params.isNotEmpty;
}
/// 设备指令下发弹窗。
///
/// 两种用法:
/// 1. 单设备:传 [device],指令按 MAC 下发([DeviceService.sendCommandByMac]);
/// 2. 门店批量:传 [orgId](不传 [device]),指令按组织下发
/// ([DeviceService.sendCommandByOrg]),影响该门店下全部设备。
class DeviceCommandSheet extends StatefulWidget {
const DeviceCommandSheet({
super.key,
this.device,
this.orgId,
this.orgName,
required this.deviceService,
}) : assert(device != null || orgId != null,
'device 与 orgId 至少传其一');
/// 单设备模式下的目标设备。
final Device? device;
/// 门店批量模式下的组织 ID。
final String? orgId;
/// 门店批量模式下的组织名称(仅用于展示)。
final String? orgName;
final DeviceService deviceService;
/// 是否为门店批量模式。
bool get isOrgMode => orgId != null && orgId!.isNotEmpty;
@override
State<DeviceCommandSheet> createState() => _DeviceCommandSheetState();
}
class _DeviceCommandSheetState extends State<DeviceCommandSheet> {
final List<TextEditingController> _paramControllers = [];
bool _sending = false;
/// 设备心跳超过该分钟数即视为「可能离线」,下发前给出提示。
static const int _heartbeatStaleMinutes = 3;
List<_CommandItem> _commands(BuildContext context) {
final t = AppLocalizations.of(context);
return [
_CommandItem(DeviceCommand.powerOff, t.deviceCommandLabel(DeviceCommand.powerOff)),
_CommandItem(DeviceCommand.reboot, t.deviceCommandLabel(DeviceCommand.reboot)),
_CommandItem(DeviceCommand.screenshot, t.deviceCommandLabel(DeviceCommand.screenshot)),
_CommandItem(DeviceCommand.themeUpdate, t.deviceCommandLabel(DeviceCommand.themeUpdate)),
_CommandItem(DeviceCommand.versionUpdate, t.deviceCommandLabel(DeviceCommand.versionUpdate)),
_CommandItem(DeviceCommand.marqueeUpdate, t.deviceCommandLabel(DeviceCommand.marqueeUpdate)),
_CommandItem(DeviceCommand.weatherUpdate, t.deviceCommandLabel(DeviceCommand.weatherUpdate)),
_CommandItem(
DeviceCommand.mediaZoneVol,
t.deviceCommandLabel(DeviceCommand.mediaZoneVol),
params: [
_CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.mediaZoneVol),
numeric: true),
],
),
_CommandItem(
DeviceCommand.defaultVol,
t.deviceCommandLabel(DeviceCommand.defaultVol),
params: [
_CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.defaultVol),
numeric: true),
_CommandParam('maxVol', t.maxVolume, numeric: true),
],
),
_CommandItem(DeviceCommand.themeClear, t.deviceCommandLabel(DeviceCommand.themeClear)),
_CommandItem(DeviceCommand.cacheClear, t.deviceCommandLabel(DeviceCommand.cacheClear)),
_CommandItem(DeviceCommand.storeExpire, t.deviceCommandLabel(DeviceCommand.storeExpire)),
_CommandItem(DeviceCommand.storeDisable, t.deviceCommandLabel(DeviceCommand.storeDisable)),
_CommandItem(
DeviceCommand.passwordUpdate,
t.deviceCommandLabel(DeviceCommand.passwordUpdate),
params: [
_CommandParam('passwd', t.deviceCommandInputLabel(DeviceCommand.passwordUpdate)),
],
),
_CommandItem(DeviceCommand.worldAd, t.deviceCommandLabel(DeviceCommand.worldAd)),
];
}
/// 设备是否「可能离线」:状态为 offline,或心跳时间早于阈值。
///
/// 用于下发前提示,不改变下发行为(仍可强制下发)。
bool _isLikelyOffline(Device device) {
if (device.status == DeviceStatus.offline) return true;
final heartbeat = device.lastSyncTime;
if (heartbeat == null) return true;
return DateTime.now().difference(heartbeat).inMinutes > _heartbeatStaleMinutes;
}
Future<void> _send(_CommandItem item) async {
final t = AppLocalizations.of(context);
final data = <String, dynamic>{};
if (item.needsInput) {
for (var i = 0; i < item.params.length; i++) {
final param = item.params[i];
final value = _paramControllers[i].text.trim();
if (value.isEmpty) {
// maxVol 允许留空(后端对该项有默认值),其余参数必填。
if (param.key == 'maxVol') continue;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.pleaseInput}${param.label}')),
);
return;
}
if (param.numeric) {
final vol = int.tryParse(value);
if (vol == null || vol < 0 || vol > 100) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.volumeRangeHint)),
);
return;
}
}
// 与 Web 端 `data[key] = item.value.trim()` 保持一致:统一传字符串,
// 避免后端按 String 解析数字时抛类型转换异常。
data[param.key] = value;
}
}
setState(() => _sending = true);
try {
if (widget.isOrgMode) {
await widget.deviceService.sendCommandByOrg(
orgId: widget.orgId!,
command: item.value,
data: data.isNotEmpty ? data : null,
);
} else {
final mac = widget.device!.mac.trim();
if (mac.isEmpty) {
if (!mounted) return;
setState(() => _sending = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.commandMacMissing)),
);
return;
}
await widget.deviceService.sendCommandByMac(
mac: mac,
command: item.value,
data: data.isNotEmpty ? data : null,
);
}
if (!mounted) return;
Navigator.pop(context);
final target = widget.isOrgMode
? '${t.belongStore}「${widget.orgName ?? widget.orgId}」'
: '${t.deviceDetails}「${widget.device!.macLabel}」';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.commandSent(target, item.label))),
);
} catch (e) {
if (!mounted) return;
setState(() => _sending = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${t.commandSendFailed}:${formatApiError(e)}')),
);
}
}
@override
void dispose() {
for (final c in _paramControllers) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = AppLocalizations.of(context);
final commands = _commands(context);
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isOrgMode ? t.sendOrgCommandTitle : t.sendCommandTitle,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
if (widget.isOrgMode) ...[
Text(
'${t.belongStore}:${widget.orgName ?? widget.orgId}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
'${t.commandType}:${t.allStores}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
] else ...[
Text(
'${t.deviceDetails}:${widget.device!.macLabel}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
'${t.macAddress}:${widget.device!.mac}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
_buildHeartbeatLine(t),
style: theme.textTheme.bodySmall?.copyWith(
color: widget.device != null && _isLikelyOffline(widget.device!)
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
Text(
t.commandType,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: commands.map((item) {
return ActionChip(
label: Text(item.label),
onPressed: _sending ? null : () => _onCommandTap(item),
);
}).toList(),
),
],
),
),
);
}
String _buildHeartbeatLine(AppLocalizations t) {
final device = widget.device;
if (device == null) return '';
final heartbeat = device.lastSyncTime;
if (heartbeat == null) {
return '${t.lastActiveTime}:${t.deviceSubtitleNotSet}';
}
final minutes = DateTime.now().difference(heartbeat).inMinutes;
return '${t.lastActiveTime}:${_format(heartbeat)}($minutes${t.minutesAgo})';
}
String _format(DateTime time) {
String two(int v) => v.toString().padLeft(2, '0');
return '${time.year}-${two(time.month)}-${two(time.day)} '
'${two(time.hour)}:${two(time.minute)}';
}
void _onCommandTap(_CommandItem item) {
if (!item.needsInput) {
_showConfirm(item);
return;
}
_showInputDialog(item);
}
/// 下发前校验:单设备模式下若设备离线/心跳超时,先给出警告。
Future<bool> _confirmIfOffline() async {
final device = widget.device;
final t = AppLocalizations.of(context);
if (device == null || !_isLikelyOffline(device)) return true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.commandConfirmTitle),
content: Text(t.commandOfflineWarning),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.commandOfflineConfirm),
),
],
),
) ??
false;
return ok;
}
void _showConfirm(_CommandItem item) async {
final t = AppLocalizations.of(context);
final proceed = await _confirmIfOffline();
if (!proceed || !mounted) return;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.commandConfirmTitle),
content: Text(t.commandConfirmContent(item.label)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.cancel),
),
FilledButton(
onPressed: _sending
? null
: () {
Navigator.pop(ctx);
_send(item);
},
child: _sending
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.confirm),
),
],
),
);
}
void _showInputDialog(_CommandItem item) async {
final t = AppLocalizations.of(context);
final proceed = await _confirmIfOffline();
if (!proceed || !mounted) return;
for (final c in _paramControllers) {
c.dispose();
}
_paramControllers
..clear()
..addAll(List.generate(item.params.length, (_) => TextEditingController()));
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(item.label),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(item.params.length, (i) {
final param = item.params[i];
return Padding(
padding: EdgeInsets.only(
bottom: i == item.params.length - 1 ? 0 : 12,
),
child: TextField(
controller: _paramControllers[i],
decoration: InputDecoration(
labelText: param.label,
border: const OutlineInputBorder(),
),
keyboardType:
param.numeric ? TextInputType.number : TextInputType.text,
obscureText: param.key == 'passwd',
),
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.cancel),
),
FilledButton(
onPressed: _sending
? null
: () {
Navigator.pop(ctx);
_send(item);
},
child: _sending
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.sendCommand),
),
],
),
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../models/device.dart';
import '../services/device_service.dart';
import '../utils/i10n.dart';
import 'device_command_sheet.dart';
import 'status_chip.dart';
/// 设备列表项。
class DeviceListTile extends StatelessWidget {
const DeviceListTile({
super.key,
required this.device,
this.onTap,
});
/// 设备数据。
final Device device;
/// 点击回调。
final VoidCallback? onTap;
IconData _iconByStatus() {
switch (device.status) {
case DeviceStatus.online:
return Icons.dns;
case DeviceStatus.offline:
return Icons.dns_outlined;
case DeviceStatus.warning:
return Icons.warning_amber;
}
}
String _title(BuildContext context) {
// 设备标识以 MAC 为主(房号独立显示在副标题,不再用「/」粘连)。
return device.macLabel;
}
String _subtitle(BuildContext context) {
// 副标题保持「门店 + 型号」不变,并追加独立的「房号:XXX」字段。
final t = AppLocalizations.of(context);
final store = device.storeName.isEmpty
? t.deviceSubtitleStore(t.deviceSubtitleNotSet)
: t.deviceSubtitleStore(device.storeName);
final model = '${t.modelLabel}:${device.model.isNotEmpty ? device.model : t.deviceSubtitleNotSet}';
final room = '${t.roomNo}:${device.roomNo.isNotEmpty ? device.roomNo : t.deviceSubtitleNotSet}';
return '$store $model $room';
}
void _showCommandSheet(BuildContext context) {
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DeviceCommandSheet(
device: device,
deviceService: DeviceService(),
),
);
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: ListTile(
leading: Icon(_iconByStatus(), size: 32),
title: Text(
_title(context),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(_subtitle(context)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.touch_app_outlined),
tooltip: AppLocalizations.of(context).sendCommand,
onPressed: () => _showCommandSheet(context),
),
),
StatusChip(status: device.status),
],
),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// 指标卡片:展示设备单项运行指标。
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.icon,
required this.title,
required this.value,
this.progress,
this.accentColor,
});
/// 指标图标。
final IconData icon;
/// 指标名称(如:CPU 使用率)。
final String title;
/// 指标值文本(如:87.0%)。
final String value;
/// 进度(0.0 ~ 1.0),为 null 时不显示进度条。
final double? progress;
/// 强调色,默认使用主题主色。
final Color? accentColor;
@override
Widget build(BuildContext context) {
final color = accentColor ?? Theme.of(context).colorScheme.primary;
final progressValue = progress?.clamp(0.0, 1.0).toDouble();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (progressValue != null) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: progressValue,
color: color,
backgroundColor: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import '../models/org_node.dart';
/// 组织选择项:携带层级深度与是否可选,用于按组织树自上而下排序并缩进展示。
class OrgPickerOption {
const OrgPickerOption({
required this.node,
this.depth = 0,
this.selectable = true,
});
final OrgNode node;
final int depth;
final bool selectable;
}
/// 组织选择器:以底部弹窗形式展示层级组织列表,支持关键字实时搜索,
/// 选中后通过 Navigator.pop 返回对应的 [OrgPickerOption]。
class OrgPickerSheet extends StatefulWidget {
const OrgPickerSheet({
super.key,
required this.title,
required this.searchHint,
required this.emptyHint,
required this.options,
this.selectedId,
});
final String title;
final String searchHint;
final String emptyHint;
final List<OrgPickerOption> options;
final String? selectedId;
@override
State<OrgPickerSheet> createState() => _OrgPickerSheetState();
}
class _OrgPickerSheetState extends State<OrgPickerSheet> {
final _searchController = TextEditingController();
String _keyword = '';
String? _selectedId;
@override
void initState() {
super.initState();
_selectedId = widget.selectedId;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSelect(OrgPickerOption option) {
if (_selectedId == option.node.id) {
Navigator.pop(context, option);
return;
}
setState(() => _selectedId = option.node.id);
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) Navigator.pop(context, option);
});
}
List<OrgPickerOption> get _filteredOptions {
final keyword = _keyword.trim().toLowerCase();
final list = widget.options.where((option) {
if (!option.selectable) return false;
if (keyword.isEmpty) return true;
return option.node.name.toLowerCase().contains(keyword);
}).toList();
// 将当前已选中的组织置顶展示,方便快速找到并确认当前选择。
if (_selectedId?.isNotEmpty == true) {
final selectedIndex = list.indexWhere((o) => o.node.id == _selectedId);
if (selectedIndex > 0) {
final selected = list.removeAt(selectedIndex);
list.insert(0, selected);
}
}
return list;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _filteredOptions;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 标题栏
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
// 搜索框
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: widget.searchHint,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
onChanged: (v) => setState(() => _keyword = v),
),
),
// 结果列表
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: filtered.isEmpty
? SizedBox(
height: 120,
child: Center(
child: Text(
widget.emptyHint,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(
height: 1,
indent: 16,
endIndent: 16,
),
itemBuilder: (context, index) {
final option = filtered[index];
final selected = _selectedId == option.node.id;
final baseStyle = theme.textTheme.bodyMedium;
return ListTile(
dense: true,
contentPadding: EdgeInsets.only(
left: 16 + option.depth * 16.0,
right: 16,
),
title: Text(
option.node.name,
style: option.selectable
? baseStyle?.copyWith(
color: selected
? theme.colorScheme.primary
: null,
fontWeight: selected
? FontWeight.bold
: FontWeight.normal,
)
: baseStyle?.copyWith(
color: theme.disabledColor,
),
),
trailing: selected
? Icon(
Icons.check_circle,
color: theme.colorScheme.primary,
)
: null,
selected: selected,
selectedTileColor: theme.colorScheme.primaryContainer
.withOpacity(0.38),
onTap: option.selectable
? () => _onSelect(option)
: null,
);
},
),
),
// 底部安全区
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
);
}
}
/// 表单中的「组织选择」输入框:点击后弹出 [OrgPickerSheet],
/// 进入页面时自动展开选择器并高亮已选项。
class OrgPickerFormField extends StatefulWidget {
const OrgPickerFormField({
super.key,
this.value,
required this.labelText,
required this.options,
required this.onChanged,
this.validator,
this.autoOpen = true,
this.searchHint,
this.emptyHint,
});
final String? value;
final String labelText;
final List<OrgPickerOption> options;
final ValueChanged<String?> onChanged;
final String? Function(String?)? validator;
final bool autoOpen;
final String? searchHint;
final String? emptyHint;
@override
State<OrgPickerFormField> createState() => _OrgPickerFormFieldState();
}
class _OrgPickerFormFieldState extends State<OrgPickerFormField> {
bool _autoOpened = false;
FormFieldState<String>? _field;
@override
void initState() {
super.initState();
_scheduleAutoOpen();
}
@override
void didUpdateWidget(covariant OrgPickerFormField oldWidget) {
super.didUpdateWidget(oldWidget);
// 外部 value 变更时同步到 FormField 状态。
if (oldWidget.value != widget.value) {
_field?.didChange(widget.value);
}
// 选项从空变为有值时(异步加载完成),立即自动展开选择器。
if (oldWidget.options.isEmpty && widget.options.isNotEmpty) {
_scheduleAutoOpen();
}
}
void _scheduleAutoOpen() {
if (!_shouldAutoOpen) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _shouldAutoOpen) {
_autoOpened = true;
_openPicker();
}
});
}
bool get _shouldAutoOpen =>
widget.autoOpen && widget.options.isNotEmpty && !_autoOpened;
Future<void> _openPicker() async {
if (!mounted) return;
final selected = await showModalBottomSheet<OrgPickerOption>(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => OrgPickerSheet(
title: widget.labelText,
searchHint: widget.searchHint ?? '搜索组织名称',
emptyHint: widget.emptyHint ?? '未找到匹配组织',
options: widget.options,
selectedId: widget.value,
),
);
if (selected != null && mounted) {
_field?.didChange(selected.node.id);
widget.onChanged(selected.node.id);
}
}
String? _displayName(String? value) {
if (value == null || value.isEmpty) return null;
for (final option in widget.options) {
if (option.node.id == value) return option.node.name;
}
return value;
}
@override
Widget build(BuildContext context) {
return FormField<String>(
initialValue: widget.value,
validator: widget.validator,
builder: (field) {
_field = field;
final displayName = _displayName(field.value);
final theme = Theme.of(context);
return InkWell(
onTap: _openPicker,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: widget.labelText,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.account_tree),
suffixIcon: const Icon(Icons.arrow_drop_down),
errorText: field.errorText,
),
child: Text(
displayName ?? widget.labelText,
style: TextStyle(
color: displayName == null ? theme.hintColor : null,
),
),
),
);
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More