2d28b067d8
onlineBadge(在线/离线 圆点文字)→ StatusPill(圆点软底 pill, success/okSoft 与 muted/infoSoft);整屏 golden ×三主题(桌面+移动)入回归闸。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
235 lines
8.8 KiB
Dart
235 lines
8.8 KiB
Dart
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/context_tokens.dart';
|
|
import '../../models/session.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../widgets/data_table_card.dart';
|
|
import '../../widgets/kpi_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: TextStyle(
|
|
fontSize: 12, color: context.tokens.muted),
|
|
),
|
|
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: [
|
|
Icon(Icons.error_outline,
|
|
size: 40, color: context.tokens.muted),
|
|
const SizedBox(height: 12),
|
|
Text('加载失败:$e',
|
|
style:
|
|
TextStyle(color: context.tokens.muted)),
|
|
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) {
|
|
// 还原原型 .badge.b-在线/b-离线:圆点软底 pill。
|
|
Widget onlineBadge(bool online) => online
|
|
? StatusPill(
|
|
label: '在线',
|
|
color: context.tokens.success,
|
|
background: context.tokens.okSoft)
|
|
: StatusPill(
|
|
label: '离线',
|
|
color: context.tokens.muted,
|
|
background: context.tokens.infoSoft);
|
|
|
|
String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t);
|
|
|
|
Widget? kickButton(DeviceSession s, {bool dense = false}) {
|
|
if (!canKick) return null;
|
|
if (s.isCurrent) {
|
|
return Text('当前设备',
|
|
style: TextStyle(fontSize: 12, color: context.tokens.muted));
|
|
}
|
|
return TextButton(
|
|
key: Key('btn_kick_${s.id}'),
|
|
onPressed: () => _confirmKick(context, ref, s),
|
|
child: Text('强制下线',
|
|
style: TextStyle(
|
|
fontSize: dense ? 13 : 12, color: context.tokens.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
|
|
? [
|
|
DataRow(cells: [
|
|
DataCell(Text('暂无在线设备',
|
|
style: TextStyle(color: context.tokens.muted))),
|
|
const DataCell(SizedBox()),
|
|
const DataCell(SizedBox()),
|
|
const DataCell(SizedBox()),
|
|
const DataCell(SizedBox()),
|
|
const DataCell(SizedBox()),
|
|
const DataCell(SizedBox()),
|
|
const 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: context.tokens.primary.withAlpha(26),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text('本机',
|
|
style: TextStyle(
|
|
fontSize: 11, color: context.tokens.primary)),
|
|
),
|
|
],
|
|
],
|
|
)),
|
|
DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')),
|
|
DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)),
|
|
DataCell(Text(s.ip.isEmpty ? '-' : s.ip,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
color: context.tokens.muted))),
|
|
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: context.tokens.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(SnackBar(
|
|
content: const Text('已下线'), backgroundColor: context.tokens.success));
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('操作失败:$e'), backgroundColor: context.tokens.danger));
|
|
}
|
|
}
|
|
}
|
|
}
|