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:
wangjia
2026-04-13 00:28:08 +08:00
parent 53a60d230f
commit bb4f17cf7a
44 changed files with 3384 additions and 710 deletions
+467 -24
View File
@@ -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!,
],
),
);