Files
jiu/client/lib/core/auth/auth_state.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

218 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../config/app_constants.dart';
const _kAccessToken = 'access_token';
const _kRefreshToken = 'refresh_token';
const _kUserId = 'user_id';
const _kUsername = 'username';
const _kRealName = 'real_name';
const _kShopNo = 'shop_no';
const _kShopId = 'shop_id';
const _kRole = 'role';
class AuthUser {
final String accessToken;
final String refreshToken;
final int id;
final String username;
final String realName;
final String shopNo;
final int shopId;
final String role;
const AuthUser({
required this.accessToken,
required this.refreshToken,
this.id = 0,
required this.username,
required this.realName,
required this.shopNo,
required this.shopId,
this.role = 'operator',
});
// Kept for ApiClient compatibility
String get token => accessToken;
}
class AuthState {
final AuthUser? user;
final bool initialized;
const AuthState({this.user, this.initialized = false});
bool get isLoggedIn => user != null;
}
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 {
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_kAccessToken);
final refreshToken = prefs.getString(_kRefreshToken);
final username = prefs.getString(_kUsername);
final realName = prefs.getString(_kRealName);
final shopNo = prefs.getString(_kShopNo);
final shopIdStr = prefs.getString(_kShopId);
final role = prefs.getString(_kRole);
final userId = prefs.getInt(_kUserId) ?? 0;
if (accessToken != null && refreshToken != null && username != null) {
state = AuthState(
initialized: true,
user: AuthUser(
accessToken: accessToken,
refreshToken: refreshToken,
id: userId,
username: username,
realName: realName ?? username,
shopNo: shopNo ?? '',
shopId: int.tryParse(shopIdStr ?? '') ?? 0,
role: role ?? 'operator',
),
);
return;
}
} catch (_) {
// Storage unavailable — fall through to logged-out state
}
state = const AuthState(initialized: true);
}
Future<void> login(AuthUser user) async {
debugPrint(
'[Auth] login() called, username=${user.username} shopId=${user.shopId}');
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.setInt(_kUserId, user.id);
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}');
}
/// 续期后写回令牌。后端会**轮换 refresh token**jti 轮换 + 盗用检测),
/// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token
/// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。
/// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。
void updateAccessToken(String newToken, [String? newRefreshToken]) {
if (state.user == null) return;
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: refreshToken,
id: state.user!.id,
username: state.user!.username,
realName: state.user!.realName,
shopNo: state.user!.shopNo,
shopId: state.user!.shopId,
role: state.user!.role,
),
);
}
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(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConstants.authProbeTimeout,
receiveTimeout: AppConstants.authProbeTimeout,
headers: {'Authorization': 'Bearer $token'},
)).post('/auth/logout');
} catch (_) {
// ignore: 后端不可达或会话已失效都无所谓
}
}
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
await _runStorage(() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUserId);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
await prefs.remove(_kRole);
});
}
}
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
final apiMessageProvider = StateProvider<String?>((ref) => null);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly';
});
/// 当前登录用户是否具备管理员权限(admin 或 superadmin)。
/// 与后端 middleware.AdminOnly() 一致(两者皆放行),用于「撤回审核中单据」等操作。
final isAdminProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'admin' || role == 'superadmin';
});
/// 当前登录用户 id(未登录为 0)。用于「撤回本人单据」等需要判断单据归属的场景。
final currentUserIdProvider = Provider<int>((ref) {
return ref.watch(authStateProvider.select((s) => s.user?.id)) ?? 0;
});