bb4f17cf7a
后端: - 新增 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>
1399 lines
49 KiB
Dart
1399 lines
49 KiB
Dart
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';
|
||
|
||
class SettingsScreen extends ConsumerStatefulWidget {
|
||
const SettingsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||
}
|
||
|
||
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: 5,
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
color: AppTheme.surface,
|
||
child: const TabBar(
|
||
isScrollable: true,
|
||
labelColor: AppTheme.primary,
|
||
unselectedLabelColor: AppTheme.textSecondary,
|
||
indicatorColor: AppTheme.primary,
|
||
indicatorWeight: 2,
|
||
labelStyle:
|
||
TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||
tabs: [
|
||
Tab(text: '用户管理'),
|
||
Tab(text: '仓库管理'),
|
||
Tab(text: '编号规则'),
|
||
Tab(text: '系统参数'),
|
||
Tab(text: '关于'),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: TabBarView(
|
||
children: [
|
||
_buildUsersTab(),
|
||
_buildWarehousesTab(),
|
||
_buildNumberRulesTab(),
|
||
_buildSystemParamsTab(),
|
||
_buildAboutTab(),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildUsersTab() {
|
||
final asyncUsers = ref.watch(userListProvider);
|
||
return Column(
|
||
children: [
|
||
Container(
|
||
height: 52,
|
||
color: AppTheme.surface,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Row(
|
||
children: [
|
||
ElevatedButton.icon(
|
||
onPressed: () => _showAddUserDialog(context),
|
||
icon: const Icon(Icons.person_add, size: 16),
|
||
label: const Text('新增用户'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: asyncUsers.when(
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
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(),
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
data: (users) => SingleChildScrollView(
|
||
child: DataTable(
|
||
headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||
columns: const [
|
||
DataColumn(label: Text('姓名')),
|
||
DataColumn(label: Text('用户名')),
|
||
DataColumn(label: Text('角色')),
|
||
DataColumn(label: Text('状态')),
|
||
DataColumn(label: Text('操作')),
|
||
],
|
||
rows: users
|
||
.map((u) => DataRow(cells: [
|
||
DataCell(Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 14,
|
||
backgroundColor:
|
||
AppTheme.primary.withOpacity(0.15),
|
||
child: Text(
|
||
(u.realName ?? u.username)
|
||
.substring(0, 1),
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppTheme.primary,
|
||
fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(u.realName ?? u.username),
|
||
],
|
||
)),
|
||
DataCell(Text(u.username,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace', fontSize: 12))),
|
||
DataCell(_RoleBadge(u.roleLabel)),
|
||
DataCell(Switch(
|
||
value: u.isActive,
|
||
onChanged: (v) => ref
|
||
.read(userListProvider.notifier)
|
||
.updateUser(u.id, {'is_active': v}),
|
||
materialTapTargetSize:
|
||
MaterialTapTargetSize.shrinkWrap,
|
||
)),
|
||
DataCell(Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextButton(
|
||
onPressed: () =>
|
||
_showEditUserDialog(context, u),
|
||
child: const Text('编辑',
|
||
style: TextStyle(fontSize: 12)),
|
||
),
|
||
TextButton(
|
||
onPressed: () =>
|
||
_showResetPasswordDialog(context, u),
|
||
child: const Text('重置密码',
|
||
style: TextStyle(fontSize: 12)),
|
||
),
|
||
],
|
||
)),
|
||
]))
|
||
.toList(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildWarehousesTab() {
|
||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||
return Column(
|
||
children: [
|
||
Container(
|
||
height: 52,
|
||
color: AppTheme.surface,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Row(
|
||
children: [
|
||
ElevatedButton.icon(
|
||
onPressed: () => _showWarehouseDialog(context),
|
||
icon: const Icon(Icons.add, size: 16),
|
||
label: const Text('新建'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: asyncWarehouses.when(
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
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(warehouseListProvider.notifier).reload(),
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
data: (warehouses) {
|
||
if (warehouses.isEmpty) {
|
||
return const Center(
|
||
child: Text('暂无仓库',
|
||
style:
|
||
TextStyle(color: AppTheme.textSecondary)));
|
||
}
|
||
return SingleChildScrollView(
|
||
child: DataTable(
|
||
headingRowColor:
|
||
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||
columns: const [
|
||
DataColumn(label: Text('仓库名称')),
|
||
DataColumn(label: Text('位置')),
|
||
DataColumn(label: Text('默认仓库')),
|
||
DataColumn(label: Text('操作')),
|
||
],
|
||
rows: warehouses
|
||
.map((w) => DataRow(cells: [
|
||
DataCell(Text(w.name,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w500))),
|
||
DataCell(Text(w.location ?? '-')),
|
||
DataCell(w.isDefault
|
||
? const Icon(Icons.check_circle,
|
||
color: AppTheme.success, size: 18)
|
||
: const SizedBox()),
|
||
DataCell(Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextButton(
|
||
key: Key('btn_edit_${w.id}'),
|
||
onPressed: () =>
|
||
_showWarehouseDialog(context,
|
||
warehouse: w),
|
||
child: const Text('编辑',
|
||
style: TextStyle(fontSize: 12)),
|
||
),
|
||
TextButton(
|
||
key: Key('btn_delete_${w.id}'),
|
||
onPressed: () =>
|
||
_confirmDeleteWarehouse(context, w),
|
||
child: const Text('删除',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: AppTheme.danger)),
|
||
),
|
||
],
|
||
)),
|
||
]))
|
||
.toList(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _confirmDeleteWarehouse(
|
||
BuildContext context, Warehouse w) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('确认删除'),
|
||
content: Text('确认删除仓库「${w.name}」?'),
|
||
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(warehouseListProvider.notifier)
|
||
.deleteWarehouse(w.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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => _WarehouseFormDialog(
|
||
warehouse: warehouse,
|
||
onSaved: () =>
|
||
ref.read(warehouseListProvider.notifier).reload(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNumberRulesTab() {
|
||
final asyncRules = ref.watch(numberRuleListProvider);
|
||
return asyncRules.when(
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
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(numberRuleListProvider.notifier).reload(),
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
data: (rules) => SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('编号规则配置',
|
||
style:
|
||
TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 4),
|
||
const Text('配置各类单据的自动编号规则,修改后对新建单据生效',
|
||
style: TextStyle(
|
||
fontSize: 13, color: AppTheme.textSecondary)),
|
||
const SizedBox(height: 16),
|
||
Card(
|
||
child: DataTable(
|
||
headingRowColor:
|
||
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||
columns: const [
|
||
DataColumn(label: Text('单据类型')),
|
||
DataColumn(label: Text('前缀')),
|
||
DataColumn(label: Text('日期格式')),
|
||
DataColumn(label: Text('下一编号示例')),
|
||
DataColumn(label: Text('当前序号'), numeric: true),
|
||
DataColumn(label: Text('操作')),
|
||
],
|
||
rows: rules
|
||
.map((r) => DataRow(cells: [
|
||
DataCell(Text(r.typeLabel,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w500))),
|
||
DataCell(Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.primary.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
child: Text(r.prefix,
|
||
style: const TextStyle(
|
||
color: AppTheme.primary,
|
||
fontFamily: 'monospace',
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600)),
|
||
)),
|
||
DataCell(Text(r.dateFormat,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppTheme.textSecondary))),
|
||
DataCell(Text(r.exampleNo,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace', fontSize: 12))),
|
||
DataCell(Text('${r.currentNo}')),
|
||
DataCell(TextButton(
|
||
onPressed: () =>
|
||
_showNumberRuleDialog(context, r),
|
||
child: const Text('编辑',
|
||
style: TextStyle(fontSize: 12)))),
|
||
]))
|
||
.toList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showNumberRuleDialog(BuildContext context, NumberRule rule) {
|
||
final prefixCtrl = TextEditingController(text: rule.prefix);
|
||
final currentNoCtrl =
|
||
TextEditingController(text: '${rule.currentNo}');
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text('编辑编号规则 — ${rule.typeLabel}'),
|
||
content: SizedBox(
|
||
width: 360,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextField(
|
||
controller: prefixCtrl,
|
||
decoration: const InputDecoration(labelText: '前缀'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: currentNoCtrl,
|
||
decoration: const InputDecoration(labelText: '当前序号'),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('取消')),
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
Navigator.of(ctx).pop();
|
||
try {
|
||
await ref
|
||
.read(numberRuleListProvider.notifier)
|
||
.updateRule(rule.id, {
|
||
'prefix': prefixCtrl.text.trim(),
|
||
'current_no': int.tryParse(currentNoCtrl.text) ?? rule.currentNo,
|
||
});
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('编号规则已更新'),
|
||
backgroundColor: AppTheme.success),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('更新失败:$e'),
|
||
backgroundColor: AppTheme.danger),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
child: const Text('保存'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSystemParamsTab() {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('系统参数',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 16),
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('基本设置',
|
||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
|
||
const Divider(height: 24),
|
||
_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: _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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||
content: Text('设置已保存'),
|
||
backgroundColor: AppTheme.success));
|
||
},
|
||
child: const Text('保存设置'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton(
|
||
onPressed: () => setState(() {
|
||
_sysName = '酒库管理系统';
|
||
_sysCurrency = '人民币(CNY)';
|
||
_sysDateFormat = 'YYYY-MM-DD';
|
||
_sysTimezone = 'Asia/Shanghai (UTC+8)';
|
||
_requireStockInApproval = true;
|
||
_requireStockOutApproval = true;
|
||
_allowOverstock = false;
|
||
}),
|
||
child: const Text('重置默认'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 关于 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,
|
||
builder: (ctx) => _UserFormDialog(
|
||
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showEditUserDialog(BuildContext context, AppUser user) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => _UserFormDialog(
|
||
user: user,
|
||
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showResetPasswordDialog(BuildContext context, AppUser user) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => _ResetPasswordDialog(user: user),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WarehouseFormDialog extends ConsumerStatefulWidget {
|
||
final Warehouse? warehouse;
|
||
final VoidCallback onSaved;
|
||
|
||
const _WarehouseFormDialog({this.warehouse, required this.onSaved});
|
||
|
||
@override
|
||
ConsumerState<_WarehouseFormDialog> createState() =>
|
||
_WarehouseFormDialogState();
|
||
}
|
||
|
||
class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
late final TextEditingController _nameCtrl;
|
||
late final TextEditingController _locationCtrl;
|
||
late bool _isDefault;
|
||
bool _saving = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_nameCtrl = TextEditingController(text: widget.warehouse?.name ?? '');
|
||
_locationCtrl =
|
||
TextEditingController(text: widget.warehouse?.location ?? '');
|
||
_isDefault = widget.warehouse?.isDefault ?? false;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameCtrl.dispose();
|
||
_locationCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
setState(() => _saving = true);
|
||
final data = {
|
||
'name': _nameCtrl.text.trim(),
|
||
if (_locationCtrl.text.trim().isNotEmpty)
|
||
'location': _locationCtrl.text.trim(),
|
||
'is_default': _isDefault,
|
||
};
|
||
try {
|
||
final notifier = ref.read(warehouseListProvider.notifier);
|
||
if (widget.warehouse != null) {
|
||
await notifier.updateWarehouse(widget.warehouse!.id, data);
|
||
} else {
|
||
await notifier.createWarehouse(data);
|
||
}
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
widget.onSaved();
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'),
|
||
backgroundColor: AppTheme.success,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('保存失败:$e'),
|
||
backgroundColor: AppTheme.danger),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'),
|
||
content: SizedBox(
|
||
width: 400,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextFormField(
|
||
controller: _nameCtrl,
|
||
decoration: const InputDecoration(labelText: '仓库名称'),
|
||
validator: (v) =>
|
||
(v == null || v.isEmpty) ? '不能为空' : null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _locationCtrl,
|
||
decoration: const InputDecoration(labelText: '位置'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
CheckboxListTile(
|
||
title: const Text('设为默认仓库'),
|
||
value: _isDefault,
|
||
onChanged: (v) => setState(() => _isDefault = v ?? false),
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _saving ? null : _save,
|
||
child: _saving
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: Colors.white))
|
||
: const Text('保存'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 新增/编辑用户弹窗 ─────────────────────────────────────
|
||
class _UserFormDialog extends ConsumerStatefulWidget {
|
||
final AppUser? user;
|
||
final VoidCallback onSaved;
|
||
const _UserFormDialog({this.user, required this.onSaved});
|
||
|
||
@override
|
||
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
|
||
}
|
||
|
||
class _UserFormDialogState extends ConsumerState<_UserFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
late final TextEditingController _usernameCtrl;
|
||
late final TextEditingController _realNameCtrl;
|
||
late final TextEditingController _phoneCtrl;
|
||
late final TextEditingController _passwordCtrl;
|
||
late String _role;
|
||
bool _saving = false;
|
||
|
||
bool get _isEdit => widget.user != null;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_usernameCtrl = TextEditingController(text: widget.user?.username ?? '');
|
||
_realNameCtrl = TextEditingController(text: widget.user?.realName ?? '');
|
||
_phoneCtrl = TextEditingController(text: widget.user?.phone ?? '');
|
||
_passwordCtrl = TextEditingController();
|
||
_role = widget.user?.role ?? 'operator';
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_usernameCtrl.dispose();
|
||
_realNameCtrl.dispose();
|
||
_phoneCtrl.dispose();
|
||
_passwordCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
setState(() => _saving = true);
|
||
try {
|
||
final notifier = ref.read(userListProvider.notifier);
|
||
if (_isEdit) {
|
||
await notifier.updateUser(widget.user!.id, {
|
||
'real_name': _realNameCtrl.text.trim(),
|
||
'phone': _phoneCtrl.text.trim(),
|
||
'role': _role,
|
||
});
|
||
} else {
|
||
await notifier.createUser({
|
||
'username': _usernameCtrl.text.trim(),
|
||
'password': _passwordCtrl.text,
|
||
'real_name': _realNameCtrl.text.trim(),
|
||
'phone': _phoneCtrl.text.trim(),
|
||
'role': _role,
|
||
});
|
||
}
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
widget.onSaved();
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||
content: Text(_isEdit ? '用户更新成功' : '用户创建成功'),
|
||
backgroundColor: AppTheme.success,
|
||
));
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||
content: Text('保存失败:$e'),
|
||
backgroundColor: AppTheme.danger,
|
||
));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Text(_isEdit ? '编辑用户' : '新增用户'),
|
||
content: SizedBox(
|
||
width: 400,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!_isEdit)
|
||
TextFormField(
|
||
controller: _usernameCtrl,
|
||
decoration: const InputDecoration(labelText: '用户名'),
|
||
validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null,
|
||
),
|
||
if (!_isEdit) const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _realNameCtrl,
|
||
decoration: const InputDecoration(labelText: '姓名'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _phoneCtrl,
|
||
decoration: const InputDecoration(labelText: '手机号'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (!_isEdit) ...[
|
||
TextFormField(
|
||
controller: _passwordCtrl,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(labelText: '初始密码'),
|
||
validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
DropdownButtonFormField<String>(
|
||
value: _role,
|
||
items: const [
|
||
DropdownMenuItem(value: 'admin', child: Text('管理员')),
|
||
DropdownMenuItem(value: 'operator', child: Text('操作员')),
|
||
DropdownMenuItem(value: 'readonly', child: Text('只读')),
|
||
],
|
||
onChanged: (v) => setState(() => _role = v ?? 'operator'),
|
||
decoration: const InputDecoration(labelText: '角色'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _saving ? null : _save,
|
||
child: _saving
|
||
? const SizedBox(
|
||
width: 16, height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||
: const Text('保存'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 重置密码弹窗 ──────────────────────────────────────────
|
||
class _ResetPasswordDialog extends ConsumerStatefulWidget {
|
||
final AppUser user;
|
||
const _ResetPasswordDialog({required this.user});
|
||
|
||
@override
|
||
ConsumerState<_ResetPasswordDialog> createState() => _ResetPasswordDialogState();
|
||
}
|
||
|
||
class _ResetPasswordDialogState extends ConsumerState<_ResetPasswordDialog> {
|
||
final _ctrl = TextEditingController();
|
||
bool _saving = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (_ctrl.text.isEmpty) return;
|
||
setState(() => _saving = true);
|
||
try {
|
||
await ref.read(userListProvider.notifier).resetPassword(widget.user.id, _ctrl.text);
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
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,
|
||
));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Text('重置密码 — ${widget.user.username}'),
|
||
content: SizedBox(
|
||
width: 360,
|
||
child: TextField(
|
||
controller: _ctrl,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(labelText: '新密码'),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _saving ? null : _save,
|
||
child: _saving
|
||
? const SizedBox(
|
||
width: 16, height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||
: const Text('确认重置'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RoleBadge extends StatelessWidget {
|
||
final String role;
|
||
const _RoleBadge(this.role);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final Color bg;
|
||
final Color fg;
|
||
switch (role) {
|
||
case '管理员':
|
||
bg = const Color(0xFFE3F2FD);
|
||
fg = AppTheme.primary;
|
||
break;
|
||
case '操作员':
|
||
bg = const Color(0xFFE8F5E9);
|
||
fg = AppTheme.success;
|
||
break;
|
||
default:
|
||
bg = const Color(0xFFF5F5F5);
|
||
fg = AppTheme.textSecondary;
|
||
}
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
|
||
child: Text(role, style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ParamRow extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final bool? switchValue;
|
||
final ValueChanged<bool>? onSwitchChanged;
|
||
final VoidCallback? onEdit;
|
||
|
||
const _ParamRow({
|
||
required this.label,
|
||
required this.value,
|
||
this.switchValue,
|
||
this.onSwitchChanged,
|
||
this.onEdit,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 180,
|
||
child: Text(label,
|
||
style: const TextStyle(
|
||
fontSize: 14, color: AppTheme.textSecondary)),
|
||
),
|
||
if (switchValue != null)
|
||
Switch(
|
||
value: switchValue!,
|
||
onChanged: onSwitchChanged,
|
||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
)
|
||
else
|
||
Text(value,
|
||
style: const TextStyle(
|
||
fontSize: 14, fontWeight: FontWeight.w500)),
|
||
const Spacer(),
|
||
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!,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|