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