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:
wangjia
2026-06-19 07:34:07 +08:00
parent e41085a878
commit 32bd64d676
23 changed files with 1319 additions and 408 deletions
+107 -20
View File
@@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../config/license_copy.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/license_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(
token: user?.accessToken,
refreshToken: user?.refreshToken,
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
onTokensRefreshed: (accessToken, refreshToken) {
ref
.read(authStateProvider.notifier)
.updateAccessToken(accessToken, refreshToken);
},
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
onConnectionError: () {
ref.read(connectivityProvider.notifier).forceCheck();
},
onForbidden: (body) {
final map = body is Map ? body : const <String, dynamic>{};
final code = map['code'];
final phase = map['phase'];
// 只读角色写被拒:直接提示(无需刷新授权)。
if (code == 'READONLY_USER') {
ref.read(apiMessageProvider.notifier).state =
LicenseCopy.readonlyUserToast;
return;
}
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
if (phase == 'readonly' || phase == 'locked') {
ref.read(licenseProvider.notifier).refresh().then((_) {
final lic = ref.read(licenseProvider).valueOrNull;
ref.read(apiMessageProvider.notifier).state = lic != null
? LicenseCopy.writeBlockedToast(lic)
: '授权已过期,无法执行写操作';
});
}
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
},
);
ref.onDispose(client.dispose);
return client;
@@ -53,13 +78,28 @@ class ApiClient {
late final Dio _dio;
bool _disposed = false;
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
String? _refreshToken;
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
Future<String?>? _refreshing;
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
void Function(String? reason)? _onAuthFailed;
ApiClient({
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
void Function(dynamic body)? onForbidden,
}) {
_refreshToken = refreshToken;
_onTokensRefreshed = onTokensRefreshed;
_onAuthFailed = onAuthFailed;
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
@@ -98,29 +138,37 @@ class ApiClient {
);
}
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
if (e.response?.statusCode == 403) {
if (!_disposed) onForbidden?.call(e.response?.data);
return handler.next(e);
}
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
if (e.response?.statusCode == 401 &&
e.response?.data is Map &&
e.response?.data['code'] == 'USER_DISABLED') {
if (!_disposed) {
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
}
return handler.next(e);
}
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
final newToken = await _refreshAccessToken();
if (newToken == null) {
return handler.next(e);
}
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newToken = resp.data['data']['access_token'] as String;
_dio.options.headers['Authorization'] = 'Bearer $newToken';
if (!_disposed) onTokenRefreshed?.call(newToken);
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) onAuthFailed?.call(reason);
} catch (retryErr) {
return handler.next(retryErr is DioException ? retryErr : e);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
@@ -131,6 +179,45 @@ class ApiClient {
);
}
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
Future<String?> _refreshAccessToken() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': _refreshToken,
});
final data = resp.data['data'] as Map;
final newAccess = data['access_token'] as String;
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
_refreshToken = newRefresh;
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
if (!_disposed && newRefresh != null) {
_onTokensRefreshed?.call(newAccess, newRefresh);
}
return newAccess;
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) _onAuthFailed?.call(reason);
return null;
}
}
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
@visibleForTesting
Dio get dioForTest => _dio;
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
+63 -23
View File
@@ -45,6 +45,22 @@ class AuthState {
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
/// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。
/// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后
/// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。
int _sessionGen = 0;
/// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化
/// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。
Future<void> _storageQueue = Future.value();
Future<void> _runStorage(Future<void> Function() op) {
final next = _storageQueue.then((_) => op());
// 吞掉单次写入异常,避免队列被卡死。
_storageQueue = next.catchError((_) {});
return next;
}
/// Called at app startup to restore persisted session.
Future<void> restore() async {
try {
@@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> login(AuthUser user) async {
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kShopNo, user.shopNo);
await prefs.setString(_kShopId, user.shopId.toString());
await prefs.setString(_kRole, user.role);
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
final gen = ++_sessionGen;
await _runStorage(() async {
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kShopNo, user.shopNo);
await prefs.setString(_kShopId, user.shopId.toString());
await prefs.setString(_kRole, user.role);
});
state = AuthState(initialized: true, user: user);
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
}
void updateAccessToken(String newToken) {
/// 续期后写回令牌。后端会**轮换 refresh token**jti 轮换 + 盗用检测),
/// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token
/// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。
/// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。
void updateAccessToken(String newToken, [String? newRefreshToken]) {
if (state.user == null) return;
SharedPreferences.getInstance()
.then((prefs) => prefs.setString(_kAccessToken, newToken));
final gen = _sessionGen;
final refreshToken = newRefreshToken ?? state.user!.refreshToken;
// fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入,
// 避免把已失效的 token 写回 SharedPreferences。
_runStorage(() async {
if (gen != _sessionGen) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, newToken);
await prefs.setString(_kRefreshToken, refreshToken);
});
state = AuthState(
initialized: true,
user: AuthUser(
accessToken: newToken,
refreshToken: state.user!.refreshToken,
refreshToken: refreshToken,
username: state.user!.username,
realName: state.user!.realName,
shopNo: state.user!.shopNo,
@@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
final token = state.user?.accessToken;
// 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。
_sessionGen++;
state = const AuthState(initialized: true);
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
if (token != null && token.isNotEmpty) {
try {
await Dio(BaseOptions(
@@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
// ignore: 后端不可达或会话已失效都无所谓
}
}
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
await prefs.remove(_kRole);
state = const AuthState(initialized: true);
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
await _runStorage(() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
await prefs.remove(_kRole);
});
}
}
@@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
final apiMessageProvider = StateProvider<String?>((ref) => null);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
+159
View File
@@ -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 天:系统锁定,无法登录,需续费或激活新授权码后恢复',
];
}
+13 -1
View File
@@ -142,7 +142,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
productId: int.parse(state.pathParameters['id']!)))),
GoRoute(
path: '/settings',
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
pageBuilder: (_, state) {
const tabIndex = {
'shop': 0,
'users': 1,
'number': 2,
'system': 3,
'license': 4,
'import': 5,
};
final tab =
tabIndex[state.uri.queryParameters['tab']] ?? 0;
return _noTransition(SettingsScreen(initialTab: tab));
}),
GoRoute(
path: '/devices',
pageBuilder: (_, __) =>
+7
View File
@@ -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';
@@ -32,4 +32,27 @@ class LicenseNotifier extends AsyncNotifier<LicenseInfo?> {
state = const AsyncValue.loading();
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);
}
}
+18 -6
View File
@@ -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<SessionHeartbeat>((ref) {
final hb = SessionHeartbeat(ref);
ref.onDispose(hb.dispose);
@@ -28,10 +32,18 @@ class SessionHeartbeat {
Future<void> _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');
}
}
+18 -12
View File
@@ -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),
@@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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 {
final ctrl = TextEditingController(text: item.remark);
final saved = await showDialog<String>(
@@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'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<InventoryListScreen> {
],
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<InventoryListScreen> {
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<InventoryListScreen> {
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),
],
@@ -170,16 +170,20 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
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<PartnersScreen> {
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<PartnersScreen> {
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)),
),
),
],
)),
@@ -358,10 +358,12 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
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<ProductsScreen> {
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)),
),
),
],
);
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
@@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget build(BuildContext context) {
return DefaultTabController(
length: 6,
initialIndex: widget.initialTab,
child: Column(
children: [
Container(
@@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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<SettingsScreen> {
),
),
const SizedBox(height: 16),
// 过期降级说明
_buildExpiryNotes(),
const SizedBox(height: 16),
// 激活码输入区
_buildActivationCard(),
],
@@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
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<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() {
return Card(
child: Padding(
@@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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);
+55 -32
View File
@@ -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<AppShell> {
final user = ref.watch(authStateProvider).user;
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
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 location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile;
@@ -538,6 +547,8 @@ class _AppShellState extends ConsumerState<AppShell> {
.valueOrNull ??
'v1.0.0',
iconOnly: iconOnly),
_LicenseStatusItem(
lic: licenseInfo, iconOnly: iconOnly),
],
),
);
@@ -556,19 +567,8 @@ class _AppShellState extends ConsumerState<AppShell> {
}
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<AppShell> {
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<AppShell> {
}
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<AppShell> {
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(红)。
/// 对应后端 phasenormal / 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
@@ -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<StockInListScreen> {
);
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<StockInListScreen> {
);
}
/// 操作按钮列表,表格与移动端卡片共用。
/// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions]
/// 入库特有的「打标签」通过 afterPrint 注入。
List<Widget> _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),
);
}
/// 入库单:窄屏卡片
@@ -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<StockOutListScreen> {
);
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<StockOutListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _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),
);
}
/// 出库单:窄屏卡片
+54
View File
@@ -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'))),
],
];
}
+64 -9
View File
@@ -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))),
);
},
),
),
],
);
}
}
+83
View File
@@ -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('授权过期 403phase=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);
});
});
}
+74
View File
@@ -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('只读'));
});
});
}
+17
View File
@@ -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<LicenseInfo?> 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'),
),
+126 -39
View File
@@ -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<PageResult<Partner>> _fixed;
_FakePartnerNotifier(String type, this._fixed) : super(type: type);
class _ReadonlyProductNotifier extends ProductListNotifier {
@override
Future<PageResult<Product>> build() async {
return const PageResult(data: [], total: 0, page: 1, pageSize: 20);
Future<PageResult<Partner>> 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<void> createProduct(Map<String, dynamic> data) async {
throw const AppException('readonly user', statusCode: 403);
}
Future<void> createPartner(Map<String, dynamic> data) async {}
@override
Future<void> updateProduct(int id, Map<String, dynamic> data) async {}
Future<void> updatePartner(int id, Map<String, dynamic> data) async {}
@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() {
// 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<DioException>()),
);
// 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);
});
}
+16
View File
@@ -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<LicenseInfo?> build() async => const LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
);
}
// ---------------------------------------------------------------------------
// Fake notifier
// ---------------------------------------------------------------------------
@@ -102,6 +116,7 @@ Widget _buildApp(AsyncValue<PageResult<StockInOrder>> 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),
+132
View File
@@ -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);
});
}