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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../auth/auth_state.dart';
|
import '../auth/auth_state.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../config/license_copy.dart';
|
||||||
import '../errors/error_reporter.dart';
|
import '../errors/error_reporter.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
|
import '../../providers/license_provider.dart';
|
||||||
import 'retry_interceptor.dart';
|
import 'retry_interceptor.dart';
|
||||||
|
|
||||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||||
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
|||||||
final client = ApiClient(
|
final client = ApiClient(
|
||||||
token: user?.accessToken,
|
token: user?.accessToken,
|
||||||
refreshToken: user?.refreshToken,
|
refreshToken: user?.refreshToken,
|
||||||
onTokenRefreshed: (newToken) {
|
onTokensRefreshed: (accessToken, refreshToken) {
|
||||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
ref
|
||||||
|
.read(authStateProvider.notifier)
|
||||||
|
.updateAccessToken(accessToken, refreshToken);
|
||||||
},
|
},
|
||||||
onAuthFailed: (reason) {
|
onAuthFailed: (reason) {
|
||||||
if (ref.read(authStateProvider).isLoggedIn) {
|
if (ref.read(authStateProvider).isLoggedIn) {
|
||||||
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
|||||||
onConnectionError: () {
|
onConnectionError: () {
|
||||||
ref.read(connectivityProvider.notifier).forceCheck();
|
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);
|
ref.onDispose(client.dispose);
|
||||||
return client;
|
return client;
|
||||||
@@ -53,13 +78,28 @@ class ApiClient {
|
|||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
bool _disposed = false;
|
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({
|
ApiClient({
|
||||||
String? token,
|
String? token,
|
||||||
String? refreshToken,
|
String? refreshToken,
|
||||||
void Function(String newToken)? onTokenRefreshed,
|
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
|
||||||
void Function(String? reason)? onAuthFailed,
|
void Function(String? reason)? onAuthFailed,
|
||||||
void Function()? onConnectionError,
|
void Function()? onConnectionError,
|
||||||
|
void Function(dynamic body)? onForbidden,
|
||||||
}) {
|
}) {
|
||||||
|
_refreshToken = refreshToken;
|
||||||
|
_onTokensRefreshed = onTokensRefreshed;
|
||||||
|
_onAuthFailed = onAuthFailed;
|
||||||
_dio = Dio(BaseOptions(
|
_dio = Dio(BaseOptions(
|
||||||
baseUrl: AppConfig.apiBaseUrl,
|
baseUrl: AppConfig.apiBaseUrl,
|
||||||
connectTimeout: const Duration(seconds: 8),
|
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...');
|
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||||
|
final newToken = await _refreshAccessToken();
|
||||||
|
if (newToken == null) {
|
||||||
|
return handler.next(e);
|
||||||
|
}
|
||||||
try {
|
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;
|
final opts = e.requestOptions;
|
||||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||||
final retryResp = await _dio.fetch(opts);
|
final retryResp = await _dio.fetch(opts);
|
||||||
return handler.resolve(retryResp);
|
return handler.resolve(retryResp);
|
||||||
} catch (refreshErr) {
|
} catch (retryErr) {
|
||||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
return handler.next(retryErr is DioException ? retryErr : e);
|
||||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
|
||||||
String? reason;
|
|
||||||
if (refreshErr is DioException &&
|
|
||||||
refreshErr.response?.data is Map &&
|
|
||||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
|
||||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
|
||||||
}
|
|
||||||
if (!_disposed) onAuthFailed?.call(reason);
|
|
||||||
}
|
}
|
||||||
} else if (e.response?.statusCode == 401) {
|
} else if (e.response?.statusCode == 401) {
|
||||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
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() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ class AuthState {
|
|||||||
class AuthNotifier extends StateNotifier<AuthState> {
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
AuthNotifier() : super(const 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.
|
/// Called at app startup to restore persisted session.
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
try {
|
try {
|
||||||
@@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
|
|
||||||
Future<void> login(AuthUser user) async {
|
Future<void> login(AuthUser user) async {
|
||||||
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
|
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final gen = ++_sessionGen;
|
||||||
await prefs.setString(_kAccessToken, user.accessToken);
|
await _runStorage(() async {
|
||||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
|
||||||
await prefs.setString(_kUsername, user.username);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_kRealName, user.realName);
|
await prefs.setString(_kAccessToken, user.accessToken);
|
||||||
await prefs.setString(_kShopNo, user.shopNo);
|
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||||
await prefs.setString(_kShopId, user.shopId.toString());
|
await prefs.setString(_kUsername, user.username);
|
||||||
await prefs.setString(_kRole, user.role);
|
await prefs.setString(_kRealName, user.realName);
|
||||||
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
|
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);
|
state = AuthState(initialized: true, user: user);
|
||||||
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
|
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;
|
if (state.user == null) return;
|
||||||
SharedPreferences.getInstance()
|
final gen = _sessionGen;
|
||||||
.then((prefs) => prefs.setString(_kAccessToken, newToken));
|
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(
|
state = AuthState(
|
||||||
initialized: true,
|
initialized: true,
|
||||||
user: AuthUser(
|
user: AuthUser(
|
||||||
accessToken: newToken,
|
accessToken: newToken,
|
||||||
refreshToken: state.user!.refreshToken,
|
refreshToken: refreshToken,
|
||||||
username: state.user!.username,
|
username: state.user!.username,
|
||||||
realName: state.user!.realName,
|
realName: state.user!.realName,
|
||||||
shopNo: state.user!.shopNo,
|
shopNo: state.user!.shopNo,
|
||||||
@@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
|
||||||
final token = state.user?.accessToken;
|
final token = state.user?.accessToken;
|
||||||
|
// 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。
|
||||||
|
_sessionGen++;
|
||||||
|
state = const AuthState(initialized: true);
|
||||||
|
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
await Dio(BaseOptions(
|
await Dio(BaseOptions(
|
||||||
@@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
// ignore: 后端不可达或会话已失效都无所谓
|
// ignore: 后端不可达或会话已失效都无所谓
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
|
||||||
await prefs.remove(_kAccessToken);
|
await _runStorage(() async {
|
||||||
await prefs.remove(_kRefreshToken);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_kUsername);
|
await prefs.remove(_kAccessToken);
|
||||||
await prefs.remove(_kRealName);
|
await prefs.remove(_kRefreshToken);
|
||||||
await prefs.remove(_kShopNo);
|
await prefs.remove(_kUsername);
|
||||||
await prefs.remove(_kShopId);
|
await prefs.remove(_kRealName);
|
||||||
await prefs.remove(_kRole);
|
await prefs.remove(_kShopNo);
|
||||||
state = const AuthState(initialized: true);
|
await prefs.remove(_kShopId);
|
||||||
|
await prefs.remove(_kRole);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
|||||||
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
||||||
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
|
||||||
|
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
|
||||||
|
final apiMessageProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
/// 后端亦有 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']!)))),
|
productId: int.parse(state.pathParameters['id']!)))),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
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(
|
GoRoute(
|
||||||
path: '/devices',
|
path: '/devices',
|
||||||
pageBuilder: (_, __) =>
|
pageBuilder: (_, __) =>
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ class LicenseInfo {
|
|||||||
return diff < 0 ? 0 : diff;
|
return diff < 0 ? 0 : diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 已过期天数(未过期或永久授权返回 0)。
|
||||||
|
int get daysExpired {
|
||||||
|
if (expiresAt == null) return 0;
|
||||||
|
final diff = DateTime.now().difference(expiresAt!).inDays;
|
||||||
|
return diff < 0 ? 0 : diff;
|
||||||
|
}
|
||||||
|
|
||||||
bool get isReadOnlyPhase => phase == 'readonly' || phase == 'locked';
|
bool get isReadOnlyPhase => phase == 'readonly' || phase == 'locked';
|
||||||
bool get isLockedPhase => phase == 'locked';
|
bool get isLockedPhase => phase == 'locked';
|
||||||
bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
||||||
|
|||||||
@@ -32,4 +32,27 @@ class LicenseNotifier extends AsyncNotifier<LicenseInfo?> {
|
|||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
state = AsyncValue.data(await _fetch());
|
state = AsyncValue.data(await _fetch());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 后台静默刷新(供心跳调用):不闪 loading;
|
||||||
|
/// 仅在成功拿到结果时更新(含「确无授权」的 null),
|
||||||
|
/// 网络/瞬时错误则保留上次状态,避免把已有授权误刷成「未激活」。
|
||||||
|
/// 会话被撤销(401)由 ApiClient 拦截器统一处理(触发登出),与此无关。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final info = await ref.read(licenseRepositoryProvider).getInfo();
|
||||||
|
state = AsyncValue.data(info);
|
||||||
|
} catch (_) {
|
||||||
|
// 保留上次状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 直接采纳心跳 /auth/ping 回带的授权概况,免去单独再请求 /license/info。
|
||||||
|
/// [raw] 为后端 LicenseInfoView 的 JSON(与 /license/info 同构);
|
||||||
|
/// 为 null 表示「确无有效授权」,与 reload/refresh 拿到 null 语义一致。
|
||||||
|
void applyServerInfo(dynamic raw) {
|
||||||
|
final info = raw == null
|
||||||
|
? null
|
||||||
|
: LicenseInfo.fromJson(Map<String, dynamic>.from(raw as Map));
|
||||||
|
state = AsyncValue.data(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../core/api/api_client.dart';
|
import '../core/api/api_client.dart';
|
||||||
import '../core/auth/auth_state.dart';
|
import '../core/auth/auth_state.dart';
|
||||||
|
import 'license_provider.dart';
|
||||||
|
|
||||||
/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。
|
/// 登录态心跳:已登录时每 30s 执行一次 **单个** POST /auth/ping:
|
||||||
/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
/// - 会话/在线检查:若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
||||||
/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout,
|
/// ApiClient 拦截器触发 refresh→失败→onAuthFailed→logout,因此空闲用户也能在
|
||||||
/// 因此空闲用户也能在 ~30s 内感知到被下线。
|
/// ~30s 内感知到被下线;同时刷新 last_seen_at(在线状态)。
|
||||||
|
/// - 授权状态检查:ping 响应**回带**当前授权概况(license 字段,与 /license/info 同构),
|
||||||
|
/// 直接喂给 licenseProvider,使到期/续费/被改动等变化在 ~30s 内反映到横幅/状态栏/设置页。
|
||||||
|
/// 如此一次心跳即覆盖两件事,**无需**再单独轮询 /license/info(每 30s 少一次请求)。
|
||||||
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
|
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
|
||||||
final hb = SessionHeartbeat(ref);
|
final hb = SessionHeartbeat(ref);
|
||||||
ref.onDispose(hb.dispose);
|
ref.onDispose(hb.dispose);
|
||||||
@@ -28,10 +32,18 @@ class SessionHeartbeat {
|
|||||||
|
|
||||||
Future<void> _ping() async {
|
Future<void> _ping() async {
|
||||||
if (!_ref.read(authStateProvider).isLoggedIn) return;
|
if (!_ref.read(authStateProvider).isLoggedIn) return;
|
||||||
|
// 一次 ping 同时完成会话/在线检查与授权概况刷新。
|
||||||
try {
|
try {
|
||||||
await _ref.read(apiClientProvider).post('/auth/ping');
|
final resp = await _ref.read(apiClientProvider).post('/auth/ping');
|
||||||
|
// ping 可能已触发登出,故重新判断登录态后再采纳授权概况。
|
||||||
|
final data = resp.data is Map ? resp.data['data'] : null;
|
||||||
|
if (data is Map &&
|
||||||
|
data.containsKey('license') &&
|
||||||
|
_ref.read(authStateProvider).isLoggedIn) {
|
||||||
|
_ref.read(licenseProvider.notifier).applyServerInfo(data['license']);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略
|
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略,保留上次授权状态
|
||||||
debugPrint('[Heartbeat] ping failed: $e');
|
debugPrint('[Heartbeat] ping failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,10 +169,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
],
|
],
|
||||||
actions: (canClose && !WriteGuard.isReadonly(ref))
|
actions: (canClose && !WriteGuard.isReadonly(ref))
|
||||||
? [
|
? [
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: () => _closeRecord(r),
|
child: TextButton(
|
||||||
child: const Text('结清',
|
onPressed: () => _closeRecord(r),
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
child: const Text('结清',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: null,
|
: null,
|
||||||
@@ -318,10 +320,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
if ((r.type == 'payable' || r.type == 'receivable') &&
|
if ((r.type == 'payable' || r.type == 'receivable') &&
|
||||||
r.status == 'open' &&
|
r.status == 'open' &&
|
||||||
!WriteGuard.isReadonly(ref)) {
|
!WriteGuard.isReadonly(ref)) {
|
||||||
return DataCell(TextButton(
|
return DataCell(WriteGuard(
|
||||||
onPressed: () => _closeRecord(r),
|
child: TextButton(
|
||||||
child: const Text('结清',
|
onPressed: () => _closeRecord(r),
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
child: const Text('结清',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return const DataCell(SizedBox());
|
return const DataCell(SizedBox());
|
||||||
@@ -419,10 +423,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
toolbar: Row(
|
toolbar: Row(
|
||||||
children: [
|
children: [
|
||||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||||
ElevatedButton.icon(
|
WriteGuard(
|
||||||
onPressed: _showAddDialog,
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
onPressed: _showAddDialog,
|
||||||
label: Text(addLabel),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: Text(addLabel),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件),
|
||||||
|
/// 否则纯文本(只读角色占位)。
|
||||||
|
Widget _remarkDisplay(Inventory item, {required bool editable}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.remark.isEmpty
|
||||||
|
? '—'
|
||||||
|
: item.remark.length > 4
|
||||||
|
? '${item.remark.substring(0, 4)}…'
|
||||||
|
: item.remark,
|
||||||
|
style: TextStyle(
|
||||||
|
color: item.remark.isEmpty
|
||||||
|
? AppTheme.textSecondary
|
||||||
|
: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editable) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.edit_outlined,
|
||||||
|
size: 12, color: AppTheme.textSecondary),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||||
final ctrl = TextEditingController(text: item.remark);
|
final ctrl = TextEditingController(text: item.remark);
|
||||||
final saved = await showDialog<String>(
|
final saved = await showDialog<String>(
|
||||||
@@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
'remark' => DataCell(Tooltip(
|
'remark' => DataCell(Tooltip(
|
||||||
message: item.remark.isEmpty ? '' : item.remark,
|
message: item.remark.isEmpty ? '' : item.remark,
|
||||||
waitDuration: const Duration(milliseconds: 300),
|
waitDuration: const Duration(milliseconds: 300),
|
||||||
child: GestureDetector(
|
// 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标),
|
||||||
onTap: WriteGuard.isReadonly(ref)
|
// 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。
|
||||||
? null
|
child: WriteGuard(
|
||||||
: () => _editRemark(context, item),
|
placeholder: _remarkDisplay(item, editable: false),
|
||||||
child: Row(
|
child: GestureDetector(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onTap: () => _editRemark(context, item),
|
||||||
children: [
|
child: _remarkDisplay(item, editable: true),
|
||||||
Text(
|
|
||||||
item.remark.isEmpty
|
|
||||||
? '—'
|
|
||||||
: item.remark.length > 4
|
|
||||||
? '${item.remark.substring(0, 4)}…'
|
|
||||||
: item.remark,
|
|
||||||
style: TextStyle(
|
|
||||||
color: item.remark.isEmpty
|
|
||||||
? AppTheme.textSecondary
|
|
||||||
: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
))),
|
))),
|
||||||
'status' => DataCell(_InventoryStatusBadge(item)),
|
'status' => DataCell(_InventoryStatusBadge(item)),
|
||||||
@@ -374,9 +386,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
if (!WriteGuard.isReadonly(ref))
|
if (!WriteGuard.isReadonly(ref))
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: () => _editRemark(context, item),
|
child: TextButton(
|
||||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
onPressed: () => _editRemark(context, item),
|
||||||
|
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (item.productId != null)
|
if (item.productId != null)
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -622,16 +636,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (canCheck)
|
if (canCheck)
|
||||||
OutlinedButton.icon(
|
WriteGuard(
|
||||||
onPressed: () => context.go('/inventory/check'),
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.fact_check, size: 16),
|
onPressed: () =>
|
||||||
label: const Text('盘点'),
|
context.go('/inventory/check'),
|
||||||
style: OutlinedButton.styleFrom(
|
icon: const Icon(Icons.fact_check, size: 16),
|
||||||
padding: const EdgeInsets.symmetric(
|
label: const Text('盘点'),
|
||||||
horizontal: 10, vertical: 6),
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: Size.zero,
|
padding: const EdgeInsets.symmetric(
|
||||||
tapTargetSize:
|
horizontal: 10, vertical: 6),
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -671,10 +688,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
SizedBox(width: 220, child: searchField),
|
SizedBox(width: 220, child: searchField),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (canCheck) ...[
|
if (canCheck) ...[
|
||||||
OutlinedButton.icon(
|
WriteGuard(
|
||||||
onPressed: () => context.go('/inventory/check'),
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.fact_check, size: 16),
|
onPressed: () => context.go('/inventory/check'),
|
||||||
label: const Text('发起盘点'),
|
icon: const Icon(Icons.fact_check, size: 16),
|
||||||
|
label: const Text('发起盘点'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -170,16 +170,20 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
|||||||
actions: WriteGuard.isReadonly(ref)
|
actions: WriteGuard.isReadonly(ref)
|
||||||
? const []
|
? const []
|
||||||
: [
|
: [
|
||||||
TextButton(
|
WriteGuard(
|
||||||
key: Key('btn_edit_${p.id}'),
|
child: TextButton(
|
||||||
onPressed: () => onEdit(p),
|
key: Key('btn_edit_${p.id}'),
|
||||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
onPressed: () => onEdit(p),
|
||||||
|
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
WriteGuard(
|
||||||
key: Key('btn_delete_${p.id}'),
|
child: TextButton(
|
||||||
onPressed: () => onDelete(p),
|
key: Key('btn_delete_${p.id}'),
|
||||||
child: const Text('删除',
|
onPressed: () => onDelete(p),
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
child: const Text('删除',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -195,10 +199,12 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
|||||||
toolbar: Row(
|
toolbar: Row(
|
||||||
children: [
|
children: [
|
||||||
if (!WriteGuard.isReadonly(ref)) ...[
|
if (!WriteGuard.isReadonly(ref)) ...[
|
||||||
ElevatedButton.icon(
|
WriteGuard(
|
||||||
onPressed: onAdd,
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
onPressed: onAdd,
|
||||||
label: Text(isSupplier ? '新建' : '新建'),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: Text(isSupplier ? '新建' : '新建'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
@@ -288,19 +294,23 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
|||||||
children: WriteGuard.isReadonly(ref)
|
children: WriteGuard.isReadonly(ref)
|
||||||
? const []
|
? const []
|
||||||
: [
|
: [
|
||||||
TextButton(
|
WriteGuard(
|
||||||
key: Key('btn_edit_${p.id}'),
|
child: TextButton(
|
||||||
onPressed: () => onEdit(p),
|
key: Key('btn_edit_${p.id}'),
|
||||||
child: const Text('编辑',
|
onPressed: () => onEdit(p),
|
||||||
style: TextStyle(fontSize: 12)),
|
child: const Text('编辑',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
WriteGuard(
|
||||||
key: Key('btn_delete_${p.id}'),
|
child: TextButton(
|
||||||
onPressed: () => onDelete(p),
|
key: Key('btn_delete_${p.id}'),
|
||||||
child: const Text('删除',
|
onPressed: () => onDelete(p),
|
||||||
style: TextStyle(
|
child: const Text('删除',
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
color: AppTheme.danger)),
|
fontSize: 12,
|
||||||
|
color: AppTheme.danger)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -358,10 +358,12 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (!WriteGuard.isReadonly(ref)) ...[
|
if (!WriteGuard.isReadonly(ref)) ...[
|
||||||
ElevatedButton.icon(
|
WriteGuard(
|
||||||
onPressed: onAdd,
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
onPressed: onAdd,
|
||||||
label: const Text('新建'),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('新建'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
@@ -422,14 +424,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
|||||||
actions: WriteGuard.isReadonly(ref)
|
actions: WriteGuard.isReadonly(ref)
|
||||||
? const []
|
? const []
|
||||||
: [
|
: [
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: onEdit,
|
child: TextButton(
|
||||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
onPressed: onEdit,
|
||||||
|
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: onDelete,
|
child: TextButton(
|
||||||
child: const Text('删除',
|
onPressed: onDelete,
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
child: const Text('删除',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ import '../../core/auth/auth_state.dart';
|
|||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_info.dart';
|
import '../../core/config/app_info.dart';
|
||||||
|
import '../../core/config/license_copy.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/number_rule.dart';
|
import '../../models/number_rule.dart';
|
||||||
import '../../models/user.dart';
|
import '../../models/user.dart';
|
||||||
import '../../providers/license_provider.dart';
|
import '../../providers/license_provider.dart';
|
||||||
import '../../repositories/license_repository.dart';
|
|
||||||
import '../../providers/number_rule_provider.dart';
|
import '../../providers/number_rule_provider.dart';
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../models/shop.dart';
|
import '../../models/shop.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerStatefulWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
/// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。
|
||||||
|
final int initialTab;
|
||||||
|
const SettingsScreen({super.key, this.initialTab = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
@@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 6,
|
length: 6,
|
||||||
|
initialIndex: widget.initialTab,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
children: WriteGuard.isReadonly(ref)
|
children: WriteGuard.isReadonly(ref)
|
||||||
? const []
|
? const []
|
||||||
: [
|
: [
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: () =>
|
child: TextButton(
|
||||||
_showEditUserDialog(context, u),
|
onPressed: () =>
|
||||||
child: const Text('编辑',
|
_showEditUserDialog(context, u),
|
||||||
style: TextStyle(fontSize: 12)),
|
child: const Text('编辑',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
WriteGuard(
|
||||||
onPressed: () =>
|
child: TextButton(
|
||||||
_showResetPasswordDialog(context, u),
|
onPressed: () =>
|
||||||
child: const Text('重置密码',
|
_showResetPasswordDialog(context, u),
|
||||||
style: TextStyle(fontSize: 12)),
|
child: const Text('重置密码',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
@@ -326,6 +333,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
// 过期降级说明
|
||||||
|
_buildExpiryNotes(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
// 激活码输入区
|
// 激活码输入区
|
||||||
_buildActivationCard(),
|
_buildActivationCard(),
|
||||||
],
|
],
|
||||||
@@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color phaseColor;
|
final Color phaseColor = LicenseCopy.phaseColor(lic.phase);
|
||||||
String phaseText;
|
final String phaseText = LicenseCopy.statusText(lic);
|
||||||
switch (lic.phase) {
|
|
||||||
case 'grace':
|
|
||||||
phaseColor = Colors.orange;
|
|
||||||
phaseText = '宽限期(剩余 ${lic.daysRemaining ?? 0} 天到期)';
|
|
||||||
case 'readonly':
|
|
||||||
phaseColor = AppTheme.danger;
|
|
||||||
phaseText = '已过期 · 只读模式';
|
|
||||||
case 'locked':
|
|
||||||
phaseColor = AppTheme.danger;
|
|
||||||
phaseText = '已锁定 · 请立即续费';
|
|
||||||
default:
|
|
||||||
phaseColor = AppTheme.success;
|
|
||||||
phaseText = lic.expiresAt == null
|
|
||||||
? '正常(永久授权)'
|
|
||||||
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -407,6 +401,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildExpiryNotes() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.info_outline,
|
||||||
|
size: 16, color: AppTheme.textSecondary),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text('过期说明',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('授权到期后会分阶段降级,请在到期前及时续费',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (final note in LicenseCopy.degradationNotes())
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 6, right: 8),
|
||||||
|
child: Icon(Icons.circle,
|
||||||
|
size: 5, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(note,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, height: 1.5)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildActivationCard() {
|
Widget _buildActivationCard() {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
style: TextStyle(color: AppTheme.textSecondary)),
|
style: TextStyle(color: AppTheme.textSecondary)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (WriteGuard.licenseBlocked(ref)) {
|
||||||
|
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final currentUser = ref.watch(authStateProvider).user;
|
final currentUser = ref.watch(authStateProvider).user;
|
||||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
|
import '../../core/config/license_copy.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
@@ -170,6 +171,14 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
final user = ref.watch(authStateProvider).user;
|
final user = ref.watch(authStateProvider).user;
|
||||||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||||||
ref.watch(sessionHeartbeatProvider);
|
ref.watch(sessionHeartbeatProvider);
|
||||||
|
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
|
||||||
|
ref.listen<String?>(apiMessageProvider, (prev, next) {
|
||||||
|
if (next == null || next.isEmpty) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..clearSnackBars()
|
||||||
|
..showSnackBar(SnackBar(content: Text(next)));
|
||||||
|
ref.read(apiMessageProvider.notifier).state = null;
|
||||||
|
});
|
||||||
final isOnline = ref.watch(connectivityProvider);
|
final isOnline = ref.watch(connectivityProvider);
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
final isMobile = context.isMobile;
|
final isMobile = context.isMobile;
|
||||||
@@ -538,6 +547,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
.valueOrNull ??
|
.valueOrNull ??
|
||||||
'v1.0.0',
|
'v1.0.0',
|
||||||
iconOnly: iconOnly),
|
iconOnly: iconOnly),
|
||||||
|
_LicenseStatusItem(
|
||||||
|
lic: licenseInfo, iconOnly: iconOnly),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -556,19 +567,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLicenseBanner(LicenseInfo lic) {
|
Widget _buildLicenseBanner(LicenseInfo lic) {
|
||||||
final Color bg;
|
final bg = LicenseCopy.bannerColor(lic.phase);
|
||||||
final String msg;
|
final msg = LicenseCopy.banner(lic);
|
||||||
switch (lic.phase) {
|
|
||||||
case 'locked':
|
|
||||||
bg = AppTheme.danger;
|
|
||||||
msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码';
|
|
||||||
case 'readonly':
|
|
||||||
bg = const Color(0xFFB71C1C);
|
|
||||||
msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)';
|
|
||||||
default: // grace
|
|
||||||
bg = const Color(0xFFE65100);
|
|
||||||
msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费';
|
|
||||||
}
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: bg,
|
color: bg,
|
||||||
@@ -584,7 +584,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/settings'),
|
onPressed: () => context.go('/settings?tab=license'),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.white70),
|
style: TextButton.styleFrom(foregroundColor: Colors.white70),
|
||||||
child: const Text('去激活'),
|
child: const Text('去激活'),
|
||||||
),
|
),
|
||||||
@@ -594,23 +594,9 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
||||||
final String title;
|
final (title, body) = LicenseCopy.dialog(lic);
|
||||||
final String body;
|
final Color titleColor =
|
||||||
final Color titleColor;
|
lic.phase == 'grace' ? Colors.orange[800]! : AppTheme.danger;
|
||||||
switch (lic.phase) {
|
|
||||||
case 'locked':
|
|
||||||
title = '授权已锁定';
|
|
||||||
body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。';
|
|
||||||
titleColor = AppTheme.danger;
|
|
||||||
case 'readonly':
|
|
||||||
title = '授权已过期 · 只读模式';
|
|
||||||
body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。';
|
|
||||||
titleColor = AppTheme.danger;
|
|
||||||
default: // grace
|
|
||||||
title = '授权即将到期';
|
|
||||||
body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。';
|
|
||||||
titleColor = Colors.orange[800]!;
|
|
||||||
}
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: ctx,
|
context: ctx,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
@@ -630,7 +616,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
|
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(_);
|
Navigator.pop(_);
|
||||||
ctx.go('/settings');
|
ctx.go('/settings?tab=license');
|
||||||
},
|
},
|
||||||
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
|
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
|
||||||
),
|
),
|
||||||
@@ -743,6 +729,43 @@ class _StatusItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。
|
||||||
|
/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。
|
||||||
|
/// 对应后端 phase:normal / grace / readonly|locked。
|
||||||
|
class _LicenseStatusItem extends StatelessWidget {
|
||||||
|
final LicenseInfo? lic;
|
||||||
|
final bool iconOnly;
|
||||||
|
const _LicenseStatusItem({required this.lic, this.iconOnly = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final lic = this.lic;
|
||||||
|
if (lic == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final color = LicenseCopy.phaseColor(lic.phase);
|
||||||
|
final icon = LicenseCopy.phaseIcon(lic.phase);
|
||||||
|
|
||||||
|
final String date = lic.expiresAt == null
|
||||||
|
? ''
|
||||||
|
: DateFormat('yyyy-MM-dd').format(lic.expiresAt!);
|
||||||
|
final String text = LicenseCopy.statusBar(lic, date);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const _StatusDivider(),
|
||||||
|
Icon(icon, size: 11, color: color),
|
||||||
|
if (!iconOnly) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _StatusDivider extends StatelessWidget {
|
class _StatusDivider extends StatelessWidget {
|
||||||
const _StatusDivider();
|
const _StatusDivider();
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../../repositories/product_repository.dart';
|
|||||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
|
||||||
class StockInListScreen extends ConsumerStatefulWidget {
|
class StockInListScreen extends ConsumerStatefulWidget {
|
||||||
const StockInListScreen({super.key});
|
const StockInListScreen({super.key});
|
||||||
@@ -306,18 +307,20 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||||
? ElevatedButton.icon(
|
? WriteGuard(
|
||||||
onPressed: () => context.go('/stock-in/new'),
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
onPressed: () => context.go('/stock-in/new'),
|
||||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
style: isMobile
|
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||||
? ElevatedButton.styleFrom(
|
style: isMobile
|
||||||
padding: const EdgeInsets.symmetric(
|
? ElevatedButton.styleFrom(
|
||||||
horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
minimumSize: Size.zero,
|
horizontal: 10, vertical: 6),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
minimumSize: Size.zero,
|
||||||
)
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
: null,
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -417,93 +420,59 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 操作按钮列表,表格与移动端卡片共用。
|
/// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions],
|
||||||
|
/// 入库特有的「打标签」通过 afterPrint 注入。
|
||||||
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
||||||
final readonly = WriteGuard.isReadonly(ref);
|
return buildOrderRowActions(
|
||||||
return [
|
readonly: WriteGuard.isReadonly(ref),
|
||||||
TextButton(
|
status: o.status,
|
||||||
onPressed: () => _showDetail(context, o.id),
|
orderId: o.id,
|
||||||
child: const Text('详情',
|
onDetail: () => _showDetail(context, o.id),
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
onPrint: () async {
|
||||||
),
|
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||||
TextButton(
|
if (context.mounted) {
|
||||||
onPressed: () async {
|
await safePrint(context, () => printStockInOrder(order));
|
||||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
}
|
||||||
if (context.mounted) {
|
},
|
||||||
await safePrint(context, () => printStockInOrder(order));
|
afterPrint: [
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('打印',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
|
||||||
final labels = order.items
|
|
||||||
.map((item) => LabelData(
|
|
||||||
productId: item.productId,
|
|
||||||
name: item.productName ?? '',
|
|
||||||
code: item.productCode ?? '',
|
|
||||||
series: item.productSeries,
|
|
||||||
spec: item.productSpec,
|
|
||||||
batchNo: item.batchNo,
|
|
||||||
productionDate: item.productionDate,
|
|
||||||
shopName: shopInfo?.name ?? '',
|
|
||||||
shopAddress: shopInfo?.address ?? '',
|
|
||||||
shopPhone: shopInfo?.phone ?? '',
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
showAppDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => LabelPreviewDialog(
|
|
||||||
labels: labels,
|
|
||||||
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('打标签',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
|
||||||
),
|
|
||||||
if (!readonly && o.status == 'approved')
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
|
onPressed: () async {
|
||||||
child: const Text('结清',
|
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
if (!context.mounted) return;
|
||||||
),
|
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||||
if (!readonly && o.status == 'draft') ...[
|
final labels = order.items
|
||||||
TextButton(
|
.map((item) => LabelData(
|
||||||
onPressed: () => context.go('/stock-in/edit/${o.id}'),
|
productId: item.productId,
|
||||||
child: const Text('修改',
|
name: item.productName ?? '',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
code: item.productCode ?? '',
|
||||||
),
|
series: item.productSeries,
|
||||||
TextButton(
|
spec: item.productSpec,
|
||||||
onPressed: () => _confirmDelete(context, o),
|
batchNo: item.batchNo,
|
||||||
child: const Text('删除',
|
productionDate: item.productionDate,
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
shopName: shopInfo?.name ?? '',
|
||||||
),
|
shopAddress: shopInfo?.address ?? '',
|
||||||
TextButton(
|
shopPhone: shopInfo?.phone ?? '',
|
||||||
onPressed: () => _confirmSubmit(context, o),
|
))
|
||||||
child: const Text('提交',
|
.toList();
|
||||||
|
showAppDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => LabelPreviewDialog(
|
||||||
|
labels: labels,
|
||||||
|
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('打标签',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (!readonly && o.status == 'pending') ...[
|
onSettle: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||||
TextButton(
|
onEdit: () => context.go('/stock-in/edit/${o.id}'),
|
||||||
key: Key('btn_approve_${o.id}'),
|
onDelete: () => _confirmDelete(context, o),
|
||||||
onPressed: () => _confirmApprove(context, o),
|
onSubmit: () => _confirmSubmit(context, o),
|
||||||
child: const Text('通过',
|
onApprove: () => _confirmApprove(context, o),
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
onReject: () => _confirmReject(context, o),
|
||||||
),
|
);
|
||||||
TextButton(
|
|
||||||
key: Key('btn_reject_${o.id}'),
|
|
||||||
onPressed: () => _confirmReject(context, o),
|
|
||||||
child: const Text('拒绝',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 入库单:窄屏卡片
|
/// 入库单:窄屏卡片
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '../../providers/tab_state_provider.dart';
|
|||||||
import '../../providers/product_provider.dart';
|
import '../../providers/product_provider.dart';
|
||||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
|
import '../../widgets/order_row_actions.dart';
|
||||||
|
|
||||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||||
const StockOutListScreen({super.key});
|
const StockOutListScreen({super.key});
|
||||||
@@ -312,18 +313,20 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||||
? ElevatedButton.icon(
|
? WriteGuard(
|
||||||
onPressed: () => context.go('/stock-out/new'),
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
onPressed: () => context.go('/stock-out/new'),
|
||||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
style: isMobile
|
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||||
? ElevatedButton.styleFrom(
|
style: isMobile
|
||||||
padding: const EdgeInsets.symmetric(
|
? ElevatedButton.styleFrom(
|
||||||
horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
minimumSize: Size.zero,
|
horizontal: 10, vertical: 6),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
minimumSize: Size.zero,
|
||||||
)
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
: null,
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -425,61 +428,24 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
|
|
||||||
/// 操作按钮列表,表格与移动端卡片共用。
|
/// 操作按钮列表,表格与移动端卡片共用。
|
||||||
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
||||||
final readonly = WriteGuard.isReadonly(ref);
|
return buildOrderRowActions(
|
||||||
return [
|
readonly: WriteGuard.isReadonly(ref),
|
||||||
TextButton(
|
status: o.status,
|
||||||
onPressed: () => _showDetail(context, o.id),
|
orderId: o.id,
|
||||||
child: const Text('详情',
|
onDetail: () => _showDetail(context, o.id),
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
onPrint: () async {
|
||||||
),
|
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||||
TextButton(
|
if (context.mounted) {
|
||||||
onPressed: () async {
|
await safePrint(context, () => printStockOutOrder(order));
|
||||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
}
|
||||||
if (context.mounted) {
|
},
|
||||||
await safePrint(context, () => printStockOutOrder(order));
|
onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||||
}
|
onEdit: () => context.go('/stock-out/edit/${o.id}'),
|
||||||
},
|
onDelete: () => _confirmDelete(context, o),
|
||||||
child: const Text('打印',
|
onSubmit: () => _confirmSubmit(context, o),
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
onApprove: () => _confirmApprove(context, o),
|
||||||
),
|
onReject: () => _confirmReject(context, o),
|
||||||
if (!readonly && o.status == 'approved')
|
);
|
||||||
TextButton(
|
|
||||||
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
|
|
||||||
child: const Text('结清',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
|
||||||
),
|
|
||||||
if (!readonly && o.status == 'draft') ...[
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.go('/stock-out/edit/${o.id}'),
|
|
||||||
child: const Text('修改',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => _confirmDelete(context, o),
|
|
||||||
child: const Text('删除',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => _confirmSubmit(context, o),
|
|
||||||
child: const Text('提交',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (!readonly && o.status == 'pending') ...[
|
|
||||||
TextButton(
|
|
||||||
key: Key('btn_approve_${o.id}'),
|
|
||||||
onPressed: () => _confirmApprove(context, o),
|
|
||||||
child: const Text('通过',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
key: Key('btn_reject_${o.id}'),
|
|
||||||
onPressed: () => _confirmReject(context, o),
|
|
||||||
child: const Text('拒绝',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 出库单:窄屏卡片
|
/// 出库单:窄屏卡片
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/theme/app_theme.dart';
|
||||||
|
import 'write_guard.dart';
|
||||||
|
|
||||||
|
/// 入/出库单行内操作按钮,列表表格与移动端卡片、入库与出库两屏共用。
|
||||||
|
///
|
||||||
|
/// 此前 stock_in / stock_out 各自逐字重复了「详情/打印/结清/修改/删除/提交/通过/拒绝」
|
||||||
|
/// 的状态门控(draft/pending/approved)与 WriteGuard 包裹脚手架,仅单据类型、路由、
|
||||||
|
/// 打印函数与回调不同。这里把**相同的结构**收敛到单一来源,差异通过回调注入:
|
||||||
|
///
|
||||||
|
/// - [afterPrint]:紧随「打印」之后的附加按钮(入库的「打标签」;出库传空)。
|
||||||
|
/// - 各 `on*` 回调由调用方绑定到对应单据的确认/跳转逻辑。
|
||||||
|
List<Widget> buildOrderRowActions({
|
||||||
|
required bool readonly,
|
||||||
|
required String status,
|
||||||
|
required int orderId,
|
||||||
|
required VoidCallback onDetail,
|
||||||
|
required VoidCallback onPrint,
|
||||||
|
required VoidCallback onSettle,
|
||||||
|
required VoidCallback onEdit,
|
||||||
|
required VoidCallback onDelete,
|
||||||
|
required VoidCallback onSubmit,
|
||||||
|
required VoidCallback onApprove,
|
||||||
|
required VoidCallback onReject,
|
||||||
|
List<Widget> afterPrint = const [],
|
||||||
|
}) {
|
||||||
|
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
||||||
|
TextButton(
|
||||||
|
key: key,
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
btn('详情', AppTheme.primary, onDetail),
|
||||||
|
btn('打印', AppTheme.primary, onPrint),
|
||||||
|
...afterPrint,
|
||||||
|
if (!readonly && status == 'approved')
|
||||||
|
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
|
||||||
|
if (!readonly && status == 'draft') ...[
|
||||||
|
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
|
||||||
|
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
|
||||||
|
WriteGuard(child: btn('提交', AppTheme.primary, onSubmit)),
|
||||||
|
],
|
||||||
|
if (!readonly && status == 'pending') ...[
|
||||||
|
WriteGuard(
|
||||||
|
child: btn('通过', AppTheme.success, onApprove,
|
||||||
|
key: Key('btn_approve_$orderId'))),
|
||||||
|
WriteGuard(
|
||||||
|
child: btn('拒绝', AppTheme.danger, onReject,
|
||||||
|
key: Key('btn_reject_$orderId'))),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../core/auth/auth_state.dart';
|
import '../core/auth/auth_state.dart';
|
||||||
|
import '../core/config/license_copy.dart';
|
||||||
|
import '../providers/license_provider.dart';
|
||||||
|
|
||||||
/// 写操作守卫:当前登录用户为只读角色(role == 'readonly')时,
|
/// 写操作守卫:按「角色只读」和「授权过期」两种受限场景**区分处理**被包裹的写控件
|
||||||
/// 隐藏被包裹的写操作控件(新增/编辑/删除/审核/提交/结清/导入…)。
|
/// (新增/编辑/删除/审核/提交/结清/导入…):
|
||||||
|
///
|
||||||
|
/// - **角色只读**(role == 'readonly')→ 隐藏(不显示)。
|
||||||
|
/// - **授权过期 >7 天**(phase == 'readonly' | 'locked')→ 显示但置灰禁用,
|
||||||
|
/// 点击弹提示说明原因并引导去激活。
|
||||||
|
/// - **其它**(normal / grace 宽限期)→ 原样显示且可点(宽限期后端仍允许写)。
|
||||||
///
|
///
|
||||||
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
||||||
/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403,
|
/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403,
|
||||||
/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过。
|
/// 本控件只负责「不误导」:只读不显示、过期显示但不可点。
|
||||||
///
|
///
|
||||||
/// 用法:
|
/// 用法:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
||||||
/// ```
|
/// ```
|
||||||
/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符:
|
/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符;
|
||||||
|
/// 过期置灰仍由内层 [WriteGuard] 负责:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
|
/// if (!WriteGuard.isReadonly(ref)) ...[WriteGuard(child: button), const SizedBox(width: 8)]
|
||||||
/// ```
|
/// ```
|
||||||
class WriteGuard extends ConsumerWidget {
|
class WriteGuard extends ConsumerWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
/// 只读时显示的占位控件,默认完全隐藏(不占位)。
|
/// 只读角色时显示的占位控件,默认完全隐藏(不占位)。
|
||||||
final Widget placeholder;
|
final Widget placeholder;
|
||||||
|
|
||||||
const WriteGuard({
|
const WriteGuard({
|
||||||
@@ -29,11 +37,58 @@ class WriteGuard extends ConsumerWidget {
|
|||||||
this.placeholder = const SizedBox.shrink(),
|
this.placeholder = const SizedBox.shrink(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 供需要在 collection-if / 复合条件中判断的场景直接调用。
|
/// 当前是否只读角色(写按钮应**隐藏**)。供 collection-if / 复合条件直接调用。
|
||||||
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
|
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
|
||||||
|
|
||||||
|
/// 当前是否因授权过期 >7 天而应**禁用**写操作(phase readonly/locked)。
|
||||||
|
static bool licenseBlocked(WidgetRef ref) {
|
||||||
|
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||||
|
return lic != null && lic.isReadOnlyPhase;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ref.watch(isReadonlyProvider) ? placeholder : child;
|
// 1) 角色只读:隐藏
|
||||||
|
if (ref.watch(isReadonlyProvider)) return placeholder;
|
||||||
|
|
||||||
|
// 2) 授权过期 >7 天:显示但置灰禁用 + 点击弹提示
|
||||||
|
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||||
|
if (lic != null && lic.isReadOnlyPhase) {
|
||||||
|
return _DisabledByLicense(lic: lic, child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) normal / grace:原样可点
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 授权过期态的写控件包装:视觉置灰 + 拦截点击并提示。
|
||||||
|
class _DisabledByLicense extends StatelessWidget {
|
||||||
|
final LicenseInfo lic;
|
||||||
|
final Widget child;
|
||||||
|
const _DisabledByLicense({required this.lic, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 置灰且不响应子控件自身的点击
|
||||||
|
IgnorePointer(child: Opacity(opacity: 0.45, child: child)),
|
||||||
|
// 覆盖一层透明手势层:捕获点击弹出原因说明
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
messenger
|
||||||
|
?..clearSnackBars()
|
||||||
|
..showSnackBar(
|
||||||
|
SnackBar(content: Text(LicenseCopy.writeBlockedToast(lic))),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
import 'package:jiu_client/core/api/api_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('ApiClient 403 拦截 → onForbidden 回调', () {
|
||||||
|
test('写请求收到 403 时,onForbidden 收到响应体(code=READONLY_USER)', () async {
|
||||||
|
dynamic captured;
|
||||||
|
final client = ApiClient(
|
||||||
|
token: 'test-token',
|
||||||
|
onForbidden: (body) => captured = body,
|
||||||
|
);
|
||||||
|
final adapter = DioAdapter(
|
||||||
|
dio: client.dioForTest,
|
||||||
|
matcher: const FullHttpRequestMatcher(),
|
||||||
|
);
|
||||||
|
adapter.onPost(
|
||||||
|
'/products',
|
||||||
|
(server) => server.reply(403, {'error': 'readonly user', 'code': 'READONLY_USER'}),
|
||||||
|
data: {'name': '测试'},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.post('/products', data: {'name': '测试'}),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captured, isA<Map>());
|
||||||
|
expect(captured['code'], 'READONLY_USER');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('授权过期 403(phase=readonly)时,onForbidden 收到 phase 字段', () async {
|
||||||
|
dynamic captured;
|
||||||
|
final client = ApiClient(
|
||||||
|
token: 'test-token',
|
||||||
|
onForbidden: (body) => captured = body,
|
||||||
|
);
|
||||||
|
final adapter = DioAdapter(
|
||||||
|
dio: client.dioForTest,
|
||||||
|
matcher: const FullHttpRequestMatcher(),
|
||||||
|
);
|
||||||
|
adapter.onPost(
|
||||||
|
'/stock-in',
|
||||||
|
(server) => server.reply(403, {'error': 'license expired', 'phase': 'readonly'}),
|
||||||
|
data: {'x': 1},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.post('/stock-in', data: {'x': 1}),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captured, isA<Map>());
|
||||||
|
expect(captured['phase'], 'readonly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非 403 错误不触发 onForbidden', () async {
|
||||||
|
var called = false;
|
||||||
|
final client = ApiClient(
|
||||||
|
token: 'test-token',
|
||||||
|
onForbidden: (_) => called = true,
|
||||||
|
);
|
||||||
|
final adapter = DioAdapter(
|
||||||
|
dio: client.dioForTest,
|
||||||
|
matcher: const FullHttpRequestMatcher(),
|
||||||
|
);
|
||||||
|
adapter.onGet(
|
||||||
|
'/products',
|
||||||
|
(server) => server.reply(404, {'error': 'not found'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.get('/products'),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(called, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jiu_client/core/config/license_copy.dart';
|
||||||
|
import 'package:jiu_client/models/license.dart';
|
||||||
|
|
||||||
|
LicenseInfo _lic(String phase, {int expiredDays = 0, bool permanent = false}) =>
|
||||||
|
LicenseInfo(
|
||||||
|
id: 1,
|
||||||
|
type: 'annual',
|
||||||
|
isActive: true,
|
||||||
|
maxDevices: 3,
|
||||||
|
phase: phase,
|
||||||
|
expiresAt:
|
||||||
|
permanent ? null : DateTime.now().subtract(Duration(days: expiredDays)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('writeBlockedToast', () {
|
||||||
|
test('readonly 阶段文案含过期天数并引导续费', () {
|
||||||
|
final s = LicenseCopy.writeBlockedToast(_lic('readonly', expiredDays: 10));
|
||||||
|
expect(s, contains('10'));
|
||||||
|
expect(s, contains('只读'));
|
||||||
|
expect(s, contains('设置 → 授权'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked 阶段文案标注已锁定', () {
|
||||||
|
final s = LicenseCopy.writeBlockedToast(_lic('locked', expiredDays: 20));
|
||||||
|
expect(s, contains('20'));
|
||||||
|
expect(s, contains('锁定'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('banner', () {
|
||||||
|
test('normal 阶段返回空串', () {
|
||||||
|
expect(LicenseCopy.banner(_lic('normal', permanent: true)), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grace/readonly/locked 各阶段含过期天数', () {
|
||||||
|
expect(LicenseCopy.banner(_lic('grace', expiredDays: 2)), contains('2'));
|
||||||
|
expect(
|
||||||
|
LicenseCopy.banner(_lic('readonly', expiredDays: 9)), contains('只读'));
|
||||||
|
expect(
|
||||||
|
LicenseCopy.banner(_lic('locked', expiredDays: 30)), contains('锁定'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('statusText / statusBar', () {
|
||||||
|
test('normal 永久授权', () {
|
||||||
|
final lic = _lic('normal', permanent: true);
|
||||||
|
expect(LicenseCopy.statusText(lic), contains('永久'));
|
||||||
|
expect(LicenseCopy.statusBar(lic, '2099-01-01'), contains('永久'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readonly 阶段标注只读', () {
|
||||||
|
final lic = _lic('readonly', expiredDays: 9);
|
||||||
|
expect(LicenseCopy.statusText(lic), contains('只读'));
|
||||||
|
expect(LicenseCopy.statusBar(lic, '2024-01-01'), contains('只读'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('degradationNotes', () {
|
||||||
|
test('三条说明随阈值联动(含 7 与 15)', () {
|
||||||
|
final notes = LicenseCopy.degradationNotes();
|
||||||
|
expect(notes.length, 3);
|
||||||
|
expect(notes.join(), contains('${LicenseCopy.graceDays}'));
|
||||||
|
expect(notes.join(), contains('${LicenseCopy.readonlyDays}'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('readonlyUserToast', () {
|
||||||
|
test('只读账号提示固定文案', () {
|
||||||
|
expect(LicenseCopy.readonlyUserToast, contains('只读'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,10 +4,25 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jiu_client/core/models/page_result.dart';
|
import 'package:jiu_client/core/models/page_result.dart';
|
||||||
|
import 'package:jiu_client/models/license.dart';
|
||||||
import 'package:jiu_client/models/partner.dart';
|
import 'package:jiu_client/models/partner.dart';
|
||||||
|
import 'package:jiu_client/providers/license_provider.dart';
|
||||||
import 'package:jiu_client/providers/partner_provider.dart';
|
import 'package:jiu_client/providers/partner_provider.dart';
|
||||||
import 'package:jiu_client/screens/partners/partners_screen.dart';
|
import 'package:jiu_client/screens/partners/partners_screen.dart';
|
||||||
|
|
||||||
|
/// WriteGuard 内层会 watch licenseProvider;测试里覆写成同步返回 normal 授权,
|
||||||
|
/// 避免触发真实网络请求(否则留下 pending timer 导致测试失败)。
|
||||||
|
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||||
|
@override
|
||||||
|
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||||
|
id: 1,
|
||||||
|
type: 'annual',
|
||||||
|
isActive: true,
|
||||||
|
maxDevices: 3,
|
||||||
|
phase: 'normal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fake notifier
|
// Fake notifier
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +95,7 @@ Widget _buildApp({
|
|||||||
}) {
|
}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||||
supplierListProvider.overrideWith(
|
supplierListProvider.overrideWith(
|
||||||
() => _FakePartnerNotifier('supplier', supplierState),
|
() => _FakePartnerNotifier('supplier', supplierState),
|
||||||
),
|
),
|
||||||
@@ -94,6 +110,7 @@ Widget _buildApp({
|
|||||||
Widget _buildLoadingApp() {
|
Widget _buildLoadingApp() {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||||
supplierListProvider.overrideWith(
|
supplierListProvider.overrideWith(
|
||||||
() => _FakeLoadingPartnerNotifier('supplier'),
|
() => _FakeLoadingPartnerNotifier('supplier'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,69 +1,156 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jiu_client/core/exceptions.dart';
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:jiu_client/core/api/api_client.dart';
|
||||||
|
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||||
|
import 'package:jiu_client/core/config/license_copy.dart';
|
||||||
import 'package:jiu_client/core/models/page_result.dart';
|
import 'package:jiu_client/core/models/page_result.dart';
|
||||||
import 'package:jiu_client/models/product.dart';
|
import 'package:jiu_client/models/license.dart';
|
||||||
import 'package:jiu_client/providers/product_provider.dart';
|
import 'package:jiu_client/models/partner.dart';
|
||||||
import 'package:jiu_client/screens/products/products_screen.dart';
|
import 'package:jiu_client/providers/license_provider.dart';
|
||||||
|
import 'package:jiu_client/providers/partner_provider.dart';
|
||||||
|
import 'package:jiu_client/screens/partners/partners_screen.dart';
|
||||||
|
|
||||||
|
// 只读角色:写按钮(新建/编辑/删除)应被 WriteGuard 隐藏。
|
||||||
|
// 替换原先因 ProductsScreen 重构而 skip 的旧用例,改测稳定的 PartnersScreen。
|
||||||
|
|
||||||
|
class _FakePartnerNotifier extends PartnerListNotifier {
|
||||||
|
final AsyncValue<PageResult<Partner>> _fixed;
|
||||||
|
_FakePartnerNotifier(String type, this._fixed) : super(type: type);
|
||||||
|
|
||||||
class _ReadonlyProductNotifier extends ProductListNotifier {
|
|
||||||
@override
|
@override
|
||||||
Future<PageResult<Product>> build() async {
|
Future<PageResult<Partner>> build() async {
|
||||||
return const PageResult(data: [], total: 0, page: 1, pageSize: 20);
|
state = _fixed;
|
||||||
|
return state.value ??
|
||||||
|
const PageResult(data: [], total: 0, page: 1, pageSize: 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void reload() {}
|
void reload() {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void setKeyword(String keyword) {}
|
void setKeyword(String keyword) {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void setPage(int page) {}
|
void setPage(int page) {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createProduct(Map<String, dynamic> data) async {
|
Future<void> createPartner(Map<String, dynamic> data) async {}
|
||||||
throw const AppException('readonly user', statusCode: 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateProduct(int id, Map<String, dynamic> data) async {}
|
Future<void> updatePartner(int id, Map<String, dynamic> data) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteProduct(int id) async {}
|
Future<void> deletePartner(int id) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||||
|
@override
|
||||||
|
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||||
|
id: 1,
|
||||||
|
type: 'annual',
|
||||||
|
isActive: true,
|
||||||
|
maxDevices: 3,
|
||||||
|
phase: 'normal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _suppliers = PageResult<Partner>(
|
||||||
|
data: [Partner(id: 1, code: 'S001', name: '茅台供应商', type: 'supplier')],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildApp({required bool readonly}) {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
isReadonlyProvider.overrideWithValue(readonly),
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||||
|
supplierListProvider.overrideWith(
|
||||||
|
() => _FakePartnerNotifier('supplier', const AsyncValue.data(_suppliers)),
|
||||||
|
),
|
||||||
|
customerListProvider.overrideWith(
|
||||||
|
() => _FakePartnerNotifier(
|
||||||
|
'customer',
|
||||||
|
const AsyncValue.data(
|
||||||
|
PageResult(data: [], total: 0, page: 1, pageSize: 20)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: Scaffold(body: PartnersScreen())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// TODO: 新建按钮位置已变更,测试待更新
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
testWidgets('write operation shows forbidden error for readonly user',
|
|
||||||
skip: true, (tester) async {
|
// 仅隐藏按钮不够:只读账号若绕过 UI(或后端授权过期)直接提交写操作,
|
||||||
|
// 必须验证 403 真的被 apiClientProvider 的 onForbidden 捕获并浮出只读提示。
|
||||||
|
test('只读账号提交写操作:后端 403(READONLY_USER) 经 apiClientProvider 浮出只读提示',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
// 登录一个只读账号,使 apiClientProvider 携带 token 并构建真实客户端。
|
||||||
|
await container.read(authStateProvider.notifier).login(const AuthUser(
|
||||||
|
accessToken: 'ro-token',
|
||||||
|
refreshToken: 'ro-refresh',
|
||||||
|
username: 'viewer',
|
||||||
|
realName: '只读用户',
|
||||||
|
shopNo: 'S001',
|
||||||
|
shopId: 1,
|
||||||
|
role: 'readonly',
|
||||||
|
));
|
||||||
|
|
||||||
|
final client = container.read(apiClientProvider);
|
||||||
|
final adapter = DioAdapter(
|
||||||
|
dio: client.dioForTest,
|
||||||
|
matcher: const FullHttpRequestMatcher(),
|
||||||
|
);
|
||||||
|
adapter.onPost(
|
||||||
|
'/partners',
|
||||||
|
(server) =>
|
||||||
|
server.reply(403, {'error': 'readonly user', 'code': 'READONLY_USER'}),
|
||||||
|
data: {'name': '新供应商'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 写请求被后端拒绝。
|
||||||
|
await expectLater(
|
||||||
|
client.post('/partners', data: {'name': '新供应商'}),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// provider 的 onForbidden 把只读提示浮到全局消息通道(用户可见的「403 浮出」)。
|
||||||
|
expect(container.read(apiMessageProvider), LicenseCopy.readonlyUserToast);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('只读角色:新建/编辑/删除按钮全部隐藏', (tester) async {
|
||||||
tester.view.physicalSize = const Size(1280, 800);
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(_buildApp(readonly: true));
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
productListProvider.overrideWith(() => _ReadonlyProductNotifier()),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: Scaffold(body: ProductsScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('新建'));
|
// 供应商已渲染,证明列表确实加载。
|
||||||
|
expect(find.text('茅台供应商'), findsOneWidget);
|
||||||
|
// 写按钮被 WriteGuard 隐藏。
|
||||||
|
expect(find.text('新建'), findsNothing);
|
||||||
|
expect(find.byKey(const Key('btn_edit_1')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('btn_delete_1')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('普通角色(normal 授权):写按钮可见', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(readonly: false));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextFormField).at(0), '测试商品');
|
expect(find.text('茅台供应商'), findsOneWidget);
|
||||||
await tester.enterText(find.byType(TextFormField).at(1), 'P999');
|
expect(find.text('新建'), findsWidgets);
|
||||||
|
expect(find.byKey(const Key('btn_edit_1')), findsOneWidget);
|
||||||
await tester.tap(find.text('保存'));
|
expect(find.byKey(const Key('btn_delete_1')), findsOneWidget);
|
||||||
await tester.pump();
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('保存失败:readonly user'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:jiu_client/core/models/page_result.dart';
|
import 'package:jiu_client/core/models/page_result.dart';
|
||||||
|
import 'package:jiu_client/models/license.dart';
|
||||||
import 'package:jiu_client/models/stock_in.dart';
|
import 'package:jiu_client/models/stock_in.dart';
|
||||||
|
import 'package:jiu_client/providers/license_provider.dart';
|
||||||
import 'package:jiu_client/providers/stock_in_provider.dart';
|
import 'package:jiu_client/providers/stock_in_provider.dart';
|
||||||
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
|
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
|
||||||
|
|
||||||
|
/// WriteGuard 内层会 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。
|
||||||
|
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||||
|
@override
|
||||||
|
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||||
|
id: 1,
|
||||||
|
type: 'annual',
|
||||||
|
isActive: true,
|
||||||
|
maxDevices: 3,
|
||||||
|
phase: 'normal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fake notifier
|
// Fake notifier
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -102,6 +116,7 @@ Widget _buildApp(AsyncValue<PageResult<StockInOrder>> state) {
|
|||||||
|
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||||
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)),
|
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(routerConfig: router),
|
child: MaterialApp.router(routerConfig: router),
|
||||||
@@ -125,6 +140,7 @@ Widget _buildLoadingApp() {
|
|||||||
|
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||||
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()),
|
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(routerConfig: router),
|
child: MaterialApp.router(routerConfig: router),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||||
|
import 'package:jiu_client/core/config/license_copy.dart';
|
||||||
|
import 'package:jiu_client/models/license.dart';
|
||||||
|
import 'package:jiu_client/providers/license_provider.dart';
|
||||||
|
import 'package:jiu_client/widgets/write_guard.dart';
|
||||||
|
|
||||||
|
/// 用固定 LicenseInfo(含指定 phase)填充 licenseProvider 的 Fake Notifier。
|
||||||
|
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||||
|
final LicenseInfo? _value;
|
||||||
|
_FakeLicenseNotifier(this._value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LicenseInfo?> build() async => _value;
|
||||||
|
}
|
||||||
|
|
||||||
|
LicenseInfo _lic(String phase, {int expiredDays = 0}) => LicenseInfo(
|
||||||
|
id: 1,
|
||||||
|
type: 'annual',
|
||||||
|
isActive: true,
|
||||||
|
maxDevices: 3,
|
||||||
|
phase: phase,
|
||||||
|
expiresAt: DateTime.now().subtract(Duration(days: expiredDays)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 把一个带 onPressed 的按钮包进 WriteGuard,按 role + license phase 覆写 provider。
|
||||||
|
Widget _harness({
|
||||||
|
required bool readonly,
|
||||||
|
LicenseInfo? lic,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
isReadonlyProvider.overrideWithValue(readonly),
|
||||||
|
licenseProvider.overrideWith(() => _FakeLicenseNotifier(lic)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: WriteGuard(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: const Text('新建'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('只读角色:按钮隐藏(findsNothing)', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_harness(readonly: true, lic: _lic('normal'), onPressed: () => tapped = true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('新建'), findsNothing);
|
||||||
|
expect(tapped, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('operator + normal:按钮可见且可点', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_harness(readonly: false, lic: _lic('normal'), onPressed: () => tapped = true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('新建'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('新建'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tapped, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('operator + grace(宽限期):按钮可见且可点', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_harness(
|
||||||
|
readonly: false,
|
||||||
|
lic: _lic('grace', expiredDays: 3),
|
||||||
|
onPressed: () => tapped = true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('新建'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('新建'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tapped, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('operator + readonly(过期>7天):按钮可见但禁用,点击弹提示且不触发 onPressed',
|
||||||
|
(tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
final lic = _lic('readonly', expiredDays: 10);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_harness(readonly: false, lic: lic, onPressed: () => tapped = true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 按钮仍渲染(不隐藏),但被 IgnorePointer + Opacity 包裹禁用。
|
||||||
|
expect(find.text('新建'), findsOneWidget);
|
||||||
|
expect(find.byType(IgnorePointer), findsWidgets);
|
||||||
|
expect(find.byType(Opacity), findsWidgets);
|
||||||
|
|
||||||
|
// 点击覆盖层弹出原因提示,且 onPressed 未被调用。
|
||||||
|
// 覆盖层(GestureDetector)盖在按钮上,命中的是覆盖层而非按钮文字,故 warnIfMissed:false。
|
||||||
|
await tester.tap(find.text('新建'), warnIfMissed: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tapped, isFalse);
|
||||||
|
expect(find.text(LicenseCopy.writeBlockedToast(lic)), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('operator + locked(过期>15天):按钮可见但禁用,弹锁定提示', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
final lic = _lic('locked', expiredDays: 20);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_harness(readonly: false, lic: lic, onPressed: () => tapped = true),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('新建'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('新建'), warnIfMissed: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tapped, isFalse);
|
||||||
|
expect(find.text(LicenseCopy.writeBlockedToast(lic)), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user