feat(client): 实时授权状态与会话失效处理
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info - 账号被停用/删除(401 USER_DISABLED)即强制重新登录 - 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回 - 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -169,10 +169,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
],
|
||||
actions: (canClose && !WriteGuard.isReadonly(ref))
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
||||
),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
@@ -318,10 +320,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
if ((r.type == 'payable' || r.type == 'receivable') &&
|
||||
r.status == 'open' &&
|
||||
!WriteGuard.isReadonly(ref)) {
|
||||
return DataCell(TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
return DataCell(WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
));
|
||||
}
|
||||
return const DataCell(SizedBox());
|
||||
@@ -419,10 +423,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
),
|
||||
),
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||
}
|
||||
|
||||
/// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件),
|
||||
/// 否则纯文本(只读角色占位)。
|
||||
Widget _remarkDisplay(Inventory item, {required bool editable}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
if (editable) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||
final ctrl = TextEditingController(text: item.remark);
|
||||
final saved = await showDialog<String>(
|
||||
@@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
'remark' => DataCell(Tooltip(
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: WriteGuard.isReadonly(ref)
|
||||
? null
|
||||
: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
// 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标),
|
||||
// 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。
|
||||
child: WriteGuard(
|
||||
placeholder: _remarkDisplay(item, editable: false),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
child: _remarkDisplay(item, editable: true),
|
||||
),
|
||||
))),
|
||||
'status' => DataCell(_InventoryStatusBadge(item)),
|
||||
@@ -374,9 +386,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
],
|
||||
actions: [
|
||||
if (!WriteGuard.isReadonly(ref))
|
||||
TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
if (item.productId != null)
|
||||
TextButton(
|
||||
@@ -622,16 +636,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
Row(
|
||||
children: [
|
||||
if (canCheck)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('盘点'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
WriteGuard(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('盘点'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -671,10 +688,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
SizedBox(width: 220, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
if (canCheck) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
WriteGuard(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
@@ -170,16 +170,20 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -195,10 +199,12 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -288,19 +294,23 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
|
||||
@@ -358,10 +358,12 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
return Row(
|
||||
children: [
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -422,14 +424,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,19 +12,21 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../repositories/license_repository.dart';
|
||||
import '../../providers/number_rule_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../models/shop.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
/// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。
|
||||
final int initialTab;
|
||||
const SettingsScreen({super.key, this.initialTab = 0});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
initialIndex: widget.initialTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
@@ -326,6 +333,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 过期降级说明
|
||||
_buildExpiryNotes(),
|
||||
const SizedBox(height: 16),
|
||||
// 激活码输入区
|
||||
_buildActivationCard(),
|
||||
],
|
||||
@@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Color phaseColor;
|
||||
String phaseText;
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
phaseColor = Colors.orange;
|
||||
phaseText = '宽限期(剩余 ${lic.daysRemaining ?? 0} 天到期)';
|
||||
case 'readonly':
|
||||
phaseColor = AppTheme.danger;
|
||||
phaseText = '已过期 · 只读模式';
|
||||
case 'locked':
|
||||
phaseColor = AppTheme.danger;
|
||||
phaseText = '已锁定 · 请立即续费';
|
||||
default:
|
||||
phaseColor = AppTheme.success;
|
||||
phaseText = lic.expiresAt == null
|
||||
? '正常(永久授权)'
|
||||
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
|
||||
}
|
||||
final Color phaseColor = LicenseCopy.phaseColor(lic.phase);
|
||||
final String phaseText = LicenseCopy.statusText(lic);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -407,6 +401,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpiryNotes() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline,
|
||||
size: 16, color: AppTheme.textSecondary),
|
||||
SizedBox(width: 6),
|
||||
Text('过期说明',
|
||||
style:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('授权到期后会分阶段降级,请在到期前及时续费',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
for (final note in LicenseCopy.degradationNotes())
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, right: 8),
|
||||
child: Icon(Icons.circle,
|
||||
size: 5, color: AppTheme.textSecondary),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(note,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivationCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
);
|
||||
}
|
||||
if (WriteGuard.licenseBlocked(ref)) {
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentUser = ref.watch(authStateProvider).user;
|
||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
@@ -170,6 +171,14 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||||
ref.watch(sessionHeartbeatProvider);
|
||||
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
|
||||
ref.listen<String?>(apiMessageProvider, (prev, next) {
|
||||
if (next == null || next.isEmpty) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(next)));
|
||||
ref.read(apiMessageProvider.notifier).state = null;
|
||||
});
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final isMobile = context.isMobile;
|
||||
@@ -538,6 +547,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
.valueOrNull ??
|
||||
'v1.0.0',
|
||||
iconOnly: iconOnly),
|
||||
_LicenseStatusItem(
|
||||
lic: licenseInfo, iconOnly: iconOnly),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -556,19 +567,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}
|
||||
|
||||
Widget _buildLicenseBanner(LicenseInfo lic) {
|
||||
final Color bg;
|
||||
final String msg;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
bg = AppTheme.danger;
|
||||
msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码';
|
||||
case 'readonly':
|
||||
bg = const Color(0xFFB71C1C);
|
||||
msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)';
|
||||
default: // grace
|
||||
bg = const Color(0xFFE65100);
|
||||
msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费';
|
||||
}
|
||||
final bg = LicenseCopy.bannerColor(lic.phase);
|
||||
final msg = LicenseCopy.banner(lic);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: bg,
|
||||
@@ -584,7 +584,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/settings'),
|
||||
onPressed: () => context.go('/settings?tab=license'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.white70),
|
||||
child: const Text('去激活'),
|
||||
),
|
||||
@@ -594,23 +594,9 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}
|
||||
|
||||
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
||||
final String title;
|
||||
final String body;
|
||||
final Color titleColor;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
title = '授权已锁定';
|
||||
body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。';
|
||||
titleColor = AppTheme.danger;
|
||||
case 'readonly':
|
||||
title = '授权已过期 · 只读模式';
|
||||
body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。';
|
||||
titleColor = AppTheme.danger;
|
||||
default: // grace
|
||||
title = '授权即将到期';
|
||||
body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。';
|
||||
titleColor = Colors.orange[800]!;
|
||||
}
|
||||
final (title, body) = LicenseCopy.dialog(lic);
|
||||
final Color titleColor =
|
||||
lic.phase == 'grace' ? Colors.orange[800]! : AppTheme.danger;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: true,
|
||||
@@ -630,7 +616,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
|
||||
onPressed: () {
|
||||
Navigator.pop(_);
|
||||
ctx.go('/settings');
|
||||
ctx.go('/settings?tab=license');
|
||||
},
|
||||
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
@@ -743,6 +729,43 @@ class _StatusItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。
|
||||
/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。
|
||||
/// 对应后端 phase:normal / grace / readonly|locked。
|
||||
class _LicenseStatusItem extends StatelessWidget {
|
||||
final LicenseInfo? lic;
|
||||
final bool iconOnly;
|
||||
const _LicenseStatusItem({required this.lic, this.iconOnly = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lic = this.lic;
|
||||
if (lic == null) return const SizedBox.shrink();
|
||||
|
||||
final color = LicenseCopy.phaseColor(lic.phase);
|
||||
final icon = LicenseCopy.phaseIcon(lic.phase);
|
||||
|
||||
final String date = lic.expiresAt == null
|
||||
? ''
|
||||
: DateFormat('yyyy-MM-dd').format(lic.expiresAt!);
|
||||
final String text = LicenseCopy.statusBar(lic, date);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _StatusDivider(),
|
||||
Icon(icon, size: 11, color: color),
|
||||
if (!iconOnly) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(text,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusDivider extends StatelessWidget {
|
||||
const _StatusDivider();
|
||||
@override
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../../repositories/product_repository.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../widgets/order_row_actions.dart';
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
@@ -306,18 +307,20 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
? WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -417,93 +420,59 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
/// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions],
|
||||
/// 入库特有的「打标签」通过 afterPrint 注入。
|
||||
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockInOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (!context.mounted) return;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
final labels = order.items
|
||||
.map((item) => LabelData(
|
||||
productId: item.productId,
|
||||
name: item.productName ?? '',
|
||||
code: item.productCode ?? '',
|
||||
series: item.productSeries,
|
||||
spec: item.productSpec,
|
||||
batchNo: item.batchNo,
|
||||
productionDate: item.productionDate,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
))
|
||||
.toList();
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (_) => LabelPreviewDialog(
|
||||
labels: labels,
|
||||
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (!readonly && o.status == 'approved')
|
||||
return buildOrderRowActions(
|
||||
readonly: WriteGuard.isReadonly(ref),
|
||||
status: o.status,
|
||||
orderId: o.id,
|
||||
onDetail: () => _showDetail(context, o.id),
|
||||
onPrint: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockInOrder(order));
|
||||
}
|
||||
},
|
||||
afterPrint: [
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (!readonly && 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('提交',
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (!context.mounted) return;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
final labels = order.items
|
||||
.map((item) => LabelData(
|
||||
productId: item.productId,
|
||||
name: item.productName ?? '',
|
||||
code: item.productCode ?? '',
|
||||
series: item.productSeries,
|
||||
spec: item.productSpec,
|
||||
batchNo: item.batchNo,
|
||||
productionDate: item.productionDate,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
))
|
||||
.toList();
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (_) => LabelPreviewDialog(
|
||||
labels: labels,
|
||||
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (!readonly && 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)),
|
||||
),
|
||||
],
|
||||
];
|
||||
onSettle: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
onEdit: () => context.go('/stock-in/edit/${o.id}'),
|
||||
onDelete: () => _confirmDelete(context, o),
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
/// 入库单:窄屏卡片
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../../providers/tab_state_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../widgets/order_row_actions.dart';
|
||||
|
||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
const StockOutListScreen({super.key});
|
||||
@@ -312,18 +313,20 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
? WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -425,61 +428,24 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockOutOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (!readonly && o.status == 'approved')
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (!readonly && 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 (!readonly && 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)),
|
||||
),
|
||||
],
|
||||
];
|
||||
return buildOrderRowActions(
|
||||
readonly: WriteGuard.isReadonly(ref),
|
||||
status: o.status,
|
||||
orderId: o.id,
|
||||
onDetail: () => _showDetail(context, o.id),
|
||||
onPrint: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockOutOrder(order));
|
||||
}
|
||||
},
|
||||
onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||
onEdit: () => context.go('/stock-out/edit/${o.id}'),
|
||||
onDelete: () => _confirmDelete(context, o),
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
/// 出库单:窄屏卡片
|
||||
|
||||
Reference in New Issue
Block a user