ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
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,
|
||||
);
|
||||
Reference in New Issue
Block a user