feat: 自动更新、系统设置、安全修复
后端: - 新增 GET /version 版本检查端点(version.go + version.yaml) - 新增 GET /license/info 接口,返回门店授权信息 - 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁 - 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE - 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY - 修复 Product Update 全字段覆盖:改用 selective Updates() - 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由) - version.go 配置缺失时返回 500 而非静默降级 前端: - 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗 - 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈 - 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据 - 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发) - 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入 - update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃 - dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
const _baseUrl = 'http://localhost:8080/api/v1';
|
||||
import '../config/app_config.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@@ -31,6 +31,9 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
onConnectionError: () {
|
||||
ref.read(connectivityProvider.notifier).forceCheck();
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
@@ -45,9 +48,10 @@ class ApiClient {
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function()? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
@@ -56,43 +60,46 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 401 auto-refresh 拦截器
|
||||
if (refreshToken != null) {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (DioException e, ErrorInterceptorHandler handler) async {
|
||||
// 如果这个 client 已被替换,忽略所有回调
|
||||
if (_disposed) {
|
||||
return handler.next(e);
|
||||
}
|
||||
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken =
|
||||
resp.data['data']['access_token'] as String;
|
||||
// 原地更新 Dio 默认 headers,后续所有请求生效
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
// 用新 token 重试原请求(此时 _dio 仍存活,不会 adapter closed)
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
}
|
||||
// 网络错误 + 401 拦截器
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (DioException e, ErrorInterceptorHandler handler) async {
|
||||
if (_disposed) return handler.next(e);
|
||||
|
||||
// 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测
|
||||
final isNetworkError = e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout;
|
||||
if (isNetworkError) {
|
||||
onConnectionError?.call();
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken = resp.data['data']['access_token'] as String;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 集中管理应用配置,通过 --dart-define=BASE_URL=... 注入环境变量
|
||||
///
|
||||
/// 开发默认值:http://localhost:8080
|
||||
/// 生产部署示例:flutter run --dart-define=BASE_URL=http://192.168.1.100:8080
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
static const _baseUrl = String.fromEnvironment(
|
||||
'BASE_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
|
||||
static String get baseUrl => _baseUrl;
|
||||
static String get apiBaseUrl => '$_baseUrl/api/v1';
|
||||
static String get healthUrl => '$_baseUrl/health';
|
||||
static String get versionUrl => '$_baseUrl/version';
|
||||
}
|
||||
@@ -82,12 +82,22 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/stock-in/new',
|
||||
pageBuilder: (_, __) => _noTransition(const StockInFormScreen())),
|
||||
GoRoute(
|
||||
path: '/stock-in/edit/:id',
|
||||
pageBuilder: (_, state) => _noTransition(
|
||||
StockInFormScreen(
|
||||
editOrderId: int.parse(state.pathParameters['id']!)))),
|
||||
GoRoute(
|
||||
path: '/stock-out',
|
||||
pageBuilder: (_, __) => _noTransition(const StockOutListScreen())),
|
||||
GoRoute(
|
||||
path: '/stock-out/new',
|
||||
pageBuilder: (_, __) => _noTransition(const StockOutFormScreen())),
|
||||
GoRoute(
|
||||
path: '/stock-out/edit/:id',
|
||||
pageBuilder: (_, state) => _noTransition(
|
||||
StockOutFormScreen(
|
||||
editOrderId: int.parse(state.pathParameters['id']!)))),
|
||||
GoRoute(
|
||||
path: '/inventory',
|
||||
pageBuilder: (_, __) =>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/config/app_config.dart';
|
||||
|
||||
final connectivityProvider =
|
||||
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
|
||||
return ConnectivityNotifier();
|
||||
});
|
||||
|
||||
class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
ConnectivityNotifier() : super(true) {
|
||||
_check(); // immediate first check
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
|
||||
}
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
// Dedicated lightweight Dio — short timeouts, no interceptors
|
||||
final _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 3),
|
||||
receiveTimeout: const Duration(seconds: 3),
|
||||
));
|
||||
|
||||
/// 立即触发一次检测(供外部调用,如 API 请求失败时)
|
||||
Future<void> forceCheck() => _check();
|
||||
|
||||
Future<void> _check() async {
|
||||
try {
|
||||
await _dio.get(AppConfig.healthUrl);
|
||||
if (!state) state = true;
|
||||
} catch (_) {
|
||||
if (state) state = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_dio.close(force: true);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,19 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
int _page = 1;
|
||||
int? _warehouseId;
|
||||
String _keyword = '';
|
||||
PageResult<Inventory>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<Inventory>> build() {
|
||||
Future<PageResult<Inventory>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<Inventory>> _fetch() {
|
||||
@@ -53,10 +61,16 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +81,19 @@ final inventoryLogProvider =
|
||||
|
||||
class InventoryLogNotifier extends AsyncNotifier<PageResult<InventoryLog>> {
|
||||
int _page = 1;
|
||||
PageResult<InventoryLog>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<InventoryLog>> build() {
|
||||
Future<PageResult<InventoryLog>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<InventoryLog>> _fetch() {
|
||||
@@ -88,9 +110,15 @@ class InventoryLogNotifier extends AsyncNotifier<PageResult<InventoryLog>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
|
||||
class LicenseInfo {
|
||||
final String type; // trial / monthly / annual / lifetime
|
||||
final bool isActive;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? activatedAt;
|
||||
|
||||
const LicenseInfo({
|
||||
required this.type,
|
||||
required this.isActive,
|
||||
this.expiresAt,
|
||||
this.activatedAt,
|
||||
});
|
||||
|
||||
factory LicenseInfo.fromJson(Map<String, dynamic> json) {
|
||||
return LicenseInfo(
|
||||
type: json['type'] as String? ?? 'trial',
|
||||
isActive: json['is_active'] as bool? ?? false,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.tryParse(json['expires_at'] as String)
|
||||
: null,
|
||||
activatedAt: json['activated_at'] != null
|
||||
? DateTime.tryParse(json['activated_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'monthly': return '月度授权';
|
||||
case 'annual': return '年度授权';
|
||||
case 'lifetime': return '永久授权';
|
||||
default: return '试用版';
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已过期
|
||||
bool get isExpired =>
|
||||
expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
/// 距到期剩余天数(null = 永久)
|
||||
int? get daysRemaining {
|
||||
if (expiresAt == null) return null;
|
||||
final diff = expiresAt!.difference(DateTime.now()).inDays;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
}
|
||||
|
||||
final licenseProvider =
|
||||
AsyncNotifierProvider<LicenseNotifier, LicenseInfo?>(LicenseNotifier.new);
|
||||
|
||||
class LicenseNotifier extends AsyncNotifier<LicenseInfo?> {
|
||||
@override
|
||||
Future<LicenseInfo?> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<LicenseInfo?> _fetch() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final resp = await client.get('/license/info');
|
||||
final data = resp.data['data'];
|
||||
if (data == null) return null;
|
||||
return LicenseInfo.fromJson(data as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await _fetch());
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,34 @@ final numberRuleListProvider =
|
||||
);
|
||||
|
||||
class NumberRuleListNotifier extends AsyncNotifier<List<NumberRule>> {
|
||||
List<NumberRule> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<NumberRule>> build() {
|
||||
Future<List<NumberRule>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return ref.read(numberRuleRepositoryProvider).list();
|
||||
try {
|
||||
final result = await ref.read(numberRuleRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(numberRuleRepositoryProvider).list());
|
||||
try {
|
||||
final result = await ref.read(numberRuleRepositoryProvider).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> updateRule(int id, Map<String, dynamic> data) async {
|
||||
|
||||
@@ -25,13 +25,21 @@ class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
final String? type;
|
||||
int _page = 1;
|
||||
String _keyword = '';
|
||||
PageResult<Partner>? _cache;
|
||||
|
||||
PartnerListNotifier({this.type});
|
||||
|
||||
@override
|
||||
Future<PageResult<Partner>> build() {
|
||||
Future<PageResult<Partner>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<Partner>> _fetch() {
|
||||
@@ -55,10 +63,16 @@ class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> createPartner(Map<String, dynamic> data) async {
|
||||
|
||||
@@ -18,11 +18,19 @@ class ProductListNotifier extends AsyncNotifier<PageResult<Product>> {
|
||||
int _page = 1;
|
||||
String _keyword = '';
|
||||
int? _categoryId;
|
||||
PageResult<Product>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<Product>> build() {
|
||||
Future<PageResult<Product>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<Product>> _fetch() {
|
||||
@@ -54,10 +62,16 @@ class ProductListNotifier extends AsyncNotifier<PageResult<Product>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> createProduct(Map<String, dynamic> data) async {
|
||||
|
||||
@@ -20,11 +20,19 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
String _status = '';
|
||||
String? _startDate;
|
||||
String? _endDate;
|
||||
PageResult<StockInOrder>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<StockInOrder>> build() {
|
||||
Future<PageResult<StockInOrder>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<StockInOrder>> _fetch() {
|
||||
@@ -56,10 +64,16 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {
|
||||
@@ -67,6 +81,11 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> deleteOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).delete(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> submitOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).submit(id);
|
||||
reload();
|
||||
|
||||
@@ -20,11 +20,19 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
String _status = '';
|
||||
String? _startDate;
|
||||
String? _endDate;
|
||||
PageResult<StockOutOrder>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<StockOutOrder>> build() {
|
||||
Future<PageResult<StockOutOrder>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<StockOutOrder>> _fetch() {
|
||||
@@ -56,10 +64,16 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {
|
||||
@@ -67,6 +81,11 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> deleteOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).delete(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> submitOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).submit(id);
|
||||
reload();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../core/config/app_config.dart';
|
||||
|
||||
// ── 数据模型 ────────────────────────────────────────────────
|
||||
class AppUpdateInfo {
|
||||
final String latestVersion;
|
||||
final int buildNumber;
|
||||
final bool forceUpdate;
|
||||
final String releaseNotes;
|
||||
final Map<String, String> downloadUrls;
|
||||
final bool hasUpdate;
|
||||
|
||||
const AppUpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.buildNumber,
|
||||
required this.forceUpdate,
|
||||
required this.releaseNotes,
|
||||
required this.downloadUrls,
|
||||
required this.hasUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Provider ────────────────────────────────────────────────
|
||||
/// null = 检查失败或无更新数据(不影响主流程)
|
||||
/// AppUpdateInfo with hasUpdate=false = 已是最新版
|
||||
/// AppUpdateInfo with hasUpdate=true = 有新版本
|
||||
final updateProvider =
|
||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
String get _checkUrl => AppConfig.versionUrl;
|
||||
Timer? _timer;
|
||||
bool _dismissed = false; // 用户已手动关闭非强制更新提示
|
||||
|
||||
final _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
ref.onDispose(() {
|
||||
_timer?.cancel();
|
||||
_dio.close(force: true);
|
||||
});
|
||||
|
||||
// 启动后 3 秒延迟首次检查(避免与登录请求竞争)
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final result = await _check();
|
||||
|
||||
// 每小时检查一次
|
||||
_timer = Timer.periodic(const Duration(hours: 1), (_) async {
|
||||
_dismissed = false;
|
||||
final r = await _check();
|
||||
state = AsyncValue.data(r);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 手动触发一次检查(供设置页"检查更新"按钮调用)
|
||||
Future<void> forceCheck() async {
|
||||
_dismissed = false;
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await _check());
|
||||
}
|
||||
|
||||
/// 用户点击"稍后再说"后调用,隐藏 banner(直到下次定时刷新)
|
||||
void dismiss() {
|
||||
_dismissed = true;
|
||||
// 保留数据但 UI 通过 dismissed 状态判断是否显示
|
||||
state = AsyncValue.data(state.valueOrNull);
|
||||
}
|
||||
|
||||
bool get isDismissed => _dismissed;
|
||||
|
||||
Future<AppUpdateInfo?> _check() async {
|
||||
try {
|
||||
final resp = await _dio.get(_checkUrl);
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
|
||||
final latestVersion = data['version'] as String? ?? '0.0.0';
|
||||
final buildNumber = data['build_number'] as int? ?? 0;
|
||||
final forceUpdate = data['force_update'] as bool? ?? false;
|
||||
final releaseNotes = data['release_notes'] as String? ?? '';
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? {};
|
||||
final downloadUrls =
|
||||
rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, info.version);
|
||||
|
||||
return AppUpdateInfo(
|
||||
latestVersion: latestVersion,
|
||||
buildNumber: buildNumber,
|
||||
forceUpdate: forceUpdate,
|
||||
releaseNotes: releaseNotes,
|
||||
downloadUrls: downloadUrls,
|
||||
hasUpdate: hasUpdate,
|
||||
);
|
||||
} catch (_) {
|
||||
return null; // 检查失败静默处理,不影响主业务
|
||||
}
|
||||
}
|
||||
|
||||
/// 语义化版本比较:latest > current → true
|
||||
bool _isNewer(String latest, String current) {
|
||||
final l = _parse(latest);
|
||||
final c = _parse(current);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (l[i] > c[i]) return true;
|
||||
if (l[i] < c[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<int> _parse(String v) {
|
||||
final parts = v.split('.').map((s) => int.tryParse(s) ?? 0).toList();
|
||||
while (parts.length < 3) parts.add(0);
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 版本号 Provider(供状态栏 / 门店信息面板使用)──────────
|
||||
final appVersionProvider = FutureProvider<String>((ref) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return 'v${info.version}';
|
||||
});
|
||||
|
||||
// ── 打开下载链接工具函数 ────────────────────────────────────
|
||||
Future<void> launchUpdateUrl(Map<String, String> downloadUrls) async {
|
||||
String? urlStr;
|
||||
|
||||
if (kIsWeb) {
|
||||
urlStr = downloadUrls['web'];
|
||||
} else if (Platform.isMacOS) {
|
||||
urlStr = downloadUrls['macos'];
|
||||
} else if (Platform.isWindows) {
|
||||
urlStr = downloadUrls['windows'];
|
||||
} else if (Platform.isIOS) {
|
||||
urlStr = downloadUrls['ios'];
|
||||
} else if (Platform.isAndroid) {
|
||||
urlStr = downloadUrls['android'];
|
||||
} else {
|
||||
// Linux 等
|
||||
urlStr = downloadUrls['web'];
|
||||
}
|
||||
|
||||
if (urlStr == null || urlStr.isEmpty) return;
|
||||
|
||||
final uri = Uri.parse(urlStr);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,34 @@ final userListProvider =
|
||||
);
|
||||
|
||||
class UserListNotifier extends AsyncNotifier<List<AppUser>> {
|
||||
List<AppUser> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<AppUser>> build() {
|
||||
Future<List<AppUser>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return ref.read(userRepositoryProvider).list();
|
||||
try {
|
||||
final result = await ref.read(userRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(userRepositoryProvider).list());
|
||||
try {
|
||||
final result = await ref.read(userRepositoryProvider).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> createUser(Map<String, dynamic> data) async {
|
||||
|
||||
@@ -14,16 +14,34 @@ final warehouseListProvider =
|
||||
);
|
||||
|
||||
class WarehouseListNotifier extends AsyncNotifier<List<Warehouse>> {
|
||||
List<Warehouse> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<Warehouse>> build() {
|
||||
Future<List<Warehouse>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return ref.read(warehouseRepositoryProvider).list();
|
||||
try {
|
||||
final result = await ref.read(warehouseRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(warehouseRepositoryProvider).list());
|
||||
try {
|
||||
final result = await ref.read(warehouseRepositoryProvider).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> createWarehouse(Map<String, dynamic> data) async {
|
||||
|
||||
@@ -63,6 +63,28 @@ class StockInRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/stock-in/orders/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '修改入库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _client.delete('/stock-in/orders/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '删除入库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submit(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-in/orders/$id/submit');
|
||||
|
||||
@@ -63,6 +63,28 @@ class StockOutRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/stock-out/orders/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '修改出库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _client.delete('/stock-out/orders/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '删除出库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submit(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-out/orders/$id/submit');
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../models/finance.dart';
|
||||
import '../../providers/finance_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
|
||||
class FinanceScreen extends ConsumerWidget {
|
||||
@@ -42,6 +43,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
|
||||
// We drive fetches by maintaining a Future locally, bypassing the global provider
|
||||
late Future<List<FinanceRecord>> _future;
|
||||
List<FinanceRecord> _allRecords = [];
|
||||
|
||||
Set<String> _filterType = {};
|
||||
Set<String> _filterPartner = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('date', '日期', required: true),
|
||||
ColDef('type', '类型'),
|
||||
ColDef('partner', '往来单位'),
|
||||
ColDef('ref', '关联单据', minWidth: 900),
|
||||
ColDef('amount', '金额'),
|
||||
ColDef('balance', '余额'),
|
||||
ColDef('remark', '备注', minWidth: 1000),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,13 +76,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
page: _page,
|
||||
pageSize: 50,
|
||||
)
|
||||
.then((r) => r.data);
|
||||
.then((r) {
|
||||
_allRecords = r.data;
|
||||
return r.data;
|
||||
});
|
||||
}
|
||||
|
||||
void _refetch() {
|
||||
setState(() => _fetch());
|
||||
}
|
||||
|
||||
List<FinanceRecord> _applyFilters(List<FinanceRecord> all) {
|
||||
return all.where((r) {
|
||||
if (_filterType.isNotEmpty && !_filterType.contains(r.typeLabel)) {
|
||||
return false;
|
||||
}
|
||||
if (_filterPartner.isNotEmpty &&
|
||||
!_filterPartner.contains(r.partnerName ?? '')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<FinanceRecord>>(
|
||||
@@ -76,20 +108,29 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
if (_allRecords.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
_OfflineBanner(onRetry: _refetch),
|
||||
Expanded(child: _buildContent(_applyFilters(_allRecords))),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _refetch, child: const Text('重试')),
|
||||
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildContent(snap.data ?? []);
|
||||
final filtered = _applyFilters(_allRecords);
|
||||
return _buildContent(filtered);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -99,6 +140,106 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
final totalBalance = records.fold(0.0, (s, r) => s + r.balance);
|
||||
final totalPaid = totalAmount - totalBalance;
|
||||
|
||||
// Derive filter options from all loaded records
|
||||
final typeOptions = _allRecords
|
||||
.map((r) => r.typeLabel)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final partnerOptions = _allRecords
|
||||
.map((r) => r.partnerName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// Build visible columns (manual hide + responsive auto-hide by screen width)
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
|
||||
final columns = visibleCols
|
||||
.map((c) => DataColumn(
|
||||
label: Text(c.label),
|
||||
numeric: c.key == 'amount' || c.key == 'balance',
|
||||
))
|
||||
.toList();
|
||||
|
||||
DataCell buildFinanceCell(String key, FinanceRecord r) {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return DataCell(Text(
|
||||
r.recordDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
));
|
||||
case 'type':
|
||||
return DataCell(_TypeBadge(r.typeLabel));
|
||||
case 'partner':
|
||||
return DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.partnerName ?? '-',
|
||||
overflow: TextOverflow.ellipsis),
|
||||
));
|
||||
case 'ref':
|
||||
return DataCell(Text(
|
||||
r.refType != null && r.refId != null
|
||||
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
||||
: '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: AppTheme.primary),
|
||||
));
|
||||
case 'amount':
|
||||
return DataCell(Text(
|
||||
'¥${r.amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
));
|
||||
case 'balance':
|
||||
return DataCell(Text(
|
||||
'¥${r.balance.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color:
|
||||
r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
));
|
||||
case 'remark':
|
||||
return DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.remark ?? '-',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
));
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
}
|
||||
|
||||
final rows = records.isEmpty
|
||||
? [
|
||||
DataRow(
|
||||
cells: List.generate(
|
||||
visibleCols.length,
|
||||
(i) => i == 0
|
||||
? const DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary)))
|
||||
: const DataCell(SizedBox()),
|
||||
),
|
||||
),
|
||||
]
|
||||
: records
|
||||
.map((r) => DataRow(
|
||||
cells: visibleCols
|
||||
.map((c) => buildFinanceCell(c.key, r))
|
||||
.toList(),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary bar (only for type-filtered tabs)
|
||||
@@ -143,85 +284,49 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
_fetch();
|
||||
});
|
||||
},
|
||||
toolbar: Row(
|
||||
toolbar: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Spacer(),
|
||||
_MonthSelector(
|
||||
value: _month,
|
||||
onChanged: (v) {
|
||||
_month = v;
|
||||
_page = 1;
|
||||
_refetch();
|
||||
},
|
||||
// Filter bar + month selector + column toggle
|
||||
Row(
|
||||
children: [
|
||||
if (typeOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '类型',
|
||||
options: typeOptions,
|
||||
selected: _filterType,
|
||||
onChanged: (v) => setState(() => _filterType = v),
|
||||
),
|
||||
if (typeOptions.length > 1) const SizedBox(width: 8),
|
||||
if (partnerOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '往来单位',
|
||||
options: partnerOptions,
|
||||
selected: _filterPartner,
|
||||
onChanged: (v) =>
|
||||
setState(() => _filterPartner = v),
|
||||
),
|
||||
const Spacer(),
|
||||
_MonthSelector(
|
||||
value: _month,
|
||||
onChanged: (v) {
|
||||
_month = v;
|
||||
_page = 1;
|
||||
_refetch();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('日期')),
|
||||
DataColumn(label: Text('类型')),
|
||||
DataColumn(label: Text('往来单位')),
|
||||
DataColumn(label: Text('关联单据')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('余额'), numeric: true),
|
||||
DataColumn(label: Text('备注')),
|
||||
],
|
||||
rows: records.isEmpty
|
||||
? [
|
||||
DataRow(cells: [
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: records
|
||||
.map((r) => DataRow(cells: [
|
||||
DataCell(Text(
|
||||
r.recordDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)),
|
||||
DataCell(_TypeBadge(r.typeLabel)),
|
||||
DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.partnerName ?? '-',
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(
|
||||
r.refType != null && r.refId != null
|
||||
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
||||
: '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: AppTheme.primary),
|
||||
)),
|
||||
DataCell(Text(
|
||||
'¥${r.amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
)),
|
||||
DataCell(Text(
|
||||
'¥${r.balance.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: r.balance > 0
|
||||
? AppTheme.danger
|
||||
: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)),
|
||||
DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.remark ?? '-',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -365,3 +470,34 @@ class _MonthSelector extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OfflineBanner extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
const _OfflineBanner({required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFFFF8E1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text('网络不可用,当前显示离线缓存数据',
|
||||
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF57F17),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../models/inventory.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart';
|
||||
|
||||
class BatchTrackingScreen extends ConsumerStatefulWidget {
|
||||
const BatchTrackingScreen({super.key});
|
||||
@@ -17,6 +18,26 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
int _page = 1;
|
||||
int _total = 0;
|
||||
late Future<List<ProductTrackingRecord>> _future;
|
||||
List<ProductTrackingRecord> _records = [];
|
||||
|
||||
Set<String> _filterStatus = {};
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSupplier = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('product', '商品', required: true),
|
||||
ColDef('spec', '规格', minWidth: 1100),
|
||||
ColDef('batch', '批次号', minWidth: 1000),
|
||||
ColDef('order_no', '入库单号', minWidth: 900),
|
||||
ColDef('supplier', '供应商', minWidth: 1100),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('date', '入库日期', minWidth: 1000),
|
||||
ColDef('qty', '数量'),
|
||||
ColDef('price', '单价', minWidth: 900),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('buyer', '买家/时间'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -30,12 +51,30 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
.listProducts(page: _page, pageSize: 20)
|
||||
.then((r) {
|
||||
_total = r.total;
|
||||
_records = r.data;
|
||||
return r.data;
|
||||
});
|
||||
}
|
||||
|
||||
void _refetch() => setState(() => _fetch());
|
||||
|
||||
List<ProductTrackingRecord> _applyFilters(
|
||||
List<ProductTrackingRecord> all) {
|
||||
return all.where((r) {
|
||||
if (_filterStatus.isNotEmpty) {
|
||||
final label = r.isSoldOut ? '已卖出' : '在售';
|
||||
if (!_filterStatus.contains(label)) return false;
|
||||
}
|
||||
if (_filterWarehouse.isNotEmpty) {
|
||||
if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false;
|
||||
}
|
||||
if (_filterSupplier.isNotEmpty) {
|
||||
if (!_filterSupplier.contains(r.supplierName ?? '')) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<ProductTrackingRecord>>(
|
||||
@@ -45,25 +84,84 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
// 有缓存数据时展示缓存,顶部加提示条
|
||||
if (_records.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
_OfflineBanner(onRetry: _refetch),
|
||||
Expanded(child: _buildTable(_applyFilters(_records))),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _refetch, child: const Text('重试')),
|
||||
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildTable(snap.data ?? []);
|
||||
final filtered = _applyFilters(_records);
|
||||
return _buildTable(filtered);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTable(List<ProductTrackingRecord> records) {
|
||||
// Derive filter options from all loaded records
|
||||
final warehouseOptions = _records
|
||||
.map((r) => r.warehouseName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final supplierOptions = _records
|
||||
.map((r) => r.supplierName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// Build visible columns (respect manual hide + responsive auto-hide)
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
|
||||
final columns = visibleCols
|
||||
.map((c) => DataColumn(
|
||||
label: Text(c.label),
|
||||
numeric: c.key == 'qty' || c.key == 'price',
|
||||
))
|
||||
.toList();
|
||||
|
||||
final rows = records.isEmpty
|
||||
? [
|
||||
DataRow(
|
||||
cells: List.generate(
|
||||
visibleCols.length,
|
||||
(i) => i == 1
|
||||
? const DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary)))
|
||||
: const DataCell(SizedBox()),
|
||||
),
|
||||
),
|
||||
]
|
||||
: records
|
||||
.map((r) => DataRow(
|
||||
cells: visibleCols
|
||||
.map((c) => _buildCell(c.key, r))
|
||||
.toList(),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: _total,
|
||||
page: _page,
|
||||
@@ -71,141 +169,158 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
_page = p;
|
||||
_fetch();
|
||||
}),
|
||||
toolbar: Row(
|
||||
toolbar: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('已审核入库商品(含库存与销售状态)',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: () => setState(() {
|
||||
_page = 1;
|
||||
_fetch();
|
||||
}),
|
||||
tooltip: '刷新',
|
||||
// Top row: description + refresh + column toggle
|
||||
Row(
|
||||
children: [
|
||||
const Text('已审核入库商品(含库存与销售状态)',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: () => setState(() {
|
||||
_page = 1;
|
||||
_fetch();
|
||||
}),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Filter bar row
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
MultiSelectDropdown(
|
||||
label: '状态',
|
||||
options: const ['在售', '已卖出'],
|
||||
selected: _filterStatus,
|
||||
onChanged: (v) => setState(() => _filterStatus = v),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (warehouseOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '仓库',
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
if (warehouseOptions.length > 1) const SizedBox(width: 8),
|
||||
if (supplierOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '供应商',
|
||||
options: supplierOptions,
|
||||
selected: _filterSupplier,
|
||||
onChanged: (v) => setState(() => _filterSupplier = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('商品')),
|
||||
DataColumn(label: Text('规格')),
|
||||
DataColumn(label: Text('批次号')),
|
||||
DataColumn(label: Text('入库单号')),
|
||||
DataColumn(label: Text('供应商')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('入库日期')),
|
||||
DataColumn(label: Text('数量'), numeric: true),
|
||||
DataColumn(label: Text('单价'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('买家/时间')),
|
||||
],
|
||||
rows: records.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: records.map((r) {
|
||||
final batchText =
|
||||
(r.batchNo != null && r.batchNo!.isNotEmpty)
|
||||
? r.batchNo!
|
||||
: null;
|
||||
return DataRow(cells: [
|
||||
DataCell(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(r.productName ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
if (r.productCode != null)
|
||||
Text(r.productCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
fontFamily: 'monospace')),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(r.productSpec ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(batchText != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(batchText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace')),
|
||||
)
|
||||
: const Text('无批次',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r.orderNo ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace'))),
|
||||
DataCell(Text(r.supplierName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(r.warehouseName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
r.orderDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(
|
||||
'¥${r.unitPrice.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
DataCell(_StatusBadge(r.isSoldOut)),
|
||||
DataCell(r.isSoldOut
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
r.buyerName?.isNotEmpty == true
|
||||
? r.buyerName!
|
||||
: '未知买家',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (r.soldAt != null)
|
||||
Text(r.soldAt!.length > 10
|
||||
? r.soldAt!.substring(0, 10)
|
||||
: r.soldAt!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary)),
|
||||
],
|
||||
)
|
||||
: const Text('-',
|
||||
style:
|
||||
TextStyle(color: AppTheme.textSecondary))),
|
||||
]);
|
||||
}).toList(),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
);
|
||||
}
|
||||
|
||||
DataCell _buildCell(String key, ProductTrackingRecord r) {
|
||||
final batchText =
|
||||
(r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null;
|
||||
switch (key) {
|
||||
case 'product':
|
||||
return DataCell(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(r.productName ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
if (r.productCode != null)
|
||||
Text(r.productCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
fontFamily: 'monospace')),
|
||||
],
|
||||
));
|
||||
case 'spec':
|
||||
return DataCell(Text(r.productSpec ?? '-',
|
||||
style: const TextStyle(fontSize: 12)));
|
||||
case 'batch':
|
||||
return DataCell(batchText != null
|
||||
? Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(batchText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace')),
|
||||
)
|
||||
: const Text('无批次',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)));
|
||||
case 'order_no':
|
||||
return DataCell(Text(r.orderNo ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace')));
|
||||
case 'supplier':
|
||||
return DataCell(Text(r.supplierName ?? '-',
|
||||
style: const TextStyle(fontSize: 12)));
|
||||
case 'warehouse':
|
||||
return DataCell(Text(r.warehouseName ?? '-',
|
||||
style: const TextStyle(fontSize: 12)));
|
||||
case 'date':
|
||||
return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12)));
|
||||
case 'qty':
|
||||
return DataCell(Text(
|
||||
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)));
|
||||
case 'price':
|
||||
return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontSize: 13)));
|
||||
case 'status':
|
||||
return DataCell(_StatusBadge(r.isSoldOut));
|
||||
case 'buyer':
|
||||
return DataCell(r.isSoldOut
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
r.buyerName?.isNotEmpty == true ? r.buyerName! : '未知买家',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (r.soldAt != null)
|
||||
Text(
|
||||
r.soldAt!.length > 10
|
||||
? r.soldAt!.substring(0, 10)
|
||||
: r.soldAt!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppTheme.textSecondary)),
|
||||
],
|
||||
)
|
||||
: const Text('-',
|
||||
style: TextStyle(color: AppTheme.textSecondary)));
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
@@ -233,3 +348,34 @@ class _StatusBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OfflineBanner extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
const _OfflineBanner({required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFFFF8E1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text('网络不可用,当前显示离线缓存数据',
|
||||
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF57F17),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -275,8 +277,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -379,8 +383,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
|
||||
@@ -53,8 +53,10 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -92,8 +94,10 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
|
||||
@@ -109,8 +109,10 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
import '../../models/warehouse.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../providers/number_rule_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
@@ -16,10 +21,19 @@ class SettingsScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
// System params local state (UI only, no backend yet)
|
||||
String _sysName = '酒库管理系统';
|
||||
String _sysCurrency = '人民币(CNY)';
|
||||
String _sysDateFormat = 'YYYY-MM-DD';
|
||||
String _sysTimezone = 'Asia/Shanghai (UTC+8)';
|
||||
bool _requireStockInApproval = true;
|
||||
bool _requireStockOutApproval = true;
|
||||
bool _allowOverstock = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 4,
|
||||
length: 5,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -37,6 +51,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Tab(text: '仓库管理'),
|
||||
Tab(text: '编号规则'),
|
||||
Tab(text: '系统参数'),
|
||||
Tab(text: '关于'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -48,6 +63,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_buildWarehousesTab(),
|
||||
_buildNumberRulesTab(),
|
||||
_buildSystemParamsTab(),
|
||||
_buildAboutTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -82,8 +98,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(userListProvider.notifier).reload(),
|
||||
@@ -189,8 +207,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -320,8 +340,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -480,17 +502,55 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
const Text('基本设置',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
|
||||
const Divider(height: 24),
|
||||
_ParamRow(label: '系统名称', value: '酒库管理系统'),
|
||||
_ParamRow(label: '货币单位', value: '人民币(CNY)'),
|
||||
_ParamRow(label: '日期格式', value: 'YYYY-MM-DD'),
|
||||
_ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'),
|
||||
_ParamRow(
|
||||
label: '系统名称',
|
||||
value: _sysName,
|
||||
onEdit: () => _showEditParamDialog('系统名称', _sysName,
|
||||
(v) => setState(() => _sysName = v)),
|
||||
),
|
||||
_ParamRow(
|
||||
label: '货币单位',
|
||||
value: _sysCurrency,
|
||||
onEdit: () => _showEditParamDialog('货币单位', _sysCurrency,
|
||||
(v) => setState(() => _sysCurrency = v)),
|
||||
),
|
||||
_ParamRow(
|
||||
label: '日期格式',
|
||||
value: _sysDateFormat,
|
||||
onEdit: () => _showEditParamDialog('日期格式', _sysDateFormat,
|
||||
(v) => setState(() => _sysDateFormat = v)),
|
||||
),
|
||||
_ParamRow(
|
||||
label: '时区',
|
||||
value: _sysTimezone,
|
||||
onEdit: () => _showEditParamDialog('时区', _sysTimezone,
|
||||
(v) => setState(() => _sysTimezone = v)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('审核设置',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
|
||||
const Divider(height: 24),
|
||||
_ParamRow(label: '入库单需要审核', value: '是', isSwitch: true),
|
||||
_ParamRow(label: '出库单需要审核', value: '是', isSwitch: true),
|
||||
_ParamRow(label: '允许超量出库', value: '否', isSwitch: false),
|
||||
_ParamRow(
|
||||
label: '入库单需要审核',
|
||||
value: _requireStockInApproval ? '是' : '否',
|
||||
switchValue: _requireStockInApproval,
|
||||
onSwitchChanged: (v) =>
|
||||
setState(() => _requireStockInApproval = v),
|
||||
),
|
||||
_ParamRow(
|
||||
label: '出库单需要审核',
|
||||
value: _requireStockOutApproval ? '是' : '否',
|
||||
switchValue: _requireStockOutApproval,
|
||||
onSwitchChanged: (v) =>
|
||||
setState(() => _requireStockOutApproval = v),
|
||||
),
|
||||
_ParamRow(
|
||||
label: '允许超量出库',
|
||||
value: _allowOverstock ? '是' : '否',
|
||||
switchValue: _allowOverstock,
|
||||
onSwitchChanged: (v) =>
|
||||
setState(() => _allowOverstock = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -499,12 +559,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('设置已保存'),
|
||||
backgroundColor: AppTheme.success));
|
||||
},
|
||||
child: const Text('保存设置'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
onPressed: () => setState(() {
|
||||
_sysName = '酒库管理系统';
|
||||
_sysCurrency = '人民币(CNY)';
|
||||
_sysDateFormat = 'YYYY-MM-DD';
|
||||
_sysTimezone = 'Asia/Shanghai (UTC+8)';
|
||||
_requireStockInApproval = true;
|
||||
_requireStockOutApproval = true;
|
||||
_allowOverstock = false;
|
||||
}),
|
||||
child: const Text('重置默认'),
|
||||
),
|
||||
],
|
||||
@@ -514,6 +586,295 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 关于 Tab ─────────────────────────────────────────────
|
||||
Widget _buildAboutTab() {
|
||||
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||
final licenseAsync = ref.watch(licenseProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── 版本信息 ──
|
||||
_AboutSection(
|
||||
title: '版本信息',
|
||||
children: [
|
||||
_AboutRow(label: '当前版本', value: appVersion),
|
||||
if (updateInfo != null && updateInfo.hasUpdate)
|
||||
_AboutRow(
|
||||
label: '最新版本',
|
||||
value: 'v${updateInfo.latestVersion}',
|
||||
valueColor: AppTheme.success,
|
||||
trailing: TextButton(
|
||||
onPressed: () => launchUpdateUrl(updateInfo.downloadUrls),
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
)
|
||||
else
|
||||
_AboutRow(
|
||||
label: '最新版本',
|
||||
value: updateInfo != null ? '已是最新' : '检查中…',
|
||||
valueColor: AppTheme.textSecondary,
|
||||
trailing: TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(updateProvider.notifier).forceCheck(),
|
||||
child: const Text('检查更新'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── 授权信息 ──
|
||||
_AboutSection(
|
||||
title: '授权信息',
|
||||
children: [
|
||||
licenseAsync.when(
|
||||
loading: () => const _AboutRow(label: '授权状态', value: '加载中…'),
|
||||
error: (_, __) =>
|
||||
const _AboutRow(label: '授权状态', value: '暂无授权信息'),
|
||||
data: (lic) {
|
||||
if (lic == null) {
|
||||
return const _AboutRow(label: '授权状态', value: '未激活');
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_AboutRow(label: '授权类型', value: lic.typeLabel),
|
||||
_AboutRow(
|
||||
label: '授权状态',
|
||||
value: lic.isExpired
|
||||
? '已过期'
|
||||
: lic.isActive
|
||||
? '正常'
|
||||
: '已停用',
|
||||
valueColor: lic.isExpired
|
||||
? AppTheme.danger
|
||||
: lic.isActive
|
||||
? AppTheme.success
|
||||
: AppTheme.textSecondary,
|
||||
),
|
||||
if (lic.expiresAt != null)
|
||||
_AboutRow(
|
||||
label: '到期时间',
|
||||
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!),
|
||||
trailing: lic.daysRemaining != null &&
|
||||
lic.daysRemaining! <= 30
|
||||
? Chip(
|
||||
label: Text(
|
||||
lic.isExpired
|
||||
? '已过期'
|
||||
: '剩余 ${lic.daysRemaining} 天',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.white),
|
||||
),
|
||||
backgroundColor: lic.isExpired
|
||||
? AppTheme.danger
|
||||
: Colors.orange,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
const _AboutRow(label: '到期时间', value: '永久有效'),
|
||||
if (lic.activatedAt != null)
|
||||
_AboutRow(
|
||||
label: '激活时间',
|
||||
value: DateFormat('yyyy-MM-dd')
|
||||
.format(lic.activatedAt!),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showRenewDialog(),
|
||||
icon: const Icon(Icons.card_membership, size: 16),
|
||||
label: const Text('续费 / 升级授权'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── 关于我们 ──
|
||||
_AboutSection(
|
||||
title: '关于我们',
|
||||
children: [
|
||||
const _AboutRow(label: '开发商', value: '酒库科技有限公司'),
|
||||
const _AboutRow(label: '官方网站', value: 'https://jiu.example.com'),
|
||||
const _AboutRow(label: '联系邮箱', value: 'support@jiu.example.com'),
|
||||
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse('mailto:support@jiu.example.com'
|
||||
'?subject=酒库管理系统咨询');
|
||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||
},
|
||||
icon: const Icon(Icons.email_outlined, size: 16),
|
||||
label: const Text('发送邮件'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── 意见反馈 ──
|
||||
_AboutSection(
|
||||
title: '意见反馈',
|
||||
children: [
|
||||
const _AboutRow(
|
||||
label: '问题反馈',
|
||||
value: '遇到 Bug 或有功能建议,欢迎告知我们',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showFeedbackDialog(isBug: true),
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 16),
|
||||
label: const Text('反馈 Bug'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showFeedbackDialog(isBug: false),
|
||||
icon: const Icon(Icons.lightbulb_outline, size: 16),
|
||||
label: const Text('功能建议'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRenewDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('续费 / 升级授权'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('请联系我们获取续费报价:'),
|
||||
SizedBox(height: 12),
|
||||
SelectableText('📧 support@jiu.example.com',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
SizedBox(height: 6),
|
||||
SelectableText('📞 400-000-0000',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
const ClipboardData(text: 'support@jiu.example.com'));
|
||||
if (ctx.mounted) {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('邮箱已复制到剪贴板')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('复制邮箱'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFeedbackDialog({required bool isBug}) {
|
||||
final ctrl = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(isBug ? '反馈 Bug' : '功能建议'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
hintText: isBug
|
||||
? '请描述问题的复现步骤和预期行为…'
|
||||
: '请描述您希望增加的功能…',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议');
|
||||
final body = Uri.encodeComponent(ctrl.text);
|
||||
final uri = Uri.parse(
|
||||
'mailto:support@jiu.example.com?subject=$subject&body=$body');
|
||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('通过邮件发送'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditParamDialog(
|
||||
String label, String current, ValueChanged<String> onSave) {
|
||||
final ctrl = TextEditingController(text: current);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('修改$label'),
|
||||
content: SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: label),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final v = ctrl.text.trim();
|
||||
if (v.isNotEmpty) onSave(v);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddUserDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -922,9 +1283,17 @@ class _RoleBadge extends StatelessWidget {
|
||||
class _ParamRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isSwitch;
|
||||
final bool? switchValue;
|
||||
final ValueChanged<bool>? onSwitchChanged;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const _ParamRow({required this.label, required this.value, this.isSwitch});
|
||||
const _ParamRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.switchValue,
|
||||
this.onSwitchChanged,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -935,19 +1304,93 @@ class _ParamRow extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: AppTheme.textSecondary)),
|
||||
),
|
||||
if (isSwitch != null)
|
||||
if (switchValue != null)
|
||||
Switch(
|
||||
value: isSwitch!,
|
||||
onChanged: (_) {},
|
||||
value: switchValue!,
|
||||
onChanged: onSwitchChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
else
|
||||
Text(value,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))),
|
||||
if (onEdit != null)
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('修改', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 关于页辅助 widgets ──────────────────────────────────────
|
||||
|
||||
class _AboutSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
const _AboutSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const Divider(height: 24),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AboutRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
final Widget? trailing;
|
||||
|
||||
const _AboutRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: valueColor ?? AppTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
|
||||
import 'dart:async';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -16,14 +18,55 @@ class AppShell extends ConsumerStatefulWidget {
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
bool _sidebarExpanded = true;
|
||||
final String _loginTime =
|
||||
DateFormat('HH:mm:ss').format(DateTime.now());
|
||||
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||||
bool _forceDialogShown = false;
|
||||
|
||||
void _showForceUpdateDialog(
|
||||
BuildContext context, AppUpdateInfo info) {
|
||||
if (_forceDialogShown) return;
|
||||
_forceDialogShown = true;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.system_update, color: AppTheme.primary),
|
||||
SizedBox(width: 8),
|
||||
Text('发现新版本'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'),
|
||||
if (info.releaseNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(info.releaseNotes,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => launchUpdateUrl(info.downloadUrls),
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<_NavItem> _navItems = const [
|
||||
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
||||
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
||||
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
||||
_NavItem(icon: Icons.track_changes, label: '商品追踪', path: '/batches'),
|
||||
_NavItem(icon: Icons.track_changes, label: '商品管理', path: '/batches'),
|
||||
_NavItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
label: '财务管理',
|
||||
@@ -36,8 +79,13 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
|
||||
final updateNotifier = ref.watch(updateProvider.notifier);
|
||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||
final appVersion =
|
||||
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
@@ -58,24 +106,26 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.wine_bar, color: Colors.white, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'酒库管理系统',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5),
|
||||
),
|
||||
_ShopButton(user: user, version: appVersion),
|
||||
const Spacer(),
|
||||
if (user != null) ...[
|
||||
const Icon(Icons.business,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.shopNo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
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),
|
||||
@@ -158,36 +208,152 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Update banner(非强制更新)
|
||||
if (updateInfo != null &&
|
||||
updateInfo.hasUpdate &&
|
||||
!updateInfo.forceUpdate &&
|
||||
!updateNotifier.isDismissed)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFFFF8E1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.system_update,
|
||||
size: 16, color: Color(0xFFF57F17)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'发现新版本 v${updateInfo.latestVersion}'
|
||||
'${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF5D4037),
|
||||
fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => launchUpdateUrl(
|
||||
updateInfo.downloadUrls),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
const Color(0xFFF57F17)),
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: updateNotifier.dismiss,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
const Color(0xFF9E9E9E)),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog)
|
||||
if (updateInfo != null &&
|
||||
updateInfo.hasUpdate &&
|
||||
updateInfo.forceUpdate)
|
||||
Builder(builder: (ctx) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showForceUpdateDialog(ctx, updateInfo);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
// Offline banner
|
||||
if (!isOnline)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.danger,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off,
|
||||
size: 16, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'网络连接已断开 · 当前处于只读模式,所有写操作已禁用',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: widget.child),
|
||||
// Status bar
|
||||
Container(
|
||||
height: 28,
|
||||
color: const Color(0xFF37474F),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (user != null) ...[
|
||||
_StatusItem(
|
||||
icon: Icons.store,
|
||||
text: '门店编号:${user.shopNo}'),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.person,
|
||||
text: '登录用户:${user.username}'),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.login,
|
||||
text: '登录时间:$_loginTime'),
|
||||
const _StatusDivider(),
|
||||
],
|
||||
const _ClockWidget(),
|
||||
const Spacer(),
|
||||
const _StatusItem(
|
||||
icon: Icons.info_outline,
|
||||
text: 'v1.0.0'),
|
||||
],
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
// Three tiers: wide / medium / narrow
|
||||
final wide = w >= 650;
|
||||
final medium = w >= 190;
|
||||
final iconOnly = !medium;
|
||||
|
||||
return Container(
|
||||
height: 28,
|
||||
color: isOnline
|
||||
? const Color(0xFF37474F)
|
||||
: AppTheme.danger,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!isOnline) ...[
|
||||
const Icon(Icons.wifi_off,
|
||||
size: 11, color: Colors.white70),
|
||||
if (!iconOnly) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Text('离线',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600)),
|
||||
],
|
||||
const _StatusDivider(),
|
||||
],
|
||||
if (isOnline && user != null) ...[
|
||||
_StatusItem(
|
||||
icon: Icons.store,
|
||||
text: user.shopNo,
|
||||
iconOnly: iconOnly),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.person,
|
||||
text: user.username,
|
||||
iconOnly: iconOnly),
|
||||
if (wide) ...[
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.login,
|
||||
text: '登录时间:$_loginTime'),
|
||||
const _StatusDivider(),
|
||||
const _ClockWidget(),
|
||||
] else
|
||||
const _StatusDivider(),
|
||||
],
|
||||
const Spacer(),
|
||||
_StatusItem(
|
||||
icon: isOnline
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.cloud_off_outlined,
|
||||
text: isOnline ? '已连接' : '连接已断开',
|
||||
iconOnly: iconOnly,
|
||||
),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.info_outline,
|
||||
text: ref
|
||||
.watch(appVersionProvider)
|
||||
.valueOrNull ??
|
||||
'v1.0.0',
|
||||
iconOnly: iconOnly),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -284,7 +450,9 @@ class _SidebarItem extends StatelessWidget {
|
||||
class _StatusItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
const _StatusItem({required this.icon, required this.text});
|
||||
final bool iconOnly;
|
||||
const _StatusItem(
|
||||
{required this.icon, required this.text, this.iconOnly = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -292,9 +460,11 @@ class _StatusItem extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 11, color: Colors.white54),
|
||||
const SizedBox(width: 4),
|
||||
Text(text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
if (!iconOnly) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -344,6 +514,128 @@ class _ClockWidgetState extends State<_ClockWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.store, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
const Text('门店信息',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Info rows
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo),
|
||||
const SizedBox(height: 14),
|
||||
_InfoRow(icon: Icons.person, label: '登录账号', value: u.username),
|
||||
const SizedBox(height: 14),
|
||||
_InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName),
|
||||
const SizedBox(height: 14),
|
||||
_InfoRow(icon: Icons.info_outline, label: '系统版本', value: version),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ShopButton extends StatelessWidget {
|
||||
final AuthUser? user;
|
||||
final String version;
|
||||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (user != null) _showShopPanel(context, user!, version: version);
|
||||
},
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wine_bar, color: Colors.white, size: 22),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'酒库管理系统',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
const _InfoRow({required this.icon, required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverMenuItem extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@@ -8,9 +8,11 @@ import '../../providers/partner_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
import '../../repositories/stock_in_repository.dart';
|
||||
|
||||
class StockInFormScreen extends ConsumerStatefulWidget {
|
||||
const StockInFormScreen({super.key});
|
||||
final int? editOrderId;
|
||||
const StockInFormScreen({super.key, this.editOrderId});
|
||||
|
||||
@override
|
||||
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
|
||||
@@ -23,13 +25,54 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
int? _partnerId;
|
||||
DateTime _orderDate = DateTime.now();
|
||||
bool _submitting = false;
|
||||
bool _loadingEdit = false;
|
||||
|
||||
final List<_ItemRow> _items = [];
|
||||
|
||||
bool get _isEdit => widget.editOrderId != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items.add(_ItemRow());
|
||||
if (_isEdit) {
|
||||
_loadEditOrder();
|
||||
} else {
|
||||
_items.add(_ItemRow());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEditOrder() async {
|
||||
setState(() => _loadingEdit = true);
|
||||
try {
|
||||
final order = await ref
|
||||
.read(stockInRepositoryProvider)
|
||||
.get(widget.editOrderId!);
|
||||
setState(() {
|
||||
_warehouseId = order.warehouseId;
|
||||
_partnerId = order.partnerId;
|
||||
if (order.orderDate != null) {
|
||||
_orderDate = DateTime.tryParse(order.orderDate!) ?? DateTime.now();
|
||||
}
|
||||
_remarkCtrl.text = order.remark ?? '';
|
||||
_items.clear();
|
||||
for (final item in order.items ?? []) {
|
||||
final row = _ItemRow();
|
||||
row.productId = item.productId;
|
||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
|
||||
_items.add(row);
|
||||
}
|
||||
if (_items.isEmpty) _items.add(_ItemRow());
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingEdit = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -101,7 +144,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
};
|
||||
|
||||
try {
|
||||
await ref.read(stockInListProvider.notifier).createOrder(data);
|
||||
if (_isEdit) {
|
||||
await ref
|
||||
.read(stockInRepositoryProvider)
|
||||
.update(widget.editOrderId!, data);
|
||||
ref.read(stockInListProvider.notifier).reload();
|
||||
} else {
|
||||
await ref.read(stockInListProvider.notifier).createOrder(data);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -144,8 +194,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('新建入库单',
|
||||
style: TextStyle(
|
||||
Text(_isEdit ? '修改入库单' : '新建入库单',
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
@@ -174,6 +224,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_loadingEdit)
|
||||
const Expanded(child: Center(child: CircularProgressIndicator())),
|
||||
if (!_loadingEdit)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/stock_in.dart';
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../repositories/stock_in_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
|
||||
@@ -19,6 +20,19 @@ class StockInListScreen extends ConsumerStatefulWidget {
|
||||
class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
String _statusFilter = '';
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSupplier = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('order_no', '入库单号', required: true),
|
||||
ColDef('supplier', '供应商', minWidth: 900),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('amount', '金额', minWidth: 800),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('date', '日期', minWidth: 900),
|
||||
ColDef('actions', '操作', required: true),
|
||||
];
|
||||
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
@@ -64,8 +78,10 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -76,20 +92,55 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
),
|
||||
),
|
||||
data: (result) {
|
||||
final List<StockInOrder> orders;
|
||||
final allOrders = result.data;
|
||||
final List<StockInOrder> statusFiltered;
|
||||
if (filterStatus == 'pending') {
|
||||
orders = result.data.where((o) => o.status == 'pending').toList();
|
||||
statusFiltered = allOrders
|
||||
.where((o) => o.status == 'draft' || o.status == 'pending')
|
||||
.toList();
|
||||
} else if (filterStatus == 'exclude_pending') {
|
||||
orders = result.data.where((o) => o.status != 'pending').toList();
|
||||
statusFiltered = allOrders
|
||||
.where((o) => o.status != 'draft' && o.status != 'pending')
|
||||
.toList();
|
||||
} else {
|
||||
orders = result.data;
|
||||
statusFiltered = allOrders;
|
||||
}
|
||||
|
||||
// Derive filter options from all loaded orders
|
||||
final warehouseOptions = allOrders
|
||||
.map((o) => o.warehouseName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final supplierOptions = allOrders
|
||||
.map((o) => o.partnerName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// Apply multi-select filters
|
||||
var orders = statusFiltered;
|
||||
if (_filterWarehouse.isNotEmpty) {
|
||||
orders = orders
|
||||
.where((o) => _filterWarehouse.contains(o.warehouseName ?? ''))
|
||||
.toList();
|
||||
}
|
||||
if (_filterSupplier.isNotEmpty) {
|
||||
orders = orders
|
||||
.where((o) => _filterSupplier.contains(o.partnerName ?? ''))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return _buildOrderTable(
|
||||
orders: orders,
|
||||
totalCount: orders.length,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == 'exclude_pending',
|
||||
showNewButton: showNewButton,
|
||||
warehouseOptions: warehouseOptions,
|
||||
supplierOptions: supplierOptions,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -101,139 +152,205 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
required bool showNewButton,
|
||||
required List<String> warehouseOptions,
|
||||
required List<String> supplierOptions,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
|
||||
final columns = visibleCols
|
||||
.map((c) => DataColumn(
|
||||
label: Text(c.label),
|
||||
numeric: c.key == 'amount',
|
||||
))
|
||||
.toList();
|
||||
|
||||
DataCell buildOrderCell(String key, StockInOrder o) {
|
||||
switch (key) {
|
||||
case 'order_no':
|
||||
return DataCell(GestureDetector(
|
||||
onTap: () => _showDetail(context, o.id),
|
||||
child: Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline)),
|
||||
));
|
||||
case 'supplier':
|
||||
return DataCell(Text(o.partnerName ?? '-'));
|
||||
case 'warehouse':
|
||||
return DataCell(Text(o.warehouseName ?? '-'));
|
||||
case 'amount':
|
||||
return DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-'));
|
||||
case 'status':
|
||||
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
|
||||
case 'date':
|
||||
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
||||
case 'actions':
|
||||
return DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-in/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmDelete(context, o),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () => _confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
));
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
}
|
||||
|
||||
final rows = orders.isEmpty
|
||||
? [
|
||||
DataRow(
|
||||
cells: List.generate(
|
||||
visibleCols.length,
|
||||
(i) => i == 0
|
||||
? const DataCell(Text('暂无入库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary)))
|
||||
: const DataCell(SizedBox()),
|
||||
),
|
||||
),
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: visibleCols
|
||||
.map((c) => buildOrderCell(c.key, o))
|
||||
.toList(),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
onPageChanged: (p) =>
|
||||
ref.read(stockInListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
toolbar: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
// Row 1: new button + status filter + date picker
|
||||
Row(
|
||||
children: [
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Row 2: multi-select filters + column toggle
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (supplierOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '供应商',
|
||||
options: supplierOptions,
|
||||
selected: _filterSupplier,
|
||||
onChanged: (v) => setState(() => _filterSupplier = v),
|
||||
),
|
||||
if (supplierOptions.length > 1) const SizedBox(width: 8),
|
||||
if (warehouseOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '仓库',
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
const Spacer(),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('入库单号')),
|
||||
DataColumn(label: Text('供应商')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('日期')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: orders.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无入库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(Text(o.partnerName ?? '-')),
|
||||
DataCell(Text(o.warehouseName ?? '-')),
|
||||
DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-')),
|
||||
DataCell(StatusBadge(
|
||||
_apiStatusToEnum(o.status))),
|
||||
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,6 +379,43 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除确认'),
|
||||
content: Text('确认删除入库单「${o.orderNo}」?此操作不可恢复。'),
|
||||
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 && mounted) {
|
||||
try {
|
||||
await ref.read(stockInListProvider.notifier).deleteOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已删除'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('删除失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmSubmit(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -359,9 +513,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.rejectOrder(o.id);
|
||||
await ref.read(stockInListProvider.notifier).rejectOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
|
||||
@@ -441,9 +593,17 @@ class _StockInDetailDialogState extends State<_StockInDetailDialog> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)));
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
SizedBox(height: 12),
|
||||
Text('暂无数据,网络不可用',
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildContent(snap.data!);
|
||||
},
|
||||
|
||||
@@ -9,9 +9,11 @@ import '../../providers/partner_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
import '../../repositories/stock_out_repository.dart';
|
||||
|
||||
class StockOutFormScreen extends ConsumerStatefulWidget {
|
||||
const StockOutFormScreen({super.key});
|
||||
final int? editOrderId;
|
||||
const StockOutFormScreen({super.key, this.editOrderId});
|
||||
|
||||
@override
|
||||
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
|
||||
@@ -24,15 +26,57 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
int? _partnerId;
|
||||
DateTime _orderDate = DateTime.now();
|
||||
bool _submitting = false;
|
||||
bool _loadingEdit = false;
|
||||
// productId → available quantity in selected warehouse
|
||||
Map<int, double> _inventoryMap = {};
|
||||
|
||||
final List<_ItemRow> _items = [];
|
||||
|
||||
bool get _isEdit => widget.editOrderId != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items.add(_ItemRow());
|
||||
if (_isEdit) {
|
||||
_loadEditOrder();
|
||||
} else {
|
||||
_items.add(_ItemRow());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEditOrder() async {
|
||||
setState(() => _loadingEdit = true);
|
||||
try {
|
||||
final order = await ref
|
||||
.read(stockOutRepositoryProvider)
|
||||
.get(widget.editOrderId!);
|
||||
setState(() {
|
||||
_warehouseId = order.warehouseId;
|
||||
_partnerId = order.partnerId;
|
||||
if (order.orderDate != null) {
|
||||
_orderDate = DateTime.tryParse(order.orderDate!) ?? DateTime.now();
|
||||
}
|
||||
_remarkCtrl.text = order.remark ?? '';
|
||||
_items.clear();
|
||||
for (final item in order.items ?? []) {
|
||||
final row = _ItemRow();
|
||||
row.productId = item.productId;
|
||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
|
||||
_items.add(row);
|
||||
}
|
||||
if (_items.isEmpty) _items.add(_ItemRow());
|
||||
});
|
||||
if (_warehouseId != null) await _loadInventory(_warehouseId!);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingEdit = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,7 +161,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
};
|
||||
|
||||
try {
|
||||
await ref.read(stockOutListProvider.notifier).createOrder(data);
|
||||
if (_isEdit) {
|
||||
await ref
|
||||
.read(stockOutRepositoryProvider)
|
||||
.update(widget.editOrderId!, data);
|
||||
ref.read(stockOutListProvider.notifier).reload();
|
||||
} else {
|
||||
await ref.read(stockOutListProvider.notifier).createOrder(data);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -160,8 +211,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('新建出库单',
|
||||
style: TextStyle(
|
||||
Text(_isEdit ? '修改出库单' : '新建出库单',
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
@@ -190,6 +241,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_loadingEdit)
|
||||
const Expanded(child: Center(child: CircularProgressIndicator())),
|
||||
if (!_loadingEdit)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/stock_out.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../repositories/stock_out_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
|
||||
@@ -20,6 +21,19 @@ class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
String _statusFilter = '';
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterCustomer = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('order_no', '出库单号', required: true),
|
||||
ColDef('customer', '客户', minWidth: 900),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('amount', '金额', minWidth: 800),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('date', '日期', minWidth: 900),
|
||||
ColDef('actions', '操作', required: true),
|
||||
];
|
||||
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
@@ -65,8 +79,10 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无数据,网络不可用',
|
||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
@@ -77,20 +93,55 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
),
|
||||
),
|
||||
data: (result) {
|
||||
final List<StockOutOrder> orders;
|
||||
final allOrders = result.data;
|
||||
final List<StockOutOrder> statusFiltered;
|
||||
if (filterStatus == 'pending') {
|
||||
orders = result.data.where((o) => o.status == 'pending').toList();
|
||||
statusFiltered = allOrders
|
||||
.where((o) => o.status == 'draft' || o.status == 'pending')
|
||||
.toList();
|
||||
} else if (filterStatus == 'exclude_pending') {
|
||||
orders = result.data.where((o) => o.status != 'pending').toList();
|
||||
statusFiltered = allOrders
|
||||
.where((o) => o.status != 'draft' && o.status != 'pending')
|
||||
.toList();
|
||||
} else {
|
||||
orders = result.data;
|
||||
statusFiltered = allOrders;
|
||||
}
|
||||
|
||||
// Derive filter options from all loaded orders
|
||||
final warehouseOptions = allOrders
|
||||
.map((o) => o.warehouseName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final customerOptions = allOrders
|
||||
.map((o) => o.partnerName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// Apply multi-select filters
|
||||
var orders = statusFiltered;
|
||||
if (_filterWarehouse.isNotEmpty) {
|
||||
orders = orders
|
||||
.where((o) => _filterWarehouse.contains(o.warehouseName ?? ''))
|
||||
.toList();
|
||||
}
|
||||
if (_filterCustomer.isNotEmpty) {
|
||||
orders = orders
|
||||
.where((o) => _filterCustomer.contains(o.partnerName ?? ''))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return _buildOrderTable(
|
||||
orders: orders,
|
||||
totalCount: orders.length,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == 'exclude_pending',
|
||||
showNewButton: showNewButton,
|
||||
warehouseOptions: warehouseOptions,
|
||||
customerOptions: customerOptions,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -102,139 +153,205 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
required bool showNewButton,
|
||||
required List<String> warehouseOptions,
|
||||
required List<String> customerOptions,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
|
||||
final columns = visibleCols
|
||||
.map((c) => DataColumn(
|
||||
label: Text(c.label),
|
||||
numeric: c.key == 'amount',
|
||||
))
|
||||
.toList();
|
||||
|
||||
DataCell buildOrderCell(String key, StockOutOrder o) {
|
||||
switch (key) {
|
||||
case 'order_no':
|
||||
return DataCell(GestureDetector(
|
||||
onTap: () => _showDetail(context, o.id),
|
||||
child: Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline)),
|
||||
));
|
||||
case 'customer':
|
||||
return DataCell(Text(o.partnerName ?? '-'));
|
||||
case 'warehouse':
|
||||
return DataCell(Text(o.warehouseName ?? '-'));
|
||||
case 'amount':
|
||||
return DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-'));
|
||||
case 'status':
|
||||
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
|
||||
case 'date':
|
||||
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
||||
case 'actions':
|
||||
return DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-out/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmDelete(context, o),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () => _confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
));
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
}
|
||||
|
||||
final rows = orders.isEmpty
|
||||
? [
|
||||
DataRow(
|
||||
cells: List.generate(
|
||||
visibleCols.length,
|
||||
(i) => i == 0
|
||||
? const DataCell(Text('暂无出库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary)))
|
||||
: const DataCell(SizedBox()),
|
||||
),
|
||||
),
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: visibleCols
|
||||
.map((c) => buildOrderCell(c.key, o))
|
||||
.toList(),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
onPageChanged: (p) =>
|
||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
toolbar: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
// Row 1: new button + status filter + date picker
|
||||
Row(
|
||||
children: [
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Row 2: multi-select filters + column toggle
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (customerOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '客户',
|
||||
options: customerOptions,
|
||||
selected: _filterCustomer,
|
||||
onChanged: (v) => setState(() => _filterCustomer = v),
|
||||
),
|
||||
if (customerOptions.length > 1) const SizedBox(width: 8),
|
||||
if (warehouseOptions.length > 1)
|
||||
MultiSelectDropdown(
|
||||
label: '仓库',
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
const Spacer(),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('出库单号')),
|
||||
DataColumn(label: Text('客户/往来单位')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('日期')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: orders.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无出库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(Text(o.partnerName ?? '-')),
|
||||
DataCell(Text(o.warehouseName ?? '-')),
|
||||
DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-')),
|
||||
DataCell(StatusBadge(
|
||||
_apiStatusToEnum(o.status))),
|
||||
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,6 +380,43 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除确认'),
|
||||
content: Text('确认删除出库单「${o.orderNo}」?此操作不可恢复。'),
|
||||
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 && mounted) {
|
||||
try {
|
||||
await ref.read(stockOutListProvider.notifier).deleteOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已删除'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('删除失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmSubmit(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -364,9 +518,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.rejectOrder(o.id);
|
||||
await ref.read(stockOutListProvider.notifier).rejectOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
|
||||
@@ -446,9 +598,17 @@ class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)));
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
||||
SizedBox(height: 12),
|
||||
Text('暂无数据,网络不可用',
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildContent(snap.data!);
|
||||
},
|
||||
|
||||
@@ -27,33 +27,38 @@ class DataTableCard extends StatelessWidget {
|
||||
children: [
|
||||
if (toolbar != null)
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: toolbar!,
|
||||
),
|
||||
if (toolbar != null) const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
const Color(0xFFF0F4FF)),
|
||||
headingTextStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
const Color(0xFFF0F4FF)),
|
||||
headingTextStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
dataTextStyle: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dataRowMinHeight: 40,
|
||||
dataRowMaxHeight: 56,
|
||||
dividerThickness: 0.5,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
),
|
||||
),
|
||||
dataTextStyle: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dataRowMinHeight: 40,
|
||||
dataRowMaxHeight: 48,
|
||||
dividerThickness: 0.5,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
/// Compact button → dialog with checkboxes for multi-select filtering
|
||||
class MultiSelectDropdown extends StatelessWidget {
|
||||
final String label;
|
||||
final List<String> options;
|
||||
final Set<String> selected;
|
||||
final ValueChanged<Set<String>> onChanged;
|
||||
|
||||
const MultiSelectDropdown({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.options,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = selected.isNotEmpty;
|
||||
return OutlinedButton(
|
||||
onPressed: options.isEmpty ? null : () => _show(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: active ? AppTheme.primary : AppTheme.textSecondary,
|
||||
side: BorderSide(color: active ? AppTheme.primary : AppTheme.border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.filter_list, size: 13),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
active ? '$label (${selected.length})' : label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
if (active) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => onChanged({}),
|
||||
child: const Icon(Icons.close, size: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black12,
|
||||
builder: (_) => _MultiSelectDialog(
|
||||
label: label,
|
||||
options: options,
|
||||
initial: selected,
|
||||
onApply: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiSelectDialog extends StatefulWidget {
|
||||
final String label;
|
||||
final List<String> options;
|
||||
final Set<String> initial;
|
||||
final ValueChanged<Set<String>> onApply;
|
||||
|
||||
const _MultiSelectDialog({
|
||||
required this.label,
|
||||
required this.options,
|
||||
required this.initial,
|
||||
required this.onApply,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_MultiSelectDialog> createState() => _MultiSelectDialogState();
|
||||
}
|
||||
|
||||
class _MultiSelectDialogState extends State<_MultiSelectDialog> {
|
||||
late Set<String> _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = Set.from(widget.initial);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.label, style: const TextStyle(fontSize: 15)),
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
content: SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _selected = Set.from(widget.options)),
|
||||
child: const Text('全选', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _selected = {}),
|
||||
child: const Text('清空', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
]),
|
||||
const Divider(height: 1),
|
||||
...widget.options.map((opt) => CheckboxListTile(
|
||||
title: Text(opt, style: const TextStyle(fontSize: 13)),
|
||||
value: _selected.contains(opt),
|
||||
dense: true,
|
||||
onChanged: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_selected.add(opt);
|
||||
} else {
|
||||
_selected.remove(opt);
|
||||
}
|
||||
}),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.onApply(_selected);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('应用'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Column definition for column visibility toggle
|
||||
class ColDef {
|
||||
final String key;
|
||||
final String label;
|
||||
final bool required; // if true, cannot be hidden
|
||||
/// Screen width below which this column is automatically hidden
|
||||
final double? minWidth;
|
||||
|
||||
const ColDef(this.key, this.label, {this.required = false, this.minWidth});
|
||||
}
|
||||
|
||||
/// Button that lets users toggle column visibility
|
||||
class ColumnToggleButton extends StatelessWidget {
|
||||
final List<ColDef> columns;
|
||||
final Set<String> hidden;
|
||||
final ValueChanged<Set<String>> onChanged;
|
||||
|
||||
const ColumnToggleButton({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.hidden,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
onPressed: () => _show(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
side: const BorderSide(color: AppTheme.border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.view_column_outlined, size: 13),
|
||||
SizedBox(width: 4),
|
||||
Text('显示字段', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black12,
|
||||
builder: (_) => _ColumnToggleDialog(
|
||||
columns: columns,
|
||||
initial: hidden,
|
||||
onApply: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColumnToggleDialog extends StatefulWidget {
|
||||
final List<ColDef> columns;
|
||||
final Set<String> initial;
|
||||
final ValueChanged<Set<String>> onApply;
|
||||
|
||||
const _ColumnToggleDialog({
|
||||
required this.columns,
|
||||
required this.initial,
|
||||
required this.onApply,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ColumnToggleDialog> createState() => _ColumnToggleDialogState();
|
||||
}
|
||||
|
||||
class _ColumnToggleDialogState extends State<_ColumnToggleDialog> {
|
||||
late Set<String> _hidden;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hidden = Set.from(widget.initial);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('显示字段', style: TextStyle(fontSize: 15)),
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
content: SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.columns
|
||||
.map((col) => CheckboxListTile(
|
||||
title: Text(col.label,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
value: !_hidden.contains(col.key),
|
||||
dense: true,
|
||||
onChanged: col.required
|
||||
? null // required columns cannot be hidden
|
||||
: (v) => setState(() {
|
||||
if (v == true) {
|
||||
_hidden.remove(col.key);
|
||||
} else {
|
||||
_hidden.add(col.key);
|
||||
}
|
||||
}),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.onApply(_hidden);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('应用'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user