feat(client): 实时授权状态与会话失效处理
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info - 账号被停用/删除(401 USER_DISABLED)即强制重新登录 - 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回 - 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../config/license_copy.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(
|
||||
token: user?.accessToken,
|
||||
refreshToken: user?.refreshToken,
|
||||
onTokenRefreshed: (newToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
onTokensRefreshed: (accessToken, refreshToken) {
|
||||
ref
|
||||
.read(authStateProvider.notifier)
|
||||
.updateAccessToken(accessToken, refreshToken);
|
||||
},
|
||||
onAuthFailed: (reason) {
|
||||
if (ref.read(authStateProvider).isLoggedIn) {
|
||||
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
onConnectionError: () {
|
||||
ref.read(connectivityProvider.notifier).forceCheck();
|
||||
},
|
||||
onForbidden: (body) {
|
||||
final map = body is Map ? body : const <String, dynamic>{};
|
||||
final code = map['code'];
|
||||
final phase = map['phase'];
|
||||
// 只读角色写被拒:直接提示(无需刷新授权)。
|
||||
if (code == 'READONLY_USER') {
|
||||
ref.read(apiMessageProvider.notifier).state =
|
||||
LicenseCopy.readonlyUserToast;
|
||||
return;
|
||||
}
|
||||
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
|
||||
if (phase == 'readonly' || phase == 'locked') {
|
||||
ref.read(licenseProvider.notifier).refresh().then((_) {
|
||||
final lic = ref.read(licenseProvider).valueOrNull;
|
||||
ref.read(apiMessageProvider.notifier).state = lic != null
|
||||
? LicenseCopy.writeBlockedToast(lic)
|
||||
: '授权已过期,无法执行写操作';
|
||||
});
|
||||
}
|
||||
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
@@ -53,13 +78,28 @@ class ApiClient {
|
||||
late final Dio _dio;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
|
||||
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
|
||||
String? _refreshToken;
|
||||
|
||||
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh,
|
||||
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
|
||||
Future<String?>? _refreshing;
|
||||
|
||||
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
|
||||
void Function(String? reason)? _onAuthFailed;
|
||||
|
||||
ApiClient({
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
|
||||
void Function(String? reason)? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
void Function(dynamic body)? onForbidden,
|
||||
}) {
|
||||
_refreshToken = refreshToken;
|
||||
_onTokensRefreshed = onTokensRefreshed;
|
||||
_onAuthFailed = onAuthFailed;
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
@@ -98,29 +138,37 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
|
||||
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
|
||||
if (e.response?.statusCode == 403) {
|
||||
if (!_disposed) onForbidden?.call(e.response?.data);
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
|
||||
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
|
||||
if (e.response?.statusCode == 401 &&
|
||||
e.response?.data is Map &&
|
||||
e.response?.data['code'] == 'USER_DISABLED') {
|
||||
if (!_disposed) {
|
||||
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
|
||||
}
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
final newToken = await _refreshAccessToken();
|
||||
if (newToken == null) {
|
||||
return handler.next(e);
|
||||
}
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken = resp.data['data']['access_token'] as String;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) onAuthFailed?.call(reason);
|
||||
} catch (retryErr) {
|
||||
return handler.next(retryErr is DioException ? retryErr : e);
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
@@ -131,6 +179,45 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
|
||||
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
|
||||
Future<String?> _refreshAccessToken() {
|
||||
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<String?> _doRefresh() async {
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': _refreshToken,
|
||||
});
|
||||
final data = resp.data['data'] as Map;
|
||||
final newAccess = data['access_token'] as String;
|
||||
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
|
||||
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
|
||||
_refreshToken = newRefresh;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
|
||||
if (!_disposed && newRefresh != null) {
|
||||
_onTokensRefreshed?.call(newAccess, newRefresh);
|
||||
}
|
||||
return newAccess;
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) _onAuthFailed?.call(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
|
||||
@visibleForTesting
|
||||
Dio get dioForTest => _dio;
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -45,6 +45,22 @@ class AuthState {
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier() : super(const AuthState());
|
||||
|
||||
/// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。
|
||||
/// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后
|
||||
/// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。
|
||||
int _sessionGen = 0;
|
||||
|
||||
/// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化
|
||||
/// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。
|
||||
Future<void> _storageQueue = Future.value();
|
||||
|
||||
Future<void> _runStorage(Future<void> Function() op) {
|
||||
final next = _storageQueue.then((_) => op());
|
||||
// 吞掉单次写入异常,避免队列被卡死。
|
||||
_storageQueue = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// Called at app startup to restore persisted session.
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
@@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> login(AuthUser user) async {
|
||||
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, user.accessToken);
|
||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||
await prefs.setString(_kUsername, user.username);
|
||||
await prefs.setString(_kRealName, user.realName);
|
||||
await prefs.setString(_kShopNo, user.shopNo);
|
||||
await prefs.setString(_kShopId, user.shopId.toString());
|
||||
await prefs.setString(_kRole, user.role);
|
||||
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
|
||||
final gen = ++_sessionGen;
|
||||
await _runStorage(() async {
|
||||
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, user.accessToken);
|
||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||
await prefs.setString(_kUsername, user.username);
|
||||
await prefs.setString(_kRealName, user.realName);
|
||||
await prefs.setString(_kShopNo, user.shopNo);
|
||||
await prefs.setString(_kShopId, user.shopId.toString());
|
||||
await prefs.setString(_kRole, user.role);
|
||||
});
|
||||
state = AuthState(initialized: true, user: user);
|
||||
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
|
||||
}
|
||||
|
||||
void updateAccessToken(String newToken) {
|
||||
/// 续期后写回令牌。后端会**轮换 refresh token**(jti 轮换 + 盗用检测),
|
||||
/// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token,
|
||||
/// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。
|
||||
/// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。
|
||||
void updateAccessToken(String newToken, [String? newRefreshToken]) {
|
||||
if (state.user == null) return;
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(_kAccessToken, newToken));
|
||||
final gen = _sessionGen;
|
||||
final refreshToken = newRefreshToken ?? state.user!.refreshToken;
|
||||
// fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入,
|
||||
// 避免把已失效的 token 写回 SharedPreferences。
|
||||
_runStorage(() async {
|
||||
if (gen != _sessionGen) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, newToken);
|
||||
await prefs.setString(_kRefreshToken, refreshToken);
|
||||
});
|
||||
state = AuthState(
|
||||
initialized: true,
|
||||
user: AuthUser(
|
||||
accessToken: newToken,
|
||||
refreshToken: state.user!.refreshToken,
|
||||
refreshToken: refreshToken,
|
||||
username: state.user!.username,
|
||||
realName: state.user!.realName,
|
||||
shopNo: state.user!.shopNo,
|
||||
@@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> logout() async {
|
||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
final token = state.user?.accessToken;
|
||||
// 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。
|
||||
_sessionGen++;
|
||||
state = const AuthState(initialized: true);
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
if (token != null && token.isNotEmpty) {
|
||||
try {
|
||||
await Dio(BaseOptions(
|
||||
@@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// ignore: 后端不可达或会话已失效都无所谓
|
||||
}
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUsername);
|
||||
await prefs.remove(_kRealName);
|
||||
await prefs.remove(_kShopNo);
|
||||
await prefs.remove(_kShopId);
|
||||
await prefs.remove(_kRole);
|
||||
state = const AuthState(initialized: true);
|
||||
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
|
||||
await _runStorage(() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUsername);
|
||||
await prefs.remove(_kRealName);
|
||||
await prefs.remove(_kShopNo);
|
||||
await prefs.remove(_kShopId);
|
||||
await prefs.remove(_kRole);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
||||
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
|
||||
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
|
||||
final apiMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/license.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// 授权阶段文案 + 阈值的集中配置。
|
||||
///
|
||||
/// 所有授权相关提示(顶部横幅、到期弹窗、设置页状态、底部状态栏、过期说明)
|
||||
/// 统一在此维护,组件层不再硬编码文案与天数阈值。
|
||||
///
|
||||
/// 阈值须与后端 `backend/internal/middleware/license_guard.go` 保持一致:
|
||||
/// 宽限期 0–[graceDays] 天(仍可写)→ 只读 [graceDays]–[readonlyDays] 天 → 锁定 [readonlyDays] 天+。
|
||||
class LicenseCopy {
|
||||
LicenseCopy._();
|
||||
|
||||
/// 宽限期上限(天):过期 ≤ 此值仍可正常写入。
|
||||
static const int graceDays = 7;
|
||||
|
||||
/// 只读期上限(天):过期 ≤ 此值进入只读;超过则锁定登录。
|
||||
static const int readonlyDays = 15;
|
||||
|
||||
/// 顶部横幅文案(仅 grace/readonly/locked 显示;normal 返回空串)。
|
||||
static String banner(LicenseInfo lic) {
|
||||
final d = lic.daysExpired;
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '授权已过期 $d 天,请及时续费,否则将影响您的正常使用';
|
||||
case 'readonly':
|
||||
return '授权已过期 $d 天,已进入只读模式,暂时无法新增或修改数据;'
|
||||
'过期满 $readonlyDays 天后将彻底锁定,请尽快续费';
|
||||
case 'locked':
|
||||
return '授权已过期 $d 天并已锁定,所有功能已停用,请立即续费或激活新授权码';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页「授权状态」行文案。
|
||||
static String statusText(LicenseInfo lic) {
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '宽限期 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'readonly':
|
||||
return '只读模式 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'locked':
|
||||
return '已锁定 · 已过期 ${lic.daysExpired} 天';
|
||||
default:
|
||||
return lic.expiresAt == null
|
||||
? '正常(永久授权)'
|
||||
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部状态栏紧凑文案。[expiryDate] 为已格式化的到期日(yyyy-MM-dd)。
|
||||
static String statusBar(LicenseInfo lic, String expiryDate) {
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '授权宽限期 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'readonly':
|
||||
return '只读模式 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'locked':
|
||||
return '已锁定 · 已过期 ${lic.daysExpired} 天';
|
||||
default:
|
||||
return lic.expiresAt == null ? '永久授权' : '授权正常 · 到期 $expiryDate';
|
||||
}
|
||||
}
|
||||
|
||||
/// 到期提醒弹窗的(标题, 正文)。
|
||||
static (String title, String body) dialog(LicenseInfo lic) {
|
||||
final d = lic.daysExpired;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
return (
|
||||
'授权已锁定',
|
||||
'您的授权已过期 $d 天(超过 $readonlyDays 天),所有功能已停用。\n'
|
||||
'请前往「设置 → 授权」激活新的授权码,或联系客服续费。',
|
||||
);
|
||||
case 'readonly':
|
||||
return (
|
||||
'授权已过期 · 只读模式',
|
||||
'您的授权已过期 $d 天,系统进入只读模式,无法执行任何写操作。\n'
|
||||
'过期满 $readonlyDays 天后将彻底锁定登录,请尽快续费。',
|
||||
);
|
||||
default: // grace
|
||||
return (
|
||||
'授权已过期',
|
||||
'您的授权已过期 $d 天,目前仍可正常使用(宽限期 $graceDays 天)。\n'
|
||||
'过期超过 $graceDays 天将进入只读模式,请尽快续费以免影响使用。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读账号尝试写操作被后端拒绝(403 code=READONLY_USER)时的提示。
|
||||
static const String readonlyUserToast = '当前为只读账号,仅可查看数据,无法新增或修改';
|
||||
|
||||
/// 写按钮被授权过期禁用时,点击弹出的提示文案。
|
||||
static String writeBlockedToast(LicenseInfo lic) {
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
return '授权已过期 ${lic.daysExpired} 天并已锁定,无法操作,请前往「设置 → 授权」续费或激活';
|
||||
default: // readonly
|
||||
return '授权已过期 ${lic.daysExpired} 天,已进入只读模式,续费后可继续操作(设置 → 授权)';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 阶段视觉(颜色 / 图标)集中映射 ----
|
||||
// 顶部横幅、底部状态条、设置页状态三处此前各自硬编码 phase→色,readonly 取色还分叉
|
||||
// (横幅 0xFFB71C1C vs 状态条 0xFFEF5350)。这里按「角色」给出单一来源:
|
||||
// - [bannerColor]:顶部横幅**实底背景**(白字承其上),仅 grace/readonly/locked 显示。
|
||||
// - [phaseColor] :前景强调色(底部状态条图标+文字、设置页状态文字共用一套)。
|
||||
// - [phaseIcon] :状态图标(状态条用)。
|
||||
|
||||
/// 顶部横幅实底背景色(深色调,承载白字)。normal 不显示横幅,返回透明。
|
||||
static Color bannerColor(String phase) {
|
||||
switch (phase) {
|
||||
case 'locked':
|
||||
return AppTheme.danger;
|
||||
case 'readonly':
|
||||
return const Color(0xFFB71C1C); // 深红,强调即将彻底锁定
|
||||
case 'grace':
|
||||
return const Color(0xFFE65100); // 深橙
|
||||
default:
|
||||
return Colors.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/// 前景强调色:normal=正常绿,grace=警告橙,readonly/locked=危险红。
|
||||
static Color phaseColor(String phase) {
|
||||
switch (phase) {
|
||||
case 'grace':
|
||||
return AppTheme.warning500;
|
||||
case 'readonly':
|
||||
case 'locked':
|
||||
return AppTheme.danger;
|
||||
default:
|
||||
return AppTheme.success;
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态图标:normal=已验证,grace=警告,readonly/locked=错误。
|
||||
static IconData phaseIcon(String phase) {
|
||||
switch (phase) {
|
||||
case 'grace':
|
||||
return Icons.warning_amber_rounded;
|
||||
case 'readonly':
|
||||
case 'locked':
|
||||
return Icons.error_outline;
|
||||
default:
|
||||
return Icons.verified_user_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
/// 过期降级说明(设置页固定展示),随阈值自动联动。
|
||||
static List<String> degradationNotes() => [
|
||||
'过期 $graceDays 天内(宽限期):仍可正常使用,请尽快续费',
|
||||
'过期 $graceDays–$readonlyDays 天(只读期):仅能查看数据,无法新增或修改',
|
||||
'过期满 $readonlyDays 天:系统锁定,无法登录,需续费或激活新授权码后恢复',
|
||||
];
|
||||
}
|
||||
@@ -142,7 +142,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
productId: int.parse(state.pathParameters['id']!)))),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
||||
pageBuilder: (_, state) {
|
||||
const tabIndex = {
|
||||
'shop': 0,
|
||||
'users': 1,
|
||||
'number': 2,
|
||||
'system': 3,
|
||||
'license': 4,
|
||||
'import': 5,
|
||||
};
|
||||
final tab =
|
||||
tabIndex[state.uri.queryParameters['tab']] ?? 0;
|
||||
return _noTransition(SettingsScreen(initialTab: tab));
|
||||
}),
|
||||
GoRoute(
|
||||
path: '/devices',
|
||||
pageBuilder: (_, __) =>
|
||||
|
||||
Reference in New Issue
Block a user