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:
wangjia
2026-06-19 07:34:07 +08:00
parent e41085a878
commit 32bd64d676
23 changed files with 1319 additions and 408 deletions
+18 -12
View File
@@ -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);
+55 -32
View File
@@ -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(红)。
/// 对应后端 phasenormal / 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),
);
}
/// 出库单:窄屏卡片