Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3e8cf7037 | |||
| 435e02278d | |||
| 64a64e7c0a | |||
| 2ac1cbfb24 | |||
| f2f7ab77fa | |||
| cf468f51cf | |||
| 90f318e246 |
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.58] - 2026-06-18
|
||||
|
||||
### 修复
|
||||
- 恢复 Windows 桌面版的自动构建与发布:上一版因构建环境问题未能产出 Windows 安装包,本版已修复,Windows 用户可正常下载安装
|
||||
|
||||
## [1.0.57] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
- 新增「设备 / 状态管理」页面:可查看本店当前在线的设备与登录会话(用户、平台、登录时间、最近活跃、在线状态);管理员/超级管理员可一键将指定设备强制下线
|
||||
|
||||
### 改进
|
||||
- 顶栏精简,不再显示门店号;用户名移到左侧导航栏底部,点击可查看门店 / 账号 / 版本信息
|
||||
- 账号在其他设备登录或会话失效时,自动退出并给出明确提示
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.57] - 2026-06-18
|
||||
|
||||
### 新功能
|
||||
- 未激活门店首次登录自动获得 30 天试用:此前手动建店或老数据无授权时既不提示也不限制,现在登录即开通试用,到期前会正常提醒并按阶段降级
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -127,6 +128,11 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据
|
||||
// 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT
|
||||
// 的 lic_exp 带上新试用到期日。
|
||||
s.ensureTrialOnFirstUse(shop.ID)
|
||||
|
||||
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
|
||||
pclass := model.PlatformClass(dev.Platform)
|
||||
quota := s.effectiveQuota(&shop, pclass)
|
||||
@@ -416,6 +422,25 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
|
||||
}
|
||||
|
||||
// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial,
|
||||
// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。
|
||||
// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。
|
||||
// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。
|
||||
func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) {
|
||||
var count int64
|
||||
if err := s.db.Model(&model.License{}).
|
||||
Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil {
|
||||
log.Printf("[license] count licenses for shop %d failed: %v", shopID, err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
if err := issueTrialLicense(s.db, shopID); err != nil {
|
||||
log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
var lic model.License
|
||||
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
||||
|
||||
@@ -2,10 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,42 @@ func TestAuthService_Login_Success(t *testing.T) {
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
}
|
||||
|
||||
func TestAuthService_Login_AutoTrialOnFirstUse(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
priv, _, err := util.GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
config.C.License.Ed25519PrivateKey = priv
|
||||
|
||||
shop := testutil.CreateTestShop(db, "TRIAL001")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
// 前置:门店「未激活」——无任何授权行
|
||||
var before int64
|
||||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&before)
|
||||
require.EqualValues(t, 0, before)
|
||||
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// 首次登录 → 自动签发 30 天 trial
|
||||
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var lic model.License
|
||||
require.NoError(t, db.Where("shop_id = ? AND is_active = 1", shop.ID).First(&lic).Error)
|
||||
assert.Equal(t, "trial", lic.Type)
|
||||
assert.True(t, lic.IsActive)
|
||||
require.NotNil(t, lic.ExpiresAt)
|
||||
days := time.Until(*lic.ExpiresAt).Hours() / 24
|
||||
assert.InDelta(t, 30, days, 1, "试用期应约为 30 天")
|
||||
|
||||
// 再次登录不重复发放
|
||||
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
var count int64
|
||||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&count)
|
||||
assert.EqualValues(t, 1, count, "已有有效授权时不应再发放 trial")
|
||||
}
|
||||
|
||||
func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HOTEL002")
|
||||
|
||||
@@ -143,12 +143,13 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
||||
Delete(&model.LicenseDevice{}).Error
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
|
||||
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
// issueTrialLicense 为门店签发并写入一条 30 天 trial license。
|
||||
// 私钥未配置或签发/落库失败时返回 error,由调用方决定如何处理(注册路径 Fatal、
|
||||
// 登录路径仅记日志)。
|
||||
func issueTrialLicense(db *gorm.DB, shopID uint64) error {
|
||||
privKey := config.C.License.Ed25519PrivateKey
|
||||
if privKey == "" {
|
||||
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
|
||||
return fmt.Errorf("ed25519 private key not configured")
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
@@ -162,8 +163,7 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
}
|
||||
token, err := util.IssueLicenseToken(payload, privKey)
|
||||
if err != nil {
|
||||
log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
lic := model.License{
|
||||
@@ -174,7 +174,16 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
IsActive: true,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
return db.Create(&lic).Error
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
|
||||
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
if config.C.License.Ed25519PrivateKey == "" {
|
||||
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
|
||||
}
|
||||
if err := issueTrialLicense(tx, shopID); err != nil {
|
||||
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
onTokenRefreshed: (newToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
},
|
||||
onAuthFailed: () {
|
||||
onAuthFailed: (reason) {
|
||||
if (ref.read(authStateProvider).isLoggedIn) {
|
||||
if (reason != null) {
|
||||
ref.read(sessionEndedMessageProvider.notifier).state = reason;
|
||||
}
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
@@ -54,7 +57,7 @@ class ApiClient {
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function()? onAuthFailed,
|
||||
void Function(String? reason)? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
@@ -110,7 +113,14 @@ class ApiClient {
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) onAuthFailed?.call(reason);
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
const _kAccessToken = 'access_token';
|
||||
const _kRefreshToken = 'refresh_token';
|
||||
@@ -111,6 +113,20 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> logout() async {
|
||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
final token = state.user?.accessToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
try {
|
||||
await Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 4),
|
||||
receiveTimeout: const Duration(seconds: 4),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
)).post('/auth/logout');
|
||||
} catch (_) {
|
||||
// ignore: 后端不可达或会话已失效都无所谓
|
||||
}
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
@@ -127,6 +143,9 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(),
|
||||
);
|
||||
|
||||
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
||||
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../screens/public/public_product_screen.dart';
|
||||
import '../../screens/public/public_shop_products_screen.dart';
|
||||
import '../../screens/settings/settings_screen.dart';
|
||||
import '../../screens/about/about_screen.dart';
|
||||
import '../../screens/devices/device_management_screen.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
Page<void> _noTransition(Widget child) =>
|
||||
@@ -142,6 +143,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
||||
GoRoute(
|
||||
path: '/devices',
|
||||
pageBuilder: (_, __) =>
|
||||
_noTransition(const DeviceManagementScreen())),
|
||||
GoRoute(
|
||||
path: '/about',
|
||||
pageBuilder: (_, __) => _noTransition(const AboutScreen())),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// 在线会话/设备(对应后端 service.SessionView)。
|
||||
class DeviceSession {
|
||||
final int id;
|
||||
final int userId;
|
||||
final String username;
|
||||
final String realName;
|
||||
final String platform;
|
||||
final String platformClass;
|
||||
final String deviceName;
|
||||
final String ip;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final bool online;
|
||||
final bool isCurrent;
|
||||
|
||||
const DeviceSession({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.realName,
|
||||
required this.platform,
|
||||
required this.platformClass,
|
||||
required this.deviceName,
|
||||
required this.ip,
|
||||
required this.createdAt,
|
||||
required this.lastSeenAt,
|
||||
required this.online,
|
||||
required this.isCurrent,
|
||||
});
|
||||
|
||||
factory DeviceSession.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parse(String? s) =>
|
||||
(s == null || s.isEmpty) ? null : DateTime.tryParse(s)?.toLocal();
|
||||
return DeviceSession(
|
||||
id: (json['id'] as num).toInt(),
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
username: json['username'] as String? ?? '',
|
||||
realName: json['real_name'] as String? ?? '',
|
||||
platform: json['platform'] as String? ?? '',
|
||||
platformClass: json['platform_class'] as String? ?? '',
|
||||
deviceName: json['device_name'] as String? ?? '',
|
||||
ip: json['ip'] as String? ?? '',
|
||||
createdAt: parse(json['created_at'] as String?),
|
||||
lastSeenAt: parse(json['last_seen_at'] as String?),
|
||||
online: json['online'] as bool? ?? false,
|
||||
isCurrent: json['is_current'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 平台中文显示名。
|
||||
String get platformLabel {
|
||||
switch (platform) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'android':
|
||||
return 'Android';
|
||||
case 'ios':
|
||||
return 'iOS';
|
||||
case 'web':
|
||||
return '网页版';
|
||||
default:
|
||||
return platform.isEmpty ? '未知' : platform;
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台类中文显示名。
|
||||
String get platformClassLabel {
|
||||
switch (platformClass) {
|
||||
case 'desktop':
|
||||
return '桌面端';
|
||||
case 'mobile':
|
||||
return '移动端';
|
||||
case 'web':
|
||||
return '网页端';
|
||||
default:
|
||||
return platformClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
|
||||
/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。
|
||||
/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
||||
/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout,
|
||||
/// 因此空闲用户也能在 ~30s 内感知到被下线。
|
||||
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
|
||||
final hb = SessionHeartbeat(ref);
|
||||
ref.onDispose(hb.dispose);
|
||||
hb.start();
|
||||
return hb;
|
||||
});
|
||||
|
||||
class SessionHeartbeat {
|
||||
final Ref _ref;
|
||||
Timer? _timer;
|
||||
|
||||
SessionHeartbeat(this._ref);
|
||||
|
||||
void start() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _ping());
|
||||
}
|
||||
|
||||
Future<void> _ping() async {
|
||||
if (!_ref.read(authStateProvider).isLoggedIn) return;
|
||||
try {
|
||||
await _ref.read(apiClientProvider).post('/auth/ping');
|
||||
} catch (e) {
|
||||
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略
|
||||
debugPrint('[Heartbeat] ping failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _timer?.cancel();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../models/session.dart';
|
||||
import '../repositories/session_repository.dart';
|
||||
|
||||
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final sessionListProvider =
|
||||
AsyncNotifierProvider<SessionListNotifier, List<DeviceSession>>(
|
||||
SessionListNotifier.new,
|
||||
);
|
||||
|
||||
class SessionListNotifier extends AsyncNotifier<List<DeviceSession>> {
|
||||
List<DeviceSession> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<DeviceSession>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forceLogout(int id) async {
|
||||
await ref.read(sessionRepositoryProvider).forceLogout(id);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/device/device_id.dart';
|
||||
|
||||
typedef AuthLoginFn = Future<AuthUser> Function({
|
||||
required String shopCode,
|
||||
@@ -37,10 +38,15 @@ class AuthRepository {
|
||||
);
|
||||
}
|
||||
try {
|
||||
final deviceId = await DeviceId.get();
|
||||
final platform = DeviceId.platformName;
|
||||
final resp = await PublicApiClient.post('/auth/login', data: {
|
||||
'shop_code': shopCode,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_id': deviceId,
|
||||
'device_name': platform,
|
||||
'platform': platform,
|
||||
});
|
||||
|
||||
final data = resp.data['data'] as Map<String, dynamic>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../core/api/api_client.dart';
|
||||
import '../models/session.dart';
|
||||
|
||||
class SessionRepository {
|
||||
final ApiClient _client;
|
||||
SessionRepository(this._client);
|
||||
|
||||
/// GET /api/v1/sessions —— 本店在线会话(所有登录用户只读)
|
||||
Future<List<DeviceSession>> list() async {
|
||||
final resp = await _client.get('/sessions');
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data
|
||||
.map((e) => DeviceSession.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin)
|
||||
Future<void> forceLogout(int id) async {
|
||||
await _client.delete('/sessions/$id');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,7 @@ set -euo pipefail
|
||||
|
||||
TAG="$1"
|
||||
VER="${TAG#client-v}"
|
||||
VER="${VER#v}" # 兼容 build-windows.yml 传裸 v 前缀(如 v1.0.4);pubspec 版本不能带 v
|
||||
|
||||
echo "==> compile-windows: version=${VER}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user