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
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:
@@ -244,6 +244,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 被踢下线 / 会话失效:登出后跳回登录页,弹一次提示并清空
|
||||
ref.listen<String?>(sessionEndedMessageProvider, (prev, next) {
|
||||
if (next != null && next.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(next), backgroundColor: AppTheme.danger));
|
||||
ref.read(sessionEndedMessageProvider.notifier).state = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryDark,
|
||||
body: Stack(
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/session.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
|
||||
/// 设备 / 状态管理:本店在线设备/会话列表。
|
||||
/// 所有登录用户只读;管理员/超级管理员可「强制下线」其他登录者。
|
||||
class DeviceManagementScreen extends ConsumerWidget {
|
||||
const DeviceManagementScreen({super.key});
|
||||
|
||||
static final _fmt = DateFormat('MM-dd HH:mm');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncSessions = ref.watch(sessionListProvider);
|
||||
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||
final canKick = role == 'admin' || role == 'superadmin';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('设备 / 状态管理',
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
canKick ? '管理员可强制下线其他登录设备' : '仅查看,无操作权限',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: '刷新',
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () =>
|
||||
ref.read(sessionListProvider.notifier).reload(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: asyncSessions.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败:$e',
|
||||
style:
|
||||
const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(sessionListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (sessions) =>
|
||||
_buildTable(context, ref, sessions, canKick),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTable(BuildContext context, WidgetRef ref,
|
||||
List<DeviceSession> sessions, bool canKick) {
|
||||
Widget onlineBadge(bool online) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle,
|
||||
size: 9,
|
||||
color: online ? AppTheme.success : AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(online ? '在线' : '离线',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color:
|
||||
online ? AppTheme.success : AppTheme.textSecondary)),
|
||||
],
|
||||
);
|
||||
|
||||
String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t);
|
||||
|
||||
Widget? kickButton(DeviceSession s, {bool dense = false}) {
|
||||
if (!canKick) return null;
|
||||
if (s.isCurrent) {
|
||||
return const Text('当前设备',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary));
|
||||
}
|
||||
return TextButton(
|
||||
key: Key('btn_kick_${s.id}'),
|
||||
onPressed: () => _confirmKick(context, ref, s),
|
||||
child: Text('强制下线',
|
||||
style: TextStyle(
|
||||
fontSize: dense ? 13 : 12, color: AppTheme.danger)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget sessionCard(DeviceSession s) {
|
||||
final action = kickButton(s, dense: true);
|
||||
return MobileListCard(
|
||||
title: Text(s.username.isEmpty ? '用户#${s.userId}' : s.username),
|
||||
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
|
||||
trailing: onlineBadge(s.online),
|
||||
fields: [
|
||||
if (s.deviceName.isNotEmpty) MobileCardField('设备', s.deviceName),
|
||||
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
|
||||
MobileCardField('登录时间', fmt(s.createdAt)),
|
||||
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
|
||||
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
|
||||
],
|
||||
actions: action == null ? null : [action],
|
||||
);
|
||||
}
|
||||
|
||||
return DataTableCard(
|
||||
mobileCards: sessions.map(sessionCard).toList(),
|
||||
columns: const [
|
||||
DataColumn(label: Text('用户')),
|
||||
DataColumn(label: Text('平台')),
|
||||
DataColumn(label: Text('设备')),
|
||||
DataColumn(label: Text('IP')),
|
||||
DataColumn(label: Text('登录时间')),
|
||||
DataColumn(label: Text('最近活跃')),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: sessions.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(Text('暂无在线设备',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: sessions.map((s) {
|
||||
return DataRow(cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(s.username.isEmpty ? '用户#${s.userId}' : s.username,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w500)),
|
||||
if (s.isCurrent) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('本机',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')),
|
||||
DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)),
|
||||
DataCell(Text(s.ip.isEmpty ? '-' : s.ip,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(fmt(s.createdAt))),
|
||||
DataCell(Text(fmt(s.lastSeenAt))),
|
||||
DataCell(onlineBadge(s.online)),
|
||||
DataCell(kickButton(s) ?? const SizedBox()),
|
||||
]);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmKick(
|
||||
BuildContext context, WidgetRef ref, DeviceSession s) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('强制下线'),
|
||||
content: Text(
|
||||
'确认将「${s.username.isEmpty ? '用户#${s.userId}' : s.username}」的'
|
||||
'${s.platformLabel}设备下线?该设备约 30 秒内退出登录。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('强制下线'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref.read(sessionListProvider.notifier).forceLogout(s.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已下线'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../../core/config/app_config.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/session_heartbeat.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
@@ -72,6 +73,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
path: '/finance'),
|
||||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||||
_NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'),
|
||||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||||
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
|
||||
];
|
||||
@@ -166,6 +168,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||||
ref.watch(sessionHeartbeatProvider);
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final isMobile = context.isMobile;
|
||||
@@ -237,35 +241,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
],
|
||||
const Spacer(),
|
||||
if (user != null) ...[
|
||||
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
|
||||
if (!isMobile) ...[
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
_showShopPanel(context, user, version: appVersion),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.business,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.shopNo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.keyboard_arrow_down,
|
||||
color: Colors.white70),
|
||||
@@ -324,6 +300,53 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// 当前登录账号(顶栏移下来的用户名):点击弹门店/账号/版本面板
|
||||
if (user != null) ...[
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showShopPanel(context, user,
|
||||
version: appVersion),
|
||||
hoverColor: Colors.white.withAlpha(13),
|
||||
splashColor: Colors.white.withAlpha(26),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _sidebarExpanded ? 16 : 3),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: _sidebarExpanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white60,
|
||||
size: 20),
|
||||
if (_sidebarExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口)
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
SizedBox(
|
||||
|
||||
Reference in New Issue
Block a user