feat(client): 实时授权状态与会话失效处理

- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info
- 账号被停用/删除(401 USER_DISABLED)即强制重新登录
- 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回
- 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 07:34:07 +08:00
parent e41085a878
commit 32bd64d676
23 changed files with 1319 additions and 408 deletions
+55 -32
View File
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/license_copy.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
@@ -170,6 +171,14 @@ class _AppShellState extends ConsumerState<AppShell> {
final user = ref.watch(authStateProvider).user;
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
ref.watch(sessionHeartbeatProvider);
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
ref.listen<String?>(apiMessageProvider, (prev, next) {
if (next == null || next.isEmpty) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(next)));
ref.read(apiMessageProvider.notifier).state = null;
});
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile;
@@ -538,6 +547,8 @@ class _AppShellState extends ConsumerState<AppShell> {
.valueOrNull ??
'v1.0.0',
iconOnly: iconOnly),
_LicenseStatusItem(
lic: licenseInfo, iconOnly: iconOnly),
],
),
);
@@ -556,19 +567,8 @@ class _AppShellState extends ConsumerState<AppShell> {
}
Widget _buildLicenseBanner(LicenseInfo lic) {
final Color bg;
final String msg;
switch (lic.phase) {
case 'locked':
bg = AppTheme.danger;
msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码';
case 'readonly':
bg = const Color(0xFFB71C1C);
msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)';
default: // grace
bg = const Color(0xFFE65100);
msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费';
}
final bg = LicenseCopy.bannerColor(lic.phase);
final msg = LicenseCopy.banner(lic);
return Container(
width: double.infinity,
color: bg,
@@ -584,7 +584,7 @@ class _AppShellState extends ConsumerState<AppShell> {
overflow: TextOverflow.ellipsis),
),
TextButton(
onPressed: () => context.go('/settings'),
onPressed: () => context.go('/settings?tab=license'),
style: TextButton.styleFrom(foregroundColor: Colors.white70),
child: const Text('去激活'),
),
@@ -594,23 +594,9 @@ class _AppShellState extends ConsumerState<AppShell> {
}
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
final String title;
final String body;
final Color titleColor;
switch (lic.phase) {
case 'locked':
title = '授权已锁定';
body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。';
titleColor = AppTheme.danger;
case 'readonly':
title = '授权已过期 · 只读模式';
body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。';
titleColor = AppTheme.danger;
default: // grace
title = '授权即将到期';
body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。';
titleColor = Colors.orange[800]!;
}
final (title, body) = LicenseCopy.dialog(lic);
final Color titleColor =
lic.phase == 'grace' ? Colors.orange[800]! : AppTheme.danger;
showDialog(
context: ctx,
barrierDismissible: true,
@@ -630,7 +616,7 @@ class _AppShellState extends ConsumerState<AppShell> {
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
onPressed: () {
Navigator.pop(_);
ctx.go('/settings');
ctx.go('/settings?tab=license');
},
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
),
@@ -743,6 +729,43 @@ class _StatusItem extends StatelessWidget {
}
}
/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。
/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。
/// 对应后端 phasenormal / grace / readonly|locked。
class _LicenseStatusItem extends StatelessWidget {
final LicenseInfo? lic;
final bool iconOnly;
const _LicenseStatusItem({required this.lic, this.iconOnly = false});
@override
Widget build(BuildContext context) {
final lic = this.lic;
if (lic == null) return const SizedBox.shrink();
final color = LicenseCopy.phaseColor(lic.phase);
final icon = LicenseCopy.phaseIcon(lic.phase);
final String date = lic.expiresAt == null
? ''
: DateFormat('yyyy-MM-dd').format(lic.expiresAt!);
final String text = LicenseCopy.statusBar(lic, date);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const _StatusDivider(),
Icon(icon, size: 11, color: color),
if (!iconOnly) ...[
const SizedBox(width: 4),
Text(text,
style: TextStyle(
color: color, fontSize: 11, fontWeight: FontWeight.w600)),
],
],
);
}
}
class _StatusDivider extends StatelessWidget {
const _StatusDivider();
@override