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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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