ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
355
lib/app/router.dart
Normal file
355
lib/app/router.dart
Normal 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
423
lib/app/theme.dart
Normal 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;
|
||||
}
|
||||
55
lib/config/app_config.dart
Normal file
55
lib/config/app_config.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
lib/config/environment.dart
Normal file
17
lib/config/environment.dart
Normal 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,
|
||||
}
|
||||
17
lib/config/prod_config.dart
Normal file
17
lib/config/prod_config.dart
Normal 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;
|
||||
}
|
||||
48
lib/config/prod_guard.dart
Normal file
48
lib/config/prod_guard.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
16
lib/config/staging_config.dart
Normal file
16
lib/config/staging_config.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
/// 预发环境专属配置。
|
||||
///
|
||||
/// 与 [TestConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
|
||||
/// 预发环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
|
||||
class StagingConfig {
|
||||
StagingConfig._();
|
||||
|
||||
/// 预发环境后端 origin(按实际预发域名填写,**严禁填写生产地址**)。
|
||||
static const String apiBaseUrl = 'https://ips-staging.ainetv.com';
|
||||
|
||||
/// 预发环境是否启用登录验证码。
|
||||
static const bool enableCaptcha = true;
|
||||
|
||||
// 预发环境密钥 / 数据库连接等敏感配置在此独立维护,
|
||||
// 不得出现任何生产环境标识或引用。
|
||||
}
|
||||
16
lib/config/test_config.dart
Normal file
16
lib/config/test_config.dart
Normal 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
145
lib/data/region_data.dart
Normal 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
315
lib/main.dart
Normal 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
95
lib/models/alert.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
95
lib/models/app_version.dart
Normal file
95
lib/models/app_version.dart
Normal 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
622
lib/models/device.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
281
lib/models/intro_category.dart
Normal file
281
lib/models/intro_category.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
132
lib/models/marquee_config.dart
Normal file
132
lib/models/marquee_config.dart
Normal 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;
|
||||
}
|
||||
211
lib/models/marquee_message.dart
Normal file
211
lib/models/marquee_message.dart
Normal 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
128
lib/models/media_asset.dart
Normal 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
273
lib/models/org_node.dart
Normal 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
75
lib/models/schedule.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
59
lib/models/store_config.dart
Normal file
59
lib/models/store_config.dart
Normal 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
219
lib/models/store_info.dart
Normal 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('');
|
||||
}
|
||||
}
|
||||
74
lib/models/store_theme.dart
Normal file
74
lib/models/store_theme.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
41
lib/models/sys_config.dart
Normal file
41
lib/models/sys_config.dart
Normal 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
74
lib/models/sys_log.dart
Normal 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
72
lib/models/sys_menu.dart
Normal 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
84
lib/models/sys_role.dart
Normal 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
218
lib/models/sys_user.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/models/theme_group.dart
Normal file
94
lib/models/theme_group.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
55
lib/models/theme_template.dart
Normal file
55
lib/models/theme_template.dart
Normal 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
80
lib/models/tv_app.dart
Normal 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
76
lib/models/user.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
158
lib/providers/auth_provider.dart
Normal file
158
lib/providers/auth_provider.dart
Normal 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,
|
||||
);
|
||||
106
lib/providers/dashboard_provider.dart
Normal file
106
lib/providers/dashboard_provider.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
33
lib/providers/devices_provider.dart
Normal file
33
lib/providers/devices_provider.dart
Normal 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();
|
||||
});
|
||||
97
lib/providers/display_provider.dart
Normal file
97
lib/providers/display_provider.dart
Normal 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,
|
||||
);
|
||||
85
lib/providers/locale_provider.dart
Normal file
85
lib/providers/locale_provider.dart
Normal 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,
|
||||
);
|
||||
51
lib/providers/metrics_visibility_provider.dart
Normal file
51
lib/providers/metrics_visibility_provider.dart
Normal 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,
|
||||
);
|
||||
23
lib/providers/theme_provider.dart
Normal file
23
lib/providers/theme_provider.dart
Normal 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,
|
||||
);
|
||||
122
lib/screens/admin/admin_hub_page.dart
Normal file
122
lib/screens/admin/admin_hub_page.dart
Normal 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;
|
||||
}
|
||||
358
lib/screens/admin/app/app_version_page.dart
Normal file
358
lib/screens/admin/app/app_version_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
346
lib/screens/admin/app/tv_app_page.dart
Normal file
346
lib/screens/admin/app/tv_app_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
894
lib/screens/admin/channel/channel_management_page.dart
Normal file
894
lib/screens/admin/channel/channel_management_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal file
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal 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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
391
lib/screens/admin/media/media_list_page.dart
Normal file
391
lib/screens/admin/media/media_list_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
368
lib/screens/admin/media/media_rank_page.dart
Normal file
368
lib/screens/admin/media/media_rank_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
404
lib/screens/admin/sys/config_page.dart
Normal file
404
lib/screens/admin/sys/config_page.dart
Normal 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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/screens/admin/sys/log_page.dart
Normal file
261
lib/screens/admin/sys/log_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
511
lib/screens/admin/sys/menu_page.dart
Normal file
511
lib/screens/admin/sys/menu_page.dart
Normal 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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
340
lib/screens/admin/sys/role_page.dart
Normal file
340
lib/screens/admin/sys/role_page.dart
Normal 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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
624
lib/screens/admin/sys/schedule_page.dart
Normal file
624
lib/screens/admin/sys/schedule_page.dart
Normal 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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1191
lib/screens/admin/sys/user_page.dart
Normal file
1191
lib/screens/admin/sys/user_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
140
lib/screens/alerts/alerts_page.dart
Normal file
140
lib/screens/alerts/alerts_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
567
lib/screens/device/device_activation_page.dart
Normal file
567
lib/screens/device/device_activation_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
488
lib/screens/device/device_group_page.dart
Normal file
488
lib/screens/device/device_group_page.dart
Normal 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 ? '保存' : '创建'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1023
lib/screens/device/device_scan_page.dart
Normal file
1023
lib/screens/device/device_scan_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
554
lib/screens/device/store_config_page.dart
Normal file
554
lib/screens/device/store_config_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
618
lib/screens/home/device_detail_page.dart
Normal file
618
lib/screens/home/device_detail_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
lib/screens/home/device_list_page.dart
Normal file
492
lib/screens/home/device_list_page.dart
Normal 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}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/screens/home/home_page.dart
Normal file
261
lib/screens/home/home_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
509
lib/screens/login/login_page.dart
Normal file
509
lib/screens/login/login_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
430
lib/screens/profile/settings_page.dart
Normal file
430
lib/screens/profile/settings_page.dart
Normal 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');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
554
lib/screens/store/org_tree_page.dart
Normal file
554
lib/screens/store/org_tree_page.dart
Normal 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;
|
||||
}
|
||||
16
lib/screens/store/store_detail_page.dart
Normal file
16
lib/screens/store/store_detail_page.dart
Normal 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功能开发中')),
|
||||
);
|
||||
}
|
||||
}
|
||||
1771
lib/screens/store/store_list_page.dart
Normal file
1771
lib/screens/store/store_list_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
667
lib/services/api_client.dart
Normal file
667
lib/services/api_client.dart
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
212
lib/services/app_service.dart
Normal file
212
lib/services/app_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
264
lib/services/auth_service.dart
Normal file
264
lib/services/auth_service.dart
Normal 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;
|
||||
}
|
||||
355
lib/services/cos/cos_client.dart
Normal file
355
lib/services/cos/cos_client.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
612
lib/services/device_service.dart
Normal file
612
lib/services/device_service.dart
Normal 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;
|
||||
}
|
||||
259
lib/services/intro_service.dart
Normal file
259
lib/services/intro_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
278
lib/services/marquee_service.dart
Normal file
278
lib/services/marquee_service.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
343
lib/services/marquee_web_source.dart
Normal file
343
lib/services/marquee_web_source.dart
Normal 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,
|
||||
}
|
||||
146
lib/services/media_service.dart
Normal file
146
lib/services/media_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
747
lib/services/org_service.dart
Normal file
747
lib/services/org_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
444
lib/services/sys_service.dart
Normal file
444
lib/services/sys_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
72
lib/services/theme_service.dart
Normal file
72
lib/services/theme_service.dart
Normal 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>[];
|
||||
}
|
||||
}
|
||||
127
lib/services/update_service.dart
Normal file
127
lib/services/update_service.dart
Normal 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();
|
||||
383
lib/services/upload_service.dart
Normal file
383
lib/services/upload_service.dart
Normal 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
161
lib/utils/app_update.dart
Normal 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
30
lib/utils/constants.dart
Normal 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;
|
||||
}
|
||||
134
lib/utils/credential_storage.dart
Normal file
134
lib/utils/credential_storage.dart
Normal 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
59
lib/utils/formatters.dart
Normal 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
666
lib/utils/i10n.dart
Normal 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 }
|
||||
69
lib/utils/permissions.dart
Normal file
69
lib/utils/permissions.dart
Normal 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
55
lib/utils/responsive.dart
Normal 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);
|
||||
}
|
||||
37
lib/utils/session_org_scope.dart
Normal file
37
lib/utils/session_org_scope.dart
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
139
lib/utils/store_local_cache.dart
Normal file
139
lib/utils/store_local_cache.dart
Normal 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
17
lib/utils/uuid.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
107
lib/widgets/app_bottom_nav.dart
Normal file
107
lib/widgets/app_bottom_nav.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/app_bottom_sheet.dart
Normal file
75
lib/widgets/app_bottom_sheet.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
173
lib/widgets/branding_editor.dart
Normal file
173
lib/widgets/branding_editor.dart
Normal 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;
|
||||
}
|
||||
48
lib/widgets/cloud_card.dart
Normal file
48
lib/widgets/cloud_card.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
441
lib/widgets/device_command_sheet.dart
Normal file
441
lib/widgets/device_command_sheet.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/widgets/device_list_tile.dart
Normal file
90
lib/widgets/device_list_tile.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/metric_card.dart
Normal file
75
lib/widgets/metric_card.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
lib/widgets/org_picker_sheet.dart
Normal file
337
lib/widgets/org_picker_sheet.dart
Normal 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
Reference in New Issue
Block a user