diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index cc386fd..2861b30 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_state.dart'; import '../config/app_config.dart'; +import '../config/license_copy.dart'; import '../errors/error_reporter.dart'; import '../../providers/connectivity_provider.dart'; +import '../../providers/license_provider.dart'; import 'retry_interceptor.dart'; /// Public Dio instance for unauthenticated calls (login / refresh) @@ -30,8 +32,10 @@ final apiClientProvider = Provider((ref) { final client = ApiClient( token: user?.accessToken, refreshToken: user?.refreshToken, - onTokenRefreshed: (newToken) { - ref.read(authStateProvider.notifier).updateAccessToken(newToken); + onTokensRefreshed: (accessToken, refreshToken) { + ref + .read(authStateProvider.notifier) + .updateAccessToken(accessToken, refreshToken); }, onAuthFailed: (reason) { if (ref.read(authStateProvider).isLoggedIn) { @@ -44,6 +48,27 @@ final apiClientProvider = Provider((ref) { onConnectionError: () { ref.read(connectivityProvider.notifier).forceCheck(); }, + onForbidden: (body) { + final map = body is Map ? body : const {}; + final code = map['code']; + final phase = map['phase']; + // 只读角色写被拒:直接提示(无需刷新授权)。 + if (code == 'READONLY_USER') { + ref.read(apiMessageProvider.notifier).state = + LicenseCopy.readonlyUserToast; + return; + } + // 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。 + if (phase == 'readonly' || phase == 'locked') { + ref.read(licenseProvider.notifier).refresh().then((_) { + final lic = ref.read(licenseProvider).valueOrNull; + ref.read(apiMessageProvider.notifier).state = lic != null + ? LicenseCopy.writeBlockedToast(lic) + : '授权已过期,无法执行写操作'; + }); + } + // 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。 + }, ); ref.onDispose(client.dispose); return client; @@ -53,13 +78,28 @@ class ApiClient { late final Dio _dio; bool _disposed = false; + /// 当前 refresh token。续期会轮换它,故必须可变并随响应更新—— + /// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。 + String? _refreshToken; + + /// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh, + /// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。 + Future? _refreshing; + + void Function(String accessToken, String refreshToken)? _onTokensRefreshed; + void Function(String? reason)? _onAuthFailed; + ApiClient({ String? token, String? refreshToken, - void Function(String newToken)? onTokenRefreshed, + void Function(String accessToken, String refreshToken)? onTokensRefreshed, void Function(String? reason)? onAuthFailed, void Function()? onConnectionError, + void Function(dynamic body)? onForbidden, }) { + _refreshToken = refreshToken; + _onTokensRefreshed = onTokensRefreshed; + _onAuthFailed = onAuthFailed; _dio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 8), @@ -98,29 +138,37 @@ class ApiClient { ); } - if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) { + // 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。 + // 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。 + if (e.response?.statusCode == 403) { + if (!_disposed) onForbidden?.call(e.response?.data); + return handler.next(e); + } + + // 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态, + // 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。 + if (e.response?.statusCode == 401 && + e.response?.data is Map && + e.response?.data['code'] == 'USER_DISABLED') { + if (!_disposed) { + _onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员'); + } + return handler.next(e); + } + + if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); + final newToken = await _refreshAccessToken(); + if (newToken == null) { + return handler.next(e); + } try { - final resp = await _publicDio.post('/auth/refresh', data: { - 'refresh_token': refreshToken, - }); - final newToken = resp.data['data']['access_token'] as String; - _dio.options.headers['Authorization'] = 'Bearer $newToken'; - if (!_disposed) onTokenRefreshed?.call(newToken); final opts = e.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retryResp = await _dio.fetch(opts); return handler.resolve(retryResp); - } catch (refreshErr) { - debugPrint('[ApiClient] refresh failed: $refreshErr'); - // 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示 - String? reason; - if (refreshErr is DioException && - refreshErr.response?.data is Map && - refreshErr.response?.data['code'] == 'SESSION_REVOKED') { - reason = '您的账号已在其他设备登录,或登录已失效,请重新登录'; - } - if (!_disposed) onAuthFailed?.call(reason); + } catch (retryErr) { + return handler.next(retryErr is DioException ? retryErr : e); } } else if (e.response?.statusCode == 401) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); @@ -131,6 +179,45 @@ class ApiClient { ); } + /// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。 + /// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。 + Future _refreshAccessToken() { + return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null); + } + + Future _doRefresh() async { + try { + final resp = await _publicDio.post('/auth/refresh', data: { + 'refresh_token': _refreshToken, + }); + final data = resp.data['data'] as Map; + final newAccess = data['access_token'] as String; + // 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。 + final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken; + _refreshToken = newRefresh; + _dio.options.headers['Authorization'] = 'Bearer $newAccess'; + if (!_disposed && newRefresh != null) { + _onTokensRefreshed?.call(newAccess, newRefresh); + } + return newAccess; + } catch (refreshErr) { + debugPrint('[ApiClient] refresh failed: $refreshErr'); + // 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示 + String? reason; + if (refreshErr is DioException && + refreshErr.response?.data is Map && + refreshErr.response?.data['code'] == 'SESSION_REVOKED') { + reason = '您的账号已在其他设备登录,或登录已失效,请重新登录'; + } + if (!_disposed) _onAuthFailed?.call(reason); + return null; + } + } + + /// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。 + @visibleForTesting + Dio get dioForTest => _dio; + /// 取消所有进行中的请求,标记实例为已废弃 void dispose() { _disposed = true; diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index c0f5a4c..c67c792 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -45,6 +45,22 @@ class AuthState { class AuthNotifier extends StateNotifier { AuthNotifier() : super(const AuthState()); + /// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。 + /// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后 + /// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。 + int _sessionGen = 0; + + /// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化 + /// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。 + Future _storageQueue = Future.value(); + + Future _runStorage(Future Function() op) { + final next = _storageQueue.then((_) => op()); + // 吞掉单次写入异常,避免队列被卡死。 + _storageQueue = next.catchError((_) {}); + return next; + } + /// Called at app startup to restore persisted session. Future restore() async { try { @@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier { Future login(AuthUser user) async { debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}'); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kAccessToken, user.accessToken); - await prefs.setString(_kRefreshToken, user.refreshToken); - await prefs.setString(_kUsername, user.username); - await prefs.setString(_kRealName, user.realName); - await prefs.setString(_kShopNo, user.shopNo); - await prefs.setString(_kShopId, user.shopId.toString()); - await prefs.setString(_kRole, user.role); - debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...'); + final gen = ++_sessionGen; + await _runStorage(() async { + if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kAccessToken, user.accessToken); + await prefs.setString(_kRefreshToken, user.refreshToken); + await prefs.setString(_kUsername, user.username); + await prefs.setString(_kRealName, user.realName); + await prefs.setString(_kShopNo, user.shopNo); + await prefs.setString(_kShopId, user.shopId.toString()); + await prefs.setString(_kRole, user.role); + }); state = AuthState(initialized: true, user: user); debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}'); } - void updateAccessToken(String newToken) { + /// 续期后写回令牌。后端会**轮换 refresh token**(jti 轮换 + 盗用检测), + /// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token, + /// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。 + /// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。 + void updateAccessToken(String newToken, [String? newRefreshToken]) { if (state.user == null) return; - SharedPreferences.getInstance() - .then((prefs) => prefs.setString(_kAccessToken, newToken)); + final gen = _sessionGen; + final refreshToken = newRefreshToken ?? state.user!.refreshToken; + // fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入, + // 避免把已失效的 token 写回 SharedPreferences。 + _runStorage(() async { + if (gen != _sessionGen) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kAccessToken, newToken); + await prefs.setString(_kRefreshToken, refreshToken); + }); state = AuthState( initialized: true, user: AuthUser( accessToken: newToken, - refreshToken: state.user!.refreshToken, + refreshToken: refreshToken, username: state.user!.username, realName: state.user!.realName, shopNo: state.user!.shopNo, @@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier { Future logout() async { debugPrint('[Auth] logout() called! stack: ${StackTrace.current}'); - // 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出) final token = state.user?.accessToken; + // 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。 + _sessionGen++; + state = const AuthState(initialized: true); + // 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出) if (token != null && token.isNotEmpty) { try { await Dio(BaseOptions( @@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier { // ignore: 后端不可达或会话已失效都无所谓 } } - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kAccessToken); - await prefs.remove(_kRefreshToken); - await prefs.remove(_kUsername); - await prefs.remove(_kRealName); - await prefs.remove(_kShopNo); - await prefs.remove(_kShopId); - await prefs.remove(_kRole); - state = const AuthState(initialized: true); + // 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。 + await _runStorage(() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kAccessToken); + await prefs.remove(_kRefreshToken); + await prefs.remove(_kUsername); + await prefs.remove(_kRealName); + await prefs.remove(_kShopNo); + await prefs.remove(_kShopId); + await prefs.remove(_kRole); + }); } } @@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider( /// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。 final sessionEndedMessageProvider = StateProvider((ref) => null); +/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。 +/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。 +final apiMessageProvider = StateProvider((ref) => null); + /// 当前登录用户是否为只读角色(role == 'readonly')。 /// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮, /// 后端亦有 middleware.ReadOnly() 兜底返回 403。 diff --git a/client/lib/core/config/license_copy.dart b/client/lib/core/config/license_copy.dart new file mode 100644 index 0000000..32954f9 --- /dev/null +++ b/client/lib/core/config/license_copy.dart @@ -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 degradationNotes() => [ + '过期 $graceDays 天内(宽限期):仍可正常使用,请尽快续费', + '过期 $graceDays–$readonlyDays 天(只读期):仅能查看数据,无法新增或修改', + '过期满 $readonlyDays 天:系统锁定,无法登录,需续费或激活新授权码后恢复', + ]; +} diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index e4bf2a2..c2b8759 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -142,7 +142,19 @@ final appRouterProvider = Provider((ref) { productId: int.parse(state.pathParameters['id']!)))), GoRoute( path: '/settings', - pageBuilder: (_, __) => _noTransition(const SettingsScreen())), + pageBuilder: (_, state) { + const tabIndex = { + 'shop': 0, + 'users': 1, + 'number': 2, + 'system': 3, + 'license': 4, + 'import': 5, + }; + final tab = + tabIndex[state.uri.queryParameters['tab']] ?? 0; + return _noTransition(SettingsScreen(initialTab: tab)); + }), GoRoute( path: '/devices', pageBuilder: (_, __) => diff --git a/client/lib/models/license.dart b/client/lib/models/license.dart index c5d156a..0e3c61f 100644 --- a/client/lib/models/license.dart +++ b/client/lib/models/license.dart @@ -50,6 +50,13 @@ class LicenseInfo { 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 isLockedPhase => phase == 'locked'; bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked'; diff --git a/client/lib/providers/license_provider.dart b/client/lib/providers/license_provider.dart index 1da67a0..524797f 100644 --- a/client/lib/providers/license_provider.dart +++ b/client/lib/providers/license_provider.dart @@ -32,4 +32,27 @@ class LicenseNotifier extends AsyncNotifier { state = const AsyncValue.loading(); state = AsyncValue.data(await _fetch()); } + + /// 后台静默刷新(供心跳调用):不闪 loading; + /// 仅在成功拿到结果时更新(含「确无授权」的 null), + /// 网络/瞬时错误则保留上次状态,避免把已有授权误刷成「未激活」。 + /// 会话被撤销(401)由 ApiClient 拦截器统一处理(触发登出),与此无关。 + Future 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.from(raw as Map)); + state = AsyncValue.data(info); + } } diff --git a/client/lib/providers/session_heartbeat.dart b/client/lib/providers/session_heartbeat.dart index 84a29d2..b5adb8f 100644 --- a/client/lib/providers/session_heartbeat.dart +++ b/client/lib/providers/session_heartbeat.dart @@ -3,11 +3,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api/api_client.dart'; import '../core/auth/auth_state.dart'; +import 'license_provider.dart'; -/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。 -/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401, -/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout, -/// 因此空闲用户也能在 ~30s 内感知到被下线。 +/// 登录态心跳:已登录时每 30s 执行一次 **单个** POST /auth/ping: +/// - 会话/在线检查:若会话已被撤销(被踢/管理员强制下线),后端返回 401, +/// ApiClient 拦截器触发 refresh→失败→onAuthFailed→logout,因此空闲用户也能在 +/// ~30s 内感知到被下线;同时刷新 last_seen_at(在线状态)。 +/// - 授权状态检查:ping 响应**回带**当前授权概况(license 字段,与 /license/info 同构), +/// 直接喂给 licenseProvider,使到期/续费/被改动等变化在 ~30s 内反映到横幅/状态栏/设置页。 +/// 如此一次心跳即覆盖两件事,**无需**再单独轮询 /license/info(每 30s 少一次请求)。 final sessionHeartbeatProvider = Provider((ref) { final hb = SessionHeartbeat(ref); ref.onDispose(hb.dispose); @@ -28,10 +32,18 @@ class SessionHeartbeat { Future _ping() async { if (!_ref.read(authStateProvider).isLoggedIn) return; + // 一次 ping 同时完成会话/在线检查与授权概况刷新。 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) { - // 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略 + // 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略,保留上次授权状态 debugPrint('[Heartbeat] ping failed: $e'); } } diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 8f3b6bb..d1667d1 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -169,10 +169,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ], actions: (canClose && !WriteGuard.isReadonly(ref)) ? [ - TextButton( - onPressed: () => _closeRecord(r), - child: const Text('结清', - style: TextStyle(fontSize: 13, color: AppTheme.success)), + WriteGuard( + child: TextButton( + onPressed: () => _closeRecord(r), + child: const Text('结清', + style: TextStyle(fontSize: 13, color: AppTheme.success)), + ), ), ] : null, @@ -318,10 +320,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open' && !WriteGuard.isReadonly(ref)) { - return DataCell(TextButton( - onPressed: () => _closeRecord(r), - child: const Text('结清', - style: TextStyle(fontSize: 12, color: AppTheme.success)), + return DataCell(WriteGuard( + child: TextButton( + onPressed: () => _closeRecord(r), + child: const Text('结清', + style: TextStyle(fontSize: 12, color: AppTheme.success)), + ), )); } return const DataCell(SizedBox()); @@ -419,10 +423,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { toolbar: Row( children: [ if (addLabel != null && !WriteGuard.isReadonly(ref)) - ElevatedButton.icon( - onPressed: _showAddDialog, - icon: const Icon(Icons.add, size: 16), - label: Text(addLabel), + WriteGuard( + child: ElevatedButton.icon( + onPressed: _showAddDialog, + icon: const Icon(Icons.add, size: 16), + label: Text(addLabel), + ), ), if (addLabel != null && !WriteGuard.isReadonly(ref)) const SizedBox(width: 8), diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 6113c82..1403cf9 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState { 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 _editRemark(BuildContext context, Inventory item) async { final ctrl = TextEditingController(text: item.remark); final saved = await showDialog( @@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState { 'remark' => DataCell(Tooltip( message: item.remark.isEmpty ? '' : item.remark, waitDuration: const Duration(milliseconds: 300), - child: GestureDetector( - onTap: WriteGuard.isReadonly(ref) - ? null - : () => _editRemark(context, item), - child: 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, - ), - ), - const SizedBox(width: 4), - const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary), - ], + // 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标), + // 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。 + child: WriteGuard( + placeholder: _remarkDisplay(item, editable: false), + child: GestureDetector( + onTap: () => _editRemark(context, item), + child: _remarkDisplay(item, editable: true), ), ))), 'status' => DataCell(_InventoryStatusBadge(item)), @@ -374,9 +386,11 @@ class _InventoryListScreenState extends ConsumerState { ], actions: [ if (!WriteGuard.isReadonly(ref)) - TextButton( - onPressed: () => _editRemark(context, item), - child: const Text('备注', style: TextStyle(fontSize: 13)), + WriteGuard( + child: TextButton( + onPressed: () => _editRemark(context, item), + child: const Text('备注', style: TextStyle(fontSize: 13)), + ), ), if (item.productId != null) TextButton( @@ -622,16 +636,19 @@ class _InventoryListScreenState extends ConsumerState { Row( children: [ if (canCheck) - OutlinedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.fact_check, size: 16), - label: const Text('盘点'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, + WriteGuard( + child: OutlinedButton.icon( + onPressed: () => + context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('盘点'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), ), ), const Spacer(), @@ -671,10 +688,12 @@ class _InventoryListScreenState extends ConsumerState { SizedBox(width: 220, child: searchField), const SizedBox(width: 12), if (canCheck) ...[ - OutlinedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.fact_check, size: 16), - label: const Text('发起盘点'), + WriteGuard( + child: OutlinedButton.icon( + onPressed: () => context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('发起盘点'), + ), ), const SizedBox(width: 8), ], diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index ce13862..a1e695c 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -170,16 +170,20 @@ class _PartnersScreenState extends ConsumerState { actions: WriteGuard.isReadonly(ref) ? const [] : [ - TextButton( - key: Key('btn_edit_${p.id}'), - onPressed: () => onEdit(p), - child: const Text('编辑', style: TextStyle(fontSize: 13)), + WriteGuard( + child: TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => onEdit(p), + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), ), - TextButton( - key: Key('btn_delete_${p.id}'), - onPressed: () => onDelete(p), - child: const Text('删除', - style: TextStyle(fontSize: 13, color: AppTheme.danger)), + WriteGuard( + child: TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => onDelete(p), + child: const Text('删除', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), ), ], ); @@ -195,10 +199,12 @@ class _PartnersScreenState extends ConsumerState { toolbar: Row( children: [ if (!WriteGuard.isReadonly(ref)) ...[ - ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add, size: 16), - label: Text(isSupplier ? '新建' : '新建'), + WriteGuard( + child: ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: Text(isSupplier ? '新建' : '新建'), + ), ), const SizedBox(width: 8), ], @@ -288,19 +294,23 @@ class _PartnersScreenState extends ConsumerState { children: WriteGuard.isReadonly(ref) ? const [] : [ - TextButton( - key: Key('btn_edit_${p.id}'), - onPressed: () => onEdit(p), - child: const Text('编辑', - style: TextStyle(fontSize: 12)), + WriteGuard( + child: TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => onEdit(p), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), ), - TextButton( - key: Key('btn_delete_${p.id}'), - onPressed: () => onDelete(p), - child: const Text('删除', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), + WriteGuard( + child: TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => onDelete(p), + child: const Text('删除', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), ), ], )), diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 223cba1..9a8755e 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -358,10 +358,12 @@ class _ProductsScreenState extends ConsumerState { return Row( children: [ if (!WriteGuard.isReadonly(ref)) ...[ - ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add, size: 16), - label: const Text('新建'), + WriteGuard( + child: ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: const Text('新建'), + ), ), const SizedBox(width: 8), ], @@ -422,14 +424,18 @@ class _ProductsScreenState extends ConsumerState { actions: WriteGuard.isReadonly(ref) ? const [] : [ - TextButton( - onPressed: onEdit, - child: const Text('编辑', style: TextStyle(fontSize: 13)), + WriteGuard( + child: TextButton( + onPressed: onEdit, + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), ), - TextButton( - onPressed: onDelete, - child: const Text('删除', - style: TextStyle(fontSize: 13, color: AppTheme.danger)), + WriteGuard( + child: TextButton( + onPressed: onDelete, + child: const Text('删除', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), ), ], ); diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 21798eb..dbe3433 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -12,19 +12,21 @@ import '../../core/auth/auth_state.dart'; import '../../widgets/write_guard.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_info.dart'; +import '../../core/config/license_copy.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; import '../../providers/license_provider.dart'; -import '../../repositories/license_repository.dart'; import '../../providers/number_rule_provider.dart'; import '../../providers/user_provider.dart'; import '../../providers/shop_provider.dart'; import '../../models/shop.dart'; class SettingsScreen extends ConsumerStatefulWidget { - const SettingsScreen({super.key}); + /// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。 + final int initialTab; + const SettingsScreen({super.key, this.initialTab = 0}); @override ConsumerState createState() => _SettingsScreenState(); @@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState { Widget build(BuildContext context) { return DefaultTabController( length: 6, + initialIndex: widget.initialTab, child: Column( children: [ Container( @@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState { children: WriteGuard.isReadonly(ref) ? const [] : [ - TextButton( - onPressed: () => - _showEditUserDialog(context, u), - child: const Text('编辑', - style: TextStyle(fontSize: 12)), + WriteGuard( + child: TextButton( + onPressed: () => + _showEditUserDialog(context, u), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), ), - TextButton( - onPressed: () => - _showResetPasswordDialog(context, u), - child: const Text('重置密码', - style: TextStyle(fontSize: 12)), + WriteGuard( + child: TextButton( + onPressed: () => + _showResetPasswordDialog(context, u), + child: const Text('重置密码', + style: TextStyle(fontSize: 12)), + ), ), ], )), @@ -326,6 +333,9 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(height: 16), + // 过期降级说明 + _buildExpiryNotes(), + const SizedBox(height: 16), // 激活码输入区 _buildActivationCard(), ], @@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState { ); } - Color phaseColor; - String phaseText; - 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} 天)'; - } + final Color phaseColor = LicenseCopy.phaseColor(lic.phase); + final String phaseText = LicenseCopy.statusText(lic); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,6 +401,53 @@ class _SettingsScreenState extends ConsumerState { ); } + 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() { return Card( child: Padding( @@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState { 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 isSuperAdmin = currentUser?.role == 'superadmin'; return _BatchImportWidget(isSuperAdmin: isSuperAdmin); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index d77925d..b20eb39 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import '../../core/auth/auth_state.dart'; import '../../widgets/write_guard.dart'; import '../../core/config/app_config.dart'; +import '../../core/config/license_copy.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../providers/connectivity_provider.dart'; @@ -170,6 +171,14 @@ class _AppShellState extends ConsumerState { final user = ref.watch(authStateProvider).user; // 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线 ref.watch(sessionHeartbeatProvider); + // 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。 + ref.listen(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 location = GoRouterState.of(context).matchedLocation; final isMobile = context.isMobile; @@ -538,6 +547,8 @@ class _AppShellState extends ConsumerState { .valueOrNull ?? 'v1.0.0', iconOnly: iconOnly), + _LicenseStatusItem( + lic: licenseInfo, iconOnly: iconOnly), ], ), ); @@ -556,19 +567,8 @@ class _AppShellState extends ConsumerState { } Widget _buildLicenseBanner(LicenseInfo lic) { - final Color bg; - final String msg; - 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} 天后到期,请及时续费'; - } + final bg = LicenseCopy.bannerColor(lic.phase); + final msg = LicenseCopy.banner(lic); return Container( width: double.infinity, color: bg, @@ -584,7 +584,7 @@ class _AppShellState extends ConsumerState { overflow: TextOverflow.ellipsis), ), TextButton( - onPressed: () => context.go('/settings'), + onPressed: () => context.go('/settings?tab=license'), style: TextButton.styleFrom(foregroundColor: Colors.white70), child: const Text('去激活'), ), @@ -594,23 +594,9 @@ class _AppShellState extends ConsumerState { } void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) { - final String title; - final String body; - final Color titleColor; - 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]!; - } + final (title, body) = LicenseCopy.dialog(lic); + final Color titleColor = + lic.phase == 'grace' ? Colors.orange[800]! : AppTheme.danger; showDialog( context: ctx, barrierDismissible: true, @@ -630,7 +616,7 @@ class _AppShellState extends ConsumerState { style: ElevatedButton.styleFrom(backgroundColor: titleColor), onPressed: () { Navigator.pop(_); - ctx.go('/settings'); + ctx.go('/settings?tab=license'); }, 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 { const _StatusDivider(); @override diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 94845b1..741f65d 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -24,6 +24,7 @@ import '../../repositories/product_repository.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../widgets/write_guard.dart'; +import '../../widgets/order_row_actions.dart'; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @@ -306,18 +307,20 @@ class _StockInListScreenState extends ConsumerState { ); final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) - ? ElevatedButton.icon( - onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: Text(isMobile ? '新建' : '新建入库审核单'), - style: isMobile - ? ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ) - : null, + ? WriteGuard( + child: ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + icon: const Icon(Icons.add, size: 16), + label: Text(isMobile ? '新建' : '新建入库审核单'), + style: isMobile + ? ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + ), ) : null; @@ -417,93 +420,59 @@ class _StockInListScreenState extends ConsumerState { ); } - /// 操作按钮列表,表格与移动端卡片共用。 + /// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions], + /// 入库特有的「打标签」通过 afterPrint 注入。 List _orderActions(BuildContext context, StockInOrder o) { - final readonly = WriteGuard.isReadonly(ref); - return [ - TextButton( - onPressed: () => _showDetail(context, o.id), - 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) { - await safePrint(context, () => printStockInOrder(order)); - } - }, - 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') + return buildOrderRowActions( + readonly: WriteGuard.isReadonly(ref), + status: o.status, + orderId: o.id, + onDetail: () => _showDetail(context, o.id), + onPrint: () async { + final order = await ref.read(stockInRepositoryProvider).get(o.id); + if (context.mounted) { + await safePrint(context, () => printStockInOrder(order)); + } + }, + afterPrint: [ TextButton( - onPressed: () => _confirmSettle(context, o.id, 'stock_in'), - child: const Text('结清', - style: TextStyle(fontSize: 12, color: AppTheme.accent)), - ), - if (!readonly && o.status == 'draft') ...[ - TextButton( - onPressed: () => context.go('/stock-in/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('提交', + 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 == '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)), - ), - ], - ]; + onSettle: () => _confirmSettle(context, o.id, 'stock_in'), + onEdit: () => context.go('/stock-in/edit/${o.id}'), + onDelete: () => _confirmDelete(context, o), + onSubmit: () => _confirmSubmit(context, o), + onApprove: () => _confirmApprove(context, o), + onReject: () => _confirmReject(context, o), + ); } /// 入库单:窄屏卡片 diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index ef55a58..b8bd232 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -22,6 +22,7 @@ import '../../providers/tab_state_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../widgets/write_guard.dart'; +import '../../widgets/order_row_actions.dart'; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -312,18 +313,20 @@ class _StockOutListScreenState extends ConsumerState { ); final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) - ? ElevatedButton.icon( - onPressed: () => context.go('/stock-out/new'), - icon: const Icon(Icons.add, size: 16), - label: Text(isMobile ? '新建' : '新建出库审核单'), - style: isMobile - ? ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ) - : null, + ? WriteGuard( + child: ElevatedButton.icon( + onPressed: () => context.go('/stock-out/new'), + icon: const Icon(Icons.add, size: 16), + label: Text(isMobile ? '新建' : '新建出库审核单'), + style: isMobile + ? ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + ), ) : null; @@ -425,61 +428,24 @@ class _StockOutListScreenState extends ConsumerState { /// 操作按钮列表,表格与移动端卡片共用。 List _orderActions(BuildContext context, StockOutOrder o) { - final readonly = WriteGuard.isReadonly(ref); - return [ - TextButton( - onPressed: () => _showDetail(context, o.id), - child: const Text('详情', - style: TextStyle(fontSize: 12, color: AppTheme.primary)), - ), - TextButton( - onPressed: () async { - final order = await ref.read(stockOutRepositoryProvider).get(o.id); - if (context.mounted) { - await safePrint(context, () => printStockOutOrder(order)); - } - }, - child: const Text('打印', - style: TextStyle(fontSize: 12, color: AppTheme.primary)), - ), - 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)), - ), - ], - ]; + return buildOrderRowActions( + readonly: WriteGuard.isReadonly(ref), + status: o.status, + orderId: o.id, + onDetail: () => _showDetail(context, o.id), + onPrint: () async { + 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), + onSubmit: () => _confirmSubmit(context, o), + onApprove: () => _confirmApprove(context, o), + onReject: () => _confirmReject(context, o), + ); } /// 出库单:窄屏卡片 diff --git a/client/lib/widgets/order_row_actions.dart b/client/lib/widgets/order_row_actions.dart new file mode 100644 index 0000000..cb57197 --- /dev/null +++ b/client/lib/widgets/order_row_actions.dart @@ -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 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 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'))), + ], + ]; +} diff --git a/client/lib/widgets/write_guard.dart b/client/lib/widgets/write_guard.dart index 1d11ccd..829aa09 100644 --- a/client/lib/widgets/write_guard.dart +++ b/client/lib/widgets/write_guard.dart @@ -1,26 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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))` 判断。 -/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403, -/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过。 +/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403, +/// 本控件只负责「不误导」:只读不显示、过期显示但不可点。 /// /// 用法: /// ```dart /// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建'))) /// ``` -/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符: +/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符; +/// 过期置灰仍由内层 [WriteGuard] 负责: /// ```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 { final Widget child; - /// 只读时显示的占位控件,默认完全隐藏(不占位)。 + /// 只读角色时显示的占位控件,默认完全隐藏(不占位)。 final Widget placeholder; const WriteGuard({ @@ -29,11 +37,58 @@ class WriteGuard extends ConsumerWidget { this.placeholder = const SizedBox.shrink(), }); - /// 供需要在 collection-if / 复合条件中判断的场景直接调用。 + /// 当前是否只读角色(写按钮应**隐藏**)。供 collection-if / 复合条件直接调用。 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 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))), + ); + }, + ), + ), + ], + ); } } diff --git a/client/test/api_forbidden_flow_test.dart b/client/test/api_forbidden_flow_test.dart new file mode 100644 index 0000000..c1bcace --- /dev/null +++ b/client/test/api_forbidden_flow_test.dart @@ -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()), + ); + + expect(captured, isA()); + 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()), + ); + + expect(captured, isA()); + 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()), + ); + + expect(called, isFalse); + }); + }); +} diff --git a/client/test/license_copy_test.dart b/client/test/license_copy_test.dart new file mode 100644 index 0000000..2f74dde --- /dev/null +++ b/client/test/license_copy_test.dart @@ -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('只读')); + }); + }); +} diff --git a/client/test/partners_screen_test.dart b/client/test/partners_screen_test.dart index f8677e4..a9a42f3 100644 --- a/client/test/partners_screen_test.dart +++ b/client/test/partners_screen_test.dart @@ -4,10 +4,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.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/providers/license_provider.dart'; import 'package:jiu_client/providers/partner_provider.dart'; import 'package:jiu_client/screens/partners/partners_screen.dart'; +/// WriteGuard 内层会 watch licenseProvider;测试里覆写成同步返回 normal 授权, +/// 避免触发真实网络请求(否则留下 pending timer 导致测试失败)。 +class _FakeLicenseNotifier extends LicenseNotifier { + @override + Future build() async => const LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + ); +} + // --------------------------------------------------------------------------- // Fake notifier // --------------------------------------------------------------------------- @@ -80,6 +95,7 @@ Widget _buildApp({ }) { return ProviderScope( overrides: [ + licenseProvider.overrideWith(() => _FakeLicenseNotifier()), supplierListProvider.overrideWith( () => _FakePartnerNotifier('supplier', supplierState), ), @@ -94,6 +110,7 @@ Widget _buildApp({ Widget _buildLoadingApp() { return ProviderScope( overrides: [ + licenseProvider.overrideWith(() => _FakeLicenseNotifier()), supplierListProvider.overrideWith( () => _FakeLoadingPartnerNotifier('supplier'), ), diff --git a/client/test/readonly_write_forbidden_test.dart b/client/test/readonly_write_forbidden_test.dart index d12e17a..36f0022 100644 --- a/client/test/readonly_write_forbidden_test.dart +++ b/client/test/readonly_write_forbidden_test.dart @@ -1,69 +1,156 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/models/product.dart'; -import 'package:jiu_client/providers/product_provider.dart'; -import 'package:jiu_client/screens/products/products_screen.dart'; +import 'package:jiu_client/models/license.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/screens/partners/partners_screen.dart'; + +// 只读角色:写按钮(新建/编辑/删除)应被 WriteGuard 隐藏。 +// 替换原先因 ProductsScreen 重构而 skip 的旧用例,改测稳定的 PartnersScreen。 + +class _FakePartnerNotifier extends PartnerListNotifier { + final AsyncValue> _fixed; + _FakePartnerNotifier(String type, this._fixed) : super(type: type); -class _ReadonlyProductNotifier extends ProductListNotifier { @override - Future> build() async { - return const PageResult(data: [], total: 0, page: 1, pageSize: 20); + Future> build() async { + state = _fixed; + return state.value ?? + const PageResult(data: [], total: 0, page: 1, pageSize: 20); } @override void reload() {} - @override void setKeyword(String keyword) {} - @override void setPage(int page) {} - @override - Future createProduct(Map data) async { - throw const AppException('readonly user', statusCode: 403); - } - + Future createPartner(Map data) async {} @override - Future updateProduct(int id, Map data) async {} - + Future updatePartner(int id, Map data) async {} @override - Future deleteProduct(int id) async {} + Future deletePartner(int id) async {} +} + +class _FakeLicenseNotifier extends LicenseNotifier { + @override + Future build() async => const LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + ); +} + +const _suppliers = PageResult( + 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() { - // TODO: 新建按钮位置已变更,测试待更新 - testWidgets('write operation shows forbidden error for readonly user', - skip: true, (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // 仅隐藏按钮不够:只读账号若绕过 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()), + ); + + // provider 的 onForbidden 把只读提示浮到全局消息通道(用户可见的「403 浮出」)。 + expect(container.read(apiMessageProvider), LicenseCopy.readonlyUserToast); + }); + + testWidgets('只读角色:新建/编辑/删除按钮全部隐藏', (tester) async { tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget( - ProviderScope( - overrides: [ - productListProvider.overrideWith(() => _ReadonlyProductNotifier()), - ], - child: const MaterialApp( - home: Scaffold(body: ProductsScreen()), - ), - ), - ); + await tester.pumpWidget(_buildApp(readonly: true)); 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.enterText(find.byType(TextFormField).at(0), '测试商品'); - await tester.enterText(find.byType(TextFormField).at(1), 'P999'); - - await tester.tap(find.text('保存')); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(find.textContaining('保存失败:readonly user'), findsOneWidget); + expect(find.text('茅台供应商'), findsOneWidget); + expect(find.text('新建'), findsWidgets); + expect(find.byKey(const Key('btn_edit_1')), findsOneWidget); + expect(find.byKey(const Key('btn_delete_1')), findsOneWidget); }); } diff --git a/client/test/stock_in_screen_test.dart b/client/test/stock_in_screen_test.dart index fa94a39..46a7796 100644 --- a/client/test/stock_in_screen_test.dart +++ b/client/test/stock_in_screen_test.dart @@ -5,10 +5,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.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/providers/license_provider.dart'; import 'package:jiu_client/providers/stock_in_provider.dart'; import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart'; +/// WriteGuard 内层会 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。 +class _FakeLicenseNotifier extends LicenseNotifier { + @override + Future build() async => const LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + ); +} + // --------------------------------------------------------------------------- // Fake notifier // --------------------------------------------------------------------------- @@ -102,6 +116,7 @@ Widget _buildApp(AsyncValue> state) { return ProviderScope( overrides: [ + licenseProvider.overrideWith(() => _FakeLicenseNotifier()), stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)), ], child: MaterialApp.router(routerConfig: router), @@ -125,6 +140,7 @@ Widget _buildLoadingApp() { return ProviderScope( overrides: [ + licenseProvider.overrideWith(() => _FakeLicenseNotifier()), stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()), ], child: MaterialApp.router(routerConfig: router), diff --git a/client/test/write_guard_test.dart b/client/test/write_guard_test.dart new file mode 100644 index 0000000..ef0a25e --- /dev/null +++ b/client/test/write_guard_test.dart @@ -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 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); + }); +}