chore: release client-v1.0.57
Deploy Client / build-windows (push) Failing after 21s
Deploy Client / build-client-web (push) Successful in 42s
Deploy Client / build-macos (push) Successful in 2m8s
Deploy Client / build-android (push) Successful in 1m25s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Has been skipped

设备/状态管理屏(查看本店在线设备/会话,管理员可强制下线)、登录携带设备信息、
会话心跳(/auth/ping)、被踢下线/会话失效提示、顶栏精简 + 用户名移至左侧栏底部。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 23:13:05 +08:00
parent 53fa259284
commit 90f318e246
12 changed files with 547 additions and 32 deletions
+13 -3
View File
@@ -33,8 +33,11 @@ final apiClientProvider = Provider<ApiClient>((ref) {
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
},
onAuthFailed: () {
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
if (reason != null) {
ref.read(sessionEndedMessageProvider.notifier).state = reason;
}
ref.read(authStateProvider.notifier).logout();
}
},
@@ -54,7 +57,7 @@ class ApiClient {
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function()? onAuthFailed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
}) {
_dio = Dio(BaseOptions(
@@ -110,7 +113,14 @@ class ApiClient {
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
if (!_disposed) onAuthFailed?.call();
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) onAuthFailed?.call(reason);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
+19
View File
@@ -1,6 +1,8 @@
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';
const _kAccessToken = 'access_token';
const _kRefreshToken = 'refresh_token';
@@ -111,6 +113,20 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
final token = state.user?.accessToken;
if (token != null && token.isNotEmpty) {
try {
await Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 4),
receiveTimeout: const Duration(seconds: 4),
headers: {'Authorization': 'Bearer $token'},
)).post('/auth/logout');
} catch (_) {
// ignore: 后端不可达或会话已失效都无所谓
}
}
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
@@ -127,6 +143,9 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
+5
View File
@@ -17,6 +17,7 @@ import '../../screens/public/public_product_screen.dart';
import '../../screens/public/public_shop_products_screen.dart';
import '../../screens/settings/settings_screen.dart';
import '../../screens/about/about_screen.dart';
import '../../screens/devices/device_management_screen.dart';
import '../auth/auth_state.dart';
Page<void> _noTransition(Widget child) =>
@@ -142,6 +143,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
GoRoute(
path: '/settings',
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
GoRoute(
path: '/devices',
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
GoRoute(
path: '/about',
pageBuilder: (_, __) => _noTransition(const AboutScreen())),