Files
jiu/client/lib/screens/devices/device_management_screen.dart
wangjia 2d28b067d8 feat(client): 设备管理屏在线状态徽章 → StatusPill + golden
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
2026-06-25 13:41:07 +08:00

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));
}
}
}
}