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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user