feat(client): 移动端布局适配 + 信息架构整理
- 入库/出库详情页:窄屏商品明细切换为卡片竖排,顶部操作收入溢出菜单 - 基础数据:新增「仓库」tab,使用 DataTableCard + mobileCards 适配窄屏 - 系统设置:移除仓库 tab(已迁入基础数据),新增「授权」tab,数据导入改名「数据管理」 - 关于我们:移除授权信息,补充帮助与文档/扫码防伪/法律信息/系统信息等模块 - AppInfo 新增 phone/wechat/termsUrl/privacyUrl/docsUrl 字段(可选,空则不渲染) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"provider": "岩美技术有限公司",
|
"provider": "岩美技术有限公司",
|
||||||
"website": "https://jiu.yanmei.com",
|
"website": "https://jiu.yanmei.com",
|
||||||
"email": "yammy2023@163.com"
|
"email": "yammy2023@163.com",
|
||||||
|
"phone": "",
|
||||||
|
"wechat": "",
|
||||||
|
"terms_url": "https://jiu.yanmei.com/terms/",
|
||||||
|
"privacy_url": "https://jiu.yanmei.com/privacy/",
|
||||||
|
"docs_url": "https://jiu.yanmei.com/docs/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class AppInfo {
|
|||||||
static String provider = '';
|
static String provider = '';
|
||||||
static String website = '';
|
static String website = '';
|
||||||
static String email = '';
|
static String email = '';
|
||||||
|
static String phone = '';
|
||||||
|
static String wechat = '';
|
||||||
|
static String termsUrl = '';
|
||||||
|
static String privacyUrl = '';
|
||||||
|
static String docsUrl = '';
|
||||||
|
|
||||||
/// 加载配置文件。失败时保留为空(不影响应用启动)。
|
/// 加载配置文件。失败时保留为空(不影响应用启动)。
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
@@ -21,6 +26,11 @@ class AppInfo {
|
|||||||
provider = (map['provider'] as String?) ?? '';
|
provider = (map['provider'] as String?) ?? '';
|
||||||
website = (map['website'] as String?) ?? '';
|
website = (map['website'] as String?) ?? '';
|
||||||
email = (map['email'] as String?) ?? '';
|
email = (map['email'] as String?) ?? '';
|
||||||
|
phone = (map['phone'] as String?) ?? '';
|
||||||
|
wechat = (map['wechat'] as String?) ?? '';
|
||||||
|
termsUrl = (map['terms_url'] as String?) ?? '';
|
||||||
|
privacyUrl = (map['privacy_url'] as String?) ?? '';
|
||||||
|
docsUrl = (map['docs_url'] as String?) ?? '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('AppInfo.load failed: $e');
|
debugPrint('AppInfo.load failed: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_info.dart';
|
import '../../core/config/app_info.dart';
|
||||||
@@ -11,10 +12,9 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../core/update/app_updater.dart';
|
import '../../core/update/app_updater.dart';
|
||||||
import '../../core/utils/dialog_util.dart';
|
import '../../core/utils/dialog_util.dart';
|
||||||
import '../../providers/feedback_provider.dart';
|
import '../../providers/feedback_provider.dart';
|
||||||
import '../../providers/license_provider.dart';
|
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
|
|
||||||
/// 「关于我们」独立页面(左侧菜单项)。原为系统设置里的「关于」Tab。
|
/// 「关于我们」独立页面(左侧菜单项)。
|
||||||
class AboutScreen extends ConsumerStatefulWidget {
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
const AboutScreen({super.key});
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||||
final licenseAsync = ref.watch(licenseProvider);
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -80,76 +79,33 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// ── 授权信息 ──
|
// ── 帮助与文档 ──
|
||||||
_AboutSection(
|
_AboutSection(
|
||||||
title: '授权信息',
|
title: '帮助与文档',
|
||||||
children: [
|
children: [
|
||||||
licenseAsync.when(
|
const _AboutRow(label: '使用手册', value: '查看产品使用文档与操作指南'),
|
||||||
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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _showRenewDialog(),
|
onPressed: () async {
|
||||||
icon: const Icon(Icons.card_membership, size: 16),
|
final url = Uri.parse(
|
||||||
label: const Text('续费 / 升级授权'),
|
AppInfo.docsUrl.isNotEmpty
|
||||||
|
? AppInfo.docsUrl
|
||||||
|
: '${AppInfo.website}/docs/');
|
||||||
|
if (await canLaunchUrl(url)) launchUrl(url);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||||
|
label: const Text('打开文档'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final url = Uri.parse('${AppInfo.website}/downloads/');
|
||||||
|
if (await canLaunchUrl(url)) launchUrl(url);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.history_outlined, size: 16),
|
||||||
|
label: const Text('更新日志'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -162,9 +118,25 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
title: '关于我们',
|
title: '关于我们',
|
||||||
children: [
|
children: [
|
||||||
_AboutRow(label: '服务商', value: AppInfo.provider),
|
_AboutRow(label: '服务商', value: AppInfo.provider),
|
||||||
_AboutRow(label: '官方网站', value: AppInfo.website),
|
_AboutRow(
|
||||||
|
label: '官方网站',
|
||||||
|
value: AppInfo.website,
|
||||||
|
trailing: AppInfo.website.isNotEmpty
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final uri = Uri.parse(AppInfo.website);
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
},
|
||||||
|
child: const Text('访问', style: TextStyle(fontSize: 12)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
||||||
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
|
if (AppInfo.phone.isNotEmpty)
|
||||||
|
_AboutRow(label: '联系电话', value: AppInfo.phone),
|
||||||
|
if (AppInfo.wechat.isNotEmpty)
|
||||||
|
_AboutRow(label: '微信', value: AppInfo.wechat),
|
||||||
|
const _AboutRow(label: '服务时间', value: '周一至周五 9:00–18:00'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -184,6 +156,81 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 扫码防伪 ──
|
||||||
|
const _AboutSection(
|
||||||
|
title: '扫码防伪',
|
||||||
|
children: [
|
||||||
|
_AboutRow(
|
||||||
|
label: '功能说明',
|
||||||
|
value: '每件商品可生成专属二维码,顾客扫码即可查看商品名称、系列、规格及批次信息,帮助验证商品真实性。',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 法律信息 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '法律信息',
|
||||||
|
children: [
|
||||||
|
if (AppInfo.termsUrl.isNotEmpty)
|
||||||
|
_AboutRow(
|
||||||
|
label: '服务条款',
|
||||||
|
value: '查看服务条款',
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final uri = Uri.parse(AppInfo.termsUrl);
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
},
|
||||||
|
child: const Text('查看', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (AppInfo.privacyUrl.isNotEmpty)
|
||||||
|
_AboutRow(
|
||||||
|
label: '隐私政策',
|
||||||
|
value: '查看隐私政策',
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final uri = Uri.parse(AppInfo.privacyUrl);
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
},
|
||||||
|
child: const Text('查看', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_AboutRow(label: '版权', value: '© ${DateTime.now().year} ${AppInfo.provider}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 系统信息 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '系统信息',
|
||||||
|
children: [
|
||||||
|
_AboutRow(
|
||||||
|
label: '运行平台',
|
||||||
|
value: kIsWeb
|
||||||
|
? 'Web'
|
||||||
|
: Platform.isAndroid
|
||||||
|
? 'Android'
|
||||||
|
: Platform.isIOS
|
||||||
|
? 'iOS'
|
||||||
|
: Platform.isMacOS
|
||||||
|
? 'macOS'
|
||||||
|
: Platform.isWindows
|
||||||
|
? 'Windows'
|
||||||
|
: Platform.operatingSystem,
|
||||||
|
),
|
||||||
|
_AboutRow(label: '应用版本', value: appVersion),
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: PackageInfo.fromPlatform(),
|
||||||
|
builder: (ctx, snap) => _AboutRow(
|
||||||
|
label: '构建号',
|
||||||
|
value: snap.data?.buildNumber ?? '-',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// ── 意见反馈 ──
|
// ── 意见反馈 ──
|
||||||
_AboutSection(
|
_AboutSection(
|
||||||
title: '意见反馈',
|
title: '意见反馈',
|
||||||
@@ -215,43 +262,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRenewDialog() {
|
|
||||||
showAppDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('续费 / 升级授权'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('请联系我们获取续费报价:'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SelectableText('📧 ${AppInfo.email}',
|
|
||||||
style: const TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('关闭'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await Clipboard.setData(ClipboardData(text: AppInfo.email));
|
|
||||||
if (ctx.mounted) {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('邮箱已复制到剪贴板')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('复制邮箱'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showFeedbackDialog({required bool isBug}) {
|
void _showFeedbackDialog({required bool isBug}) {
|
||||||
showAppDialog(
|
showAppDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/warehouse.dart';
|
||||||
import '../../providers/product_option_provider.dart';
|
import '../../providers/product_option_provider.dart';
|
||||||
|
import '../../providers/warehouse_provider.dart';
|
||||||
import '../../widgets/data_table_card.dart';
|
import '../../widgets/data_table_card.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/mobile_list_card.dart';
|
||||||
import '../../widgets/page_scaffold.dart';
|
import '../../widgets/page_scaffold.dart';
|
||||||
@@ -41,11 +43,13 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
|||||||
Tab(text: '商品名称'),
|
Tab(text: '商品名称'),
|
||||||
Tab(text: '系列'),
|
Tab(text: '系列'),
|
||||||
Tab(text: '规格'),
|
Tab(text: '规格'),
|
||||||
|
Tab(text: '仓库'),
|
||||||
],
|
],
|
||||||
tabViews: [
|
tabViews: [
|
||||||
_buildNameTab(),
|
_buildNameTab(),
|
||||||
_buildSeriesTab(),
|
_buildSeriesTab(),
|
||||||
_buildSpecTab(),
|
_buildSpecTab(),
|
||||||
|
_buildWarehousesTab(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -451,6 +455,123 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 仓库 tab ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildWarehousesTab() {
|
||||||
|
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||||
|
return asyncWarehouses.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => _buildError(() => ref.read(warehouseListProvider.notifier).reload()),
|
||||||
|
data: (warehouses) => DataTableCard(
|
||||||
|
totalCount: warehouses.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: warehouses.length + 1,
|
||||||
|
onPageChanged: (_) {},
|
||||||
|
onPageSizeChanged: (_) {},
|
||||||
|
toolbar: Row(
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _showWarehouseDialog(),
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('新建'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
mobileCards: warehouses
|
||||||
|
.map((w) => MobileListCard(
|
||||||
|
title: Text(w.name),
|
||||||
|
subtitle: w.location?.isNotEmpty == true ? Text(w.location!) : null,
|
||||||
|
fields: [
|
||||||
|
if (w.isDefault) const MobileCardField('默认仓库', '是'),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _showWarehouseDialog(warehouse: w),
|
||||||
|
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _confirmDeleteWarehouse(w),
|
||||||
|
child: const Text('删除',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
columns: const [
|
||||||
|
DataColumn(label: Text('仓库名称')),
|
||||||
|
DataColumn(label: Text('位置')),
|
||||||
|
DataColumn(label: Text('默认仓库')),
|
||||||
|
DataColumn(label: Text('操作')),
|
||||||
|
],
|
||||||
|
rows: warehouses.isEmpty
|
||||||
|
? [const DataRow(cells: [
|
||||||
|
DataCell(SizedBox()),
|
||||||
|
DataCell(Text('暂无仓库', style: TextStyle(color: AppTheme.textSecondary))),
|
||||||
|
DataCell(SizedBox()),
|
||||||
|
DataCell(SizedBox()),
|
||||||
|
])]
|
||||||
|
: 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(_actionButtons(
|
||||||
|
onEdit: () => _showWarehouseDialog(warehouse: w),
|
||||||
|
onDelete: () => _confirmDeleteWarehouse(w),
|
||||||
|
)),
|
||||||
|
]))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showWarehouseDialog({Warehouse? warehouse}) {
|
||||||
|
showAppDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _WarehouseFormDialog(
|
||||||
|
warehouse: warehouse,
|
||||||
|
onSaved: () => ref.read(warehouseListProvider.notifier).reload(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteWarehouse(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showOptionDialog({
|
Future<void> _showOptionDialog({
|
||||||
required String title,
|
required String title,
|
||||||
required bool hasQuantity,
|
required bool hasQuantity,
|
||||||
@@ -535,3 +656,119 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: context.dialogWidth(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))
|
||||||
|
: const Text('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
|
import '../../core/config/app_info.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/number_rule.dart';
|
import '../../models/number_rule.dart';
|
||||||
import '../../models/user.dart';
|
import '../../models/user.dart';
|
||||||
import '../../models/warehouse.dart';
|
import '../../providers/license_provider.dart';
|
||||||
import '../../providers/number_rule_provider.dart';
|
import '../../providers/number_rule_provider.dart';
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../providers/warehouse_provider.dart';
|
|
||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../models/shop.dart';
|
import '../../models/shop.dart';
|
||||||
|
|
||||||
@@ -56,10 +57,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: '酒行信息'),
|
Tab(text: '酒行信息'),
|
||||||
Tab(text: '用户管理'),
|
Tab(text: '用户管理'),
|
||||||
Tab(text: '仓库管理'),
|
|
||||||
Tab(text: '编号规则'),
|
Tab(text: '编号规则'),
|
||||||
Tab(text: '系统参数'),
|
Tab(text: '系统参数'),
|
||||||
Tab(text: '数据导入'),
|
Tab(text: '授权'),
|
||||||
|
Tab(text: '数据管理'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -69,9 +70,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildShopInfoTab(),
|
_buildShopInfoTab(),
|
||||||
_buildUsersTab(),
|
_buildUsersTab(),
|
||||||
_buildWarehousesTab(),
|
|
||||||
_buildNumberRulesTab(),
|
_buildNumberRulesTab(),
|
||||||
_buildSystemParamsTab(),
|
_buildSystemParamsTab(),
|
||||||
|
_buildLicenseTab(),
|
||||||
_buildImportTab(),
|
_buildImportTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -289,153 +290,151 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWarehousesTab() {
|
// ── 授权 Tab ──────────────────────────────────────────────
|
||||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
Widget _buildLicenseTab() {
|
||||||
return Column(
|
final licenseAsync = ref.watch(licenseProvider);
|
||||||
children: [
|
return SingleChildScrollView(
|
||||||
Container(
|
padding: const EdgeInsets.all(24),
|
||||||
height: 52,
|
child: Column(
|
||||||
color: AppTheme.surface,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
children: [
|
||||||
child: Row(
|
const Text('授权信息',
|
||||||
children: [
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
ElevatedButton.icon(
|
const SizedBox(height: 4),
|
||||||
onPressed: () => _showWarehouseDialog(context),
|
const Text('当前门店的授权状态与到期信息',
|
||||||
icon: const Icon(Icons.add, size: 16),
|
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
label: const Text('新建'),
|
const SizedBox(height: 16),
|
||||||
),
|
Card(
|
||||||
],
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.all(20),
|
||||||
),
|
child: licenseAsync.when(
|
||||||
const Divider(height: 1),
|
loading: () => const _ParamRow(label: '授权状态', value: '加载中…'),
|
||||||
Expanded(
|
error: (_, __) => const _ParamRow(label: '授权状态', value: '暂无授权信息'),
|
||||||
child: asyncWarehouses.when(
|
data: (lic) {
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
if (lic == null) {
|
||||||
error: (e, _) => Center(
|
return Column(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const _ParamRow(label: '授权状态', value: '未激活'),
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
OutlinedButton.icon(
|
||||||
const SizedBox(height: 12),
|
onPressed: _showRenewLicenseDialog,
|
||||||
const Text('暂无数据,网络不可用',
|
icon: const Icon(Icons.card_membership, size: 16),
|
||||||
style: const TextStyle(color: AppTheme.textSecondary)),
|
label: const Text('续费 / 升级授权'),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
ElevatedButton(
|
],
|
||||||
onPressed: () =>
|
);
|
||||||
ref.read(warehouseListProvider.notifier).reload(),
|
}
|
||||||
child: const Text('重试'),
|
final statusColor = lic.isExpired
|
||||||
),
|
? AppTheme.danger
|
||||||
],
|
: lic.isActive
|
||||||
|
? AppTheme.success
|
||||||
|
: AppTheme.textSecondary;
|
||||||
|
final statusText = lic.isExpired
|
||||||
|
? '已过期'
|
||||||
|
: lic.isActive
|
||||||
|
? '正常'
|
||||||
|
: '已停用';
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_ParamRow(label: '授权类型', value: lic.typeLabel),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: Text('授权状态',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary)),
|
||||||
|
),
|
||||||
|
Text(statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: statusColor)),
|
||||||
|
if (lic.daysRemaining != null &&
|
||||||
|
lic.daysRemaining! <= 30) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (lic.expiresAt != null)
|
||||||
|
_ParamRow(
|
||||||
|
label: '到期时间',
|
||||||
|
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!))
|
||||||
|
else
|
||||||
|
const _ParamRow(label: '到期时间', value: '永久有效'),
|
||||||
|
if (lic.activatedAt != null)
|
||||||
|
_ParamRow(
|
||||||
|
label: '激活时间',
|
||||||
|
value: DateFormat('yyyy-MM-dd').format(lic.activatedAt!)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _showRenewLicenseDialog,
|
||||||
|
icon: const Icon(Icons.card_membership, size: 16),
|
||||||
|
label: 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}) {
|
void _showRenewLicenseDialog() {
|
||||||
showAppDialog(
|
showAppDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => _WarehouseFormDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
warehouse: warehouse,
|
title: const Text('续费 / 升级授权'),
|
||||||
onSaved: () =>
|
content: Column(
|
||||||
ref.read(warehouseListProvider.notifier).reload(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('请联系我们获取续费报价:'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SelectableText('📧 ${AppInfo.email}',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('关闭'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: AppInfo.email));
|
||||||
|
if (ctx.mounted) {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('邮箱已复制到剪贴板')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('复制邮箱'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -760,133 +759,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: context.dialogWidth(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 {
|
class _UserFormDialog extends ConsumerStatefulWidget {
|
||||||
final AppUser? user;
|
final AppUser? user;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../providers/inventory_provider.dart';
|
|||||||
import '../../providers/stock_in_provider.dart';
|
import '../../providers/stock_in_provider.dart';
|
||||||
import '../../providers/warehouse_provider.dart';
|
import '../../providers/warehouse_provider.dart';
|
||||||
import '../../widgets/searchable_option_field.dart';
|
import '../../widgets/searchable_option_field.dart';
|
||||||
|
import '../../widgets/mobile_list_card.dart';
|
||||||
|
|
||||||
class StockInFormScreen extends ConsumerStatefulWidget {
|
class StockInFormScreen extends ConsumerStatefulWidget {
|
||||||
final int? editOrderId;
|
final int? editOrderId;
|
||||||
@@ -296,6 +297,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||||
final asyncSuppliers = ref.watch(supplierListProvider);
|
final asyncSuppliers = ref.watch(supplierListProvider);
|
||||||
final currentUser = ref.watch(authStateProvider).user;
|
final currentUser = ref.watch(authStateProvider).user;
|
||||||
|
final isMobile = context.isMobile;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
@@ -316,35 +318,71 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
Text(_isEdit ? '修改入库单' : '新建入库单',
|
Text(_isEdit ? '修改入库单' : '新建入库单',
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (_isEdit && _loadedOrder != null) ...[
|
// 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏
|
||||||
OutlinedButton.icon(
|
if (isMobile) ...[
|
||||||
onPressed: _printOrder,
|
ElevatedButton(
|
||||||
icon: const Icon(Icons.print_outlined, size: 16),
|
onPressed: _submitting ? null : () => _submit(false),
|
||||||
label: const Text('打印'),
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('提交'),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
switch (v) {
|
||||||
|
case 'draft':
|
||||||
|
_submit(true);
|
||||||
|
break;
|
||||||
|
case 'print':
|
||||||
|
_printOrder();
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
context.go('/stock-in');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(value: 'draft', child: Text('保存草稿')),
|
||||||
|
if (_isEdit && _loadedOrder != null)
|
||||||
|
const PopupMenuItem(value: 'print', child: Text('打印')),
|
||||||
|
const PopupMenuItem(value: 'cancel', child: Text('取消')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
if (_isEdit && _loadedOrder != null) ...[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _printOrder,
|
||||||
|
icon: const Icon(Icons.print_outlined, size: 16),
|
||||||
|
label: const Text('打印'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : () => _submit(true),
|
||||||
|
child: const Text('保存草稿'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _submitting ? null : () => _submit(false),
|
||||||
|
icon: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Icon(Icons.send, size: 16),
|
||||||
|
label: const Text('提交审核'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => context.go('/stock-in'),
|
||||||
|
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||||
|
label: const Text('取消'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _submitting ? null : () => _submit(true),
|
|
||||||
child: const Text('保存草稿'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _submitting ? null : () => _submit(false),
|
|
||||||
icon: _submitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
||||||
: const Icon(Icons.send, size: 16),
|
|
||||||
label: const Text('提交审核'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => context.go('/stock-in'),
|
|
||||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
|
||||||
label: const Text('取消'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -498,41 +536,53 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Table(
|
// 窄屏:逐项卡片竖排,避免 12 列表格横向溢出;宽屏保持表格
|
||||||
columnWidths: const {
|
if (isMobile)
|
||||||
0: FixedColumnWidth(36), // 序号
|
Column(
|
||||||
1: FlexColumnWidth(1.2), // 商品编码
|
children: List.generate(
|
||||||
2: FlexColumnWidth(2.0), // 名称
|
_items.length,
|
||||||
3: FlexColumnWidth(1.3), // 系列
|
(i) => Padding(
|
||||||
4: FlexColumnWidth(1.3), // 规格
|
padding:
|
||||||
5: FlexColumnWidth(0.9), // 单品数量
|
const EdgeInsets.only(bottom: 10),
|
||||||
6: FlexColumnWidth(1.0), // 数量
|
child: _buildItemCard(i),
|
||||||
7: FlexColumnWidth(1.0), // 单价
|
)),
|
||||||
8: FlexColumnWidth(1.0), // 金额
|
)
|
||||||
9: FlexColumnWidth(1.2), // 批次号
|
else
|
||||||
10: FlexColumnWidth(1.2), // 生产日期
|
Table(
|
||||||
11: FixedColumnWidth(60), // 操作
|
columnWidths: const {
|
||||||
},
|
0: FixedColumnWidth(36), // 序号
|
||||||
children: [
|
1: FlexColumnWidth(1.2), // 商品编码
|
||||||
TableRow(
|
2: FlexColumnWidth(2.0), // 名称
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
3: FlexColumnWidth(1.3), // 系列
|
||||||
children: [
|
4: FlexColumnWidth(1.3), // 规格
|
||||||
'序号', '商品编码', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '批次号', '生产日期', '操作',
|
5: FlexColumnWidth(0.9), // 单品数量
|
||||||
]
|
6: FlexColumnWidth(1.0), // 数量
|
||||||
.map((h) => Padding(
|
7: FlexColumnWidth(1.0), // 单价
|
||||||
padding: const EdgeInsets.symmetric(
|
8: FlexColumnWidth(1.0), // 金额
|
||||||
horizontal: 8, vertical: 10),
|
9: FlexColumnWidth(1.2), // 批次号
|
||||||
child: Text(h,
|
10: FlexColumnWidth(1.2), // 生产日期
|
||||||
style: const TextStyle(
|
11: FixedColumnWidth(60), // 操作
|
||||||
fontSize: 13,
|
},
|
||||||
fontWeight: FontWeight.w600,
|
children: [
|
||||||
color: AppTheme.primaryDark)),
|
TableRow(
|
||||||
))
|
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||||
.toList(),
|
children: [
|
||||||
),
|
'序号', '商品编码', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '批次号', '生产日期', '操作',
|
||||||
...List.generate(_items.length, (i) => _buildItemRow(i)),
|
]
|
||||||
],
|
.map((h) => Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 10),
|
||||||
|
child: Text(h,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
...List.generate(_items.length, (i) => _buildItemRow(i)),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
@@ -566,104 +616,191 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 明细字段构件(表格行与移动卡片共用,保持两种布局一致)──────────
|
||||||
|
|
||||||
|
Widget _nameField(_ItemRow item) {
|
||||||
|
final asyncNames = ref.watch(productNameListProvider);
|
||||||
|
return asyncNames.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (names) => SearchableOptionField(
|
||||||
|
options: names
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedNameId,
|
||||||
|
hint: '选择名称',
|
||||||
|
dialogTitle: '选择商品名称',
|
||||||
|
isRequired: true,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
item.selectedNameId = v;
|
||||||
|
item.productId = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _seriesField(_ItemRow item) {
|
||||||
|
final asyncSeries = ref.watch(productSeriesListProvider);
|
||||||
|
return asyncSeries.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (series) => SearchableOptionField(
|
||||||
|
options: series
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedSeriesId,
|
||||||
|
hint: '选择系列',
|
||||||
|
dialogTitle: '选择系列',
|
||||||
|
isRequired: true,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
item.selectedSeriesId = v;
|
||||||
|
item.productId = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _specField(_ItemRow item) {
|
||||||
|
final asyncSpecs = ref.watch(productSpecListProvider);
|
||||||
|
return asyncSpecs.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (specs) => SearchableOptionField(
|
||||||
|
options: specs
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedSpecId,
|
||||||
|
hint: '选择规格',
|
||||||
|
dialogTitle: '选择规格',
|
||||||
|
isRequired: true,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
item.selectedSpecId = v;
|
||||||
|
item.productId = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _qtyField(_ItemRow item) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: item.qtyCtrl,
|
||||||
|
decoration: const InputDecoration(hintText: '0', isDense: true),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||||
|
],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return '不能为空';
|
||||||
|
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _priceField(_ItemRow item) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: item.priceCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '0.00', prefixText: '¥', isDense: true),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||||
|
],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return '不能为空';
|
||||||
|
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _batchField(_ItemRow item) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: item.batchNoCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '选填',
|
||||||
|
labelText: '批次号',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateField(_ItemRow item) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: item.productionDateCtrl,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请选择',
|
||||||
|
labelText: '* 生产日期',
|
||||||
|
isDense: true,
|
||||||
|
suffixIcon: Icon(Icons.calendar_today, size: 14),
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
onTap: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: item.productionDate ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
locale: const Locale('zh', 'CN'),
|
||||||
|
);
|
||||||
|
if (date != null) {
|
||||||
|
setState(() {
|
||||||
|
item.productionDate = date;
|
||||||
|
item.productionDateCtrl.text =
|
||||||
|
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _specQtyOf(_ItemRow item) =>
|
||||||
|
ref.read(productSpecListProvider).valueOrNull
|
||||||
|
?.where((o) => o.id == item.selectedSpecId)
|
||||||
|
.firstOrNull
|
||||||
|
?.quantity ??
|
||||||
|
0;
|
||||||
|
|
||||||
|
String _productCodeOf(_ItemRow item) =>
|
||||||
|
ref.read(productNameListProvider).valueOrNull
|
||||||
|
?.where((o) => o.id == item.selectedNameId)
|
||||||
|
.firstOrNull
|
||||||
|
?.code ??
|
||||||
|
'';
|
||||||
|
|
||||||
TableRow _buildItemRow(int index) {
|
TableRow _buildItemRow(int index) {
|
||||||
final item = _items[index];
|
final item = _items[index];
|
||||||
final asyncNames = ref.watch(productNameListProvider);
|
|
||||||
final asyncSeries = ref.watch(productSeriesListProvider);
|
|
||||||
final asyncSpecs = ref.watch(productSpecListProvider);
|
|
||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||||
final amount = qty * price;
|
final amount = qty * price;
|
||||||
final specQty = asyncSpecs.valueOrNull
|
final specQty = _specQtyOf(item);
|
||||||
?.where((o) => o.id == item.selectedSpecId)
|
final productCode = _productCodeOf(item);
|
||||||
.firstOrNull
|
|
||||||
?.quantity ?? 0;
|
|
||||||
final productCode = asyncNames.valueOrNull
|
|
||||||
?.where((o) => o.id == item.selectedNameId)
|
|
||||||
.firstOrNull
|
|
||||||
?.code ?? '';
|
|
||||||
|
|
||||||
return TableRow(
|
return TableRow(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
|
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
// 序号
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
child: Text('${index + 1}',
|
child: Text('${index + 1}',
|
||||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
),
|
),
|
||||||
// 商品编码
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
child: Text(productCode,
|
child: Text(productCode,
|
||||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
||||||
),
|
),
|
||||||
// 名称
|
Padding(padding: const EdgeInsets.all(4), child: _nameField(item)),
|
||||||
Padding(
|
Padding(padding: const EdgeInsets.all(4), child: _seriesField(item)),
|
||||||
padding: const EdgeInsets.all(4),
|
Padding(padding: const EdgeInsets.all(4), child: _specField(item)),
|
||||||
child: asyncNames.when(
|
|
||||||
loading: () => const LinearProgressIndicator(),
|
|
||||||
error: (_, __) => const Text('加载失败'),
|
|
||||||
data: (names) => SearchableOptionField(
|
|
||||||
options: names
|
|
||||||
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
|
||||||
.toList(),
|
|
||||||
selectedId: item.selectedNameId,
|
|
||||||
hint: '选择名称',
|
|
||||||
dialogTitle: '选择商品名称',
|
|
||||||
isRequired: true,
|
|
||||||
onChanged: (v) => setState(() {
|
|
||||||
item.selectedNameId = v;
|
|
||||||
item.productId = null;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 系列
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: asyncSeries.when(
|
|
||||||
loading: () => const LinearProgressIndicator(),
|
|
||||||
error: (_, __) => const Text('加载失败'),
|
|
||||||
data: (series) => SearchableOptionField(
|
|
||||||
options: series
|
|
||||||
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
|
||||||
.toList(),
|
|
||||||
selectedId: item.selectedSeriesId,
|
|
||||||
hint: '选择系列',
|
|
||||||
dialogTitle: '选择系列',
|
|
||||||
isRequired: true,
|
|
||||||
onChanged: (v) => setState(() {
|
|
||||||
item.selectedSeriesId = v;
|
|
||||||
item.productId = null;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 规格
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: asyncSpecs.when(
|
|
||||||
loading: () => const LinearProgressIndicator(),
|
|
||||||
error: (_, __) => const Text('加载失败'),
|
|
||||||
data: (specs) => SearchableOptionField(
|
|
||||||
options: specs
|
|
||||||
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
|
||||||
.toList(),
|
|
||||||
selectedId: item.selectedSpecId,
|
|
||||||
hint: '选择规格',
|
|
||||||
dialogTitle: '选择规格',
|
|
||||||
isRequired: true,
|
|
||||||
onChanged: (v) => setState(() {
|
|
||||||
item.selectedSpecId = v;
|
|
||||||
item.productId = null;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 单品数量
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -674,46 +811,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 数量
|
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
|
||||||
Padding(
|
Padding(padding: const EdgeInsets.all(4), child: _priceField(item)),
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: item.qtyCtrl,
|
|
||||||
decoration: const InputDecoration(hintText: '0', isDense: true),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
|
||||||
],
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.isEmpty) return '不能为空';
|
|
||||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 单价
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: item.priceCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '0.00', prefixText: '¥', isDense: true),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
|
||||||
],
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.isEmpty) return '不能为空';
|
|
||||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 金额
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -721,52 +820,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 批次号
|
Padding(padding: const EdgeInsets.all(4), child: _batchField(item)),
|
||||||
Padding(
|
Padding(padding: const EdgeInsets.all(4), child: _dateField(item)),
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: item.batchNoCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '选填',
|
|
||||||
labelText: '批次号',
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 生产日期
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: item.productionDateCtrl,
|
|
||||||
readOnly: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '请选择',
|
|
||||||
labelText: '* 生产日期',
|
|
||||||
isDense: true,
|
|
||||||
suffixIcon: Icon(Icons.calendar_today, size: 14),
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
onTap: () async {
|
|
||||||
final date = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: item.productionDate ?? DateTime.now(),
|
|
||||||
firstDate: DateTime(2000),
|
|
||||||
lastDate: DateTime(2100),
|
|
||||||
locale: const Locale('zh', 'CN'),
|
|
||||||
);
|
|
||||||
if (date != null) {
|
|
||||||
setState(() {
|
|
||||||
item.productionDate = date;
|
|
||||||
item.productionDateCtrl.text =
|
|
||||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 操作
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -781,6 +836,38 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
|
||||||
|
Widget _buildItemCard(int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
|
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||||
|
final amount = qty * price;
|
||||||
|
final specQty = _specQtyOf(item);
|
||||||
|
final productCode = _productCodeOf(item);
|
||||||
|
|
||||||
|
return MobileListCard(
|
||||||
|
title: Text('商品 ${index + 1}'),
|
||||||
|
subtitle: productCode.isNotEmpty ? Text('编码 $productCode') : null,
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
|
||||||
|
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
|
||||||
|
tooltip: '删除',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
fields: [
|
||||||
|
MobileCardField('名称', null, valueWidget: _nameField(item)),
|
||||||
|
MobileCardField('系列', null, valueWidget: _seriesField(item)),
|
||||||
|
MobileCardField('规格', null, valueWidget: _specField(item)),
|
||||||
|
MobileCardField('单品数量', specQty > 0 ? '$specQty' : '-'),
|
||||||
|
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
||||||
|
MobileCardField('单价', null, valueWidget: _priceField(item)),
|
||||||
|
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||||
|
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
||||||
|
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInventoryCell(int? productId) {
|
Widget _buildInventoryCell(int? productId) {
|
||||||
if (productId == null) {
|
if (productId == null) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../providers/inventory_provider.dart';
|
|||||||
import '../../providers/partner_provider.dart';
|
import '../../providers/partner_provider.dart';
|
||||||
import '../../providers/stock_out_provider.dart';
|
import '../../providers/stock_out_provider.dart';
|
||||||
import '../../providers/warehouse_provider.dart';
|
import '../../providers/warehouse_provider.dart';
|
||||||
|
import '../../widgets/mobile_list_card.dart';
|
||||||
|
|
||||||
// Aggregated per-product inventory item for the picker dialog
|
// Aggregated per-product inventory item for the picker dialog
|
||||||
class _PickerItem {
|
class _PickerItem {
|
||||||
@@ -307,6 +308,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||||
final asyncCustomers = ref.watch(customerListProvider);
|
final asyncCustomers = ref.watch(customerListProvider);
|
||||||
final currentUser = ref.watch(authStateProvider).user;
|
final currentUser = ref.watch(authStateProvider).user;
|
||||||
|
final isMobile = context.isMobile;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
@@ -327,35 +329,71 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
Text(_isEdit ? '修改出库单' : '新建出库单',
|
Text(_isEdit ? '修改出库单' : '新建出库单',
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (_isEdit && _loadedOrder != null) ...[
|
// 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏
|
||||||
OutlinedButton.icon(
|
if (isMobile) ...[
|
||||||
onPressed: _printOrder,
|
ElevatedButton(
|
||||||
icon: const Icon(Icons.print_outlined, size: 16),
|
onPressed: _submitting ? null : () => _submit(false),
|
||||||
label: const Text('打印'),
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('提交'),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
switch (v) {
|
||||||
|
case 'draft':
|
||||||
|
_submit(true);
|
||||||
|
break;
|
||||||
|
case 'print':
|
||||||
|
_printOrder();
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
context.go('/stock-out');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(value: 'draft', child: Text('保存草稿')),
|
||||||
|
if (_isEdit && _loadedOrder != null)
|
||||||
|
const PopupMenuItem(value: 'print', child: Text('打印')),
|
||||||
|
const PopupMenuItem(value: 'cancel', child: Text('取消')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
if (_isEdit && _loadedOrder != null) ...[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _printOrder,
|
||||||
|
icon: const Icon(Icons.print_outlined, size: 16),
|
||||||
|
label: const Text('打印'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : () => _submit(true),
|
||||||
|
child: const Text('保存草稿'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _submitting ? null : () => _submit(false),
|
||||||
|
icon: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Icon(Icons.send, size: 16),
|
||||||
|
label: const Text('提交审核'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => context.go('/stock-out'),
|
||||||
|
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||||
|
label: const Text('取消'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _submitting ? null : () => _submit(true),
|
|
||||||
child: const Text('保存草稿'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _submitting ? null : () => _submit(false),
|
|
||||||
icon: _submitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
||||||
: const Icon(Icons.send, size: 16),
|
|
||||||
label: const Text('提交审核'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => context.go('/stock-out'),
|
|
||||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
|
||||||
label: const Text('取消'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -507,38 +545,50 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Table(
|
// 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格
|
||||||
columnWidths: const {
|
if (isMobile)
|
||||||
0: FixedColumnWidth(36), // 序号
|
Column(
|
||||||
1: FlexColumnWidth(1.2), // 商品编码
|
children: List.generate(
|
||||||
2: FlexColumnWidth(2.0), // 商品名称
|
_items.length,
|
||||||
3: FlexColumnWidth(1.2), // 系列
|
(i) => Padding(
|
||||||
4: FlexColumnWidth(1.2), // 规格
|
padding:
|
||||||
5: FlexColumnWidth(1.0), // 单价
|
const EdgeInsets.only(bottom: 10),
|
||||||
6: FlexColumnWidth(0.8), // 数量
|
child: _buildItemCard(i),
|
||||||
7: FlexColumnWidth(1.0), // 金额
|
)),
|
||||||
8: FixedColumnWidth(48), // 操作
|
)
|
||||||
},
|
else
|
||||||
children: [
|
Table(
|
||||||
TableRow(
|
columnWidths: const {
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
0: FixedColumnWidth(36), // 序号
|
||||||
children: [
|
1: FlexColumnWidth(1.2), // 商品编码
|
||||||
'序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作',
|
2: FlexColumnWidth(2.0), // 商品名称
|
||||||
]
|
3: FlexColumnWidth(1.2), // 系列
|
||||||
.map((h) => Padding(
|
4: FlexColumnWidth(1.2), // 规格
|
||||||
padding: const EdgeInsets.symmetric(
|
5: FlexColumnWidth(1.0), // 单价
|
||||||
horizontal: 8, vertical: 10),
|
6: FlexColumnWidth(0.8), // 数量
|
||||||
child: Text(h,
|
7: FlexColumnWidth(1.0), // 金额
|
||||||
style: const TextStyle(
|
8: FixedColumnWidth(48), // 操作
|
||||||
fontSize: 13,
|
},
|
||||||
fontWeight: FontWeight.w600,
|
children: [
|
||||||
color: AppTheme.primaryDark)),
|
TableRow(
|
||||||
))
|
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||||
.toList(),
|
children: [
|
||||||
),
|
'序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作',
|
||||||
...List.generate(_items.length, (i) => _buildItemRow(i)),
|
]
|
||||||
],
|
.map((h) => Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 10),
|
||||||
|
child: Text(h,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
...List.generate(_items.length, (i) => _buildItemRow(i)),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (_items.isEmpty)
|
if (_items.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
@@ -583,7 +633,6 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
final price = item.unitPrice ?? 0;
|
final price = item.unitPrice ?? 0;
|
||||||
final amount = qty * price;
|
final amount = qty * price;
|
||||||
final available = _inventoryMap[item.productId];
|
|
||||||
|
|
||||||
return TableRow(
|
return TableRow(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -603,22 +652,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
// 单价
|
// 单价
|
||||||
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))),
|
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))),
|
||||||
// 数量
|
// 数量
|
||||||
Padding(
|
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: TextFormField(
|
|
||||||
controller: item.qtyCtrl,
|
|
||||||
decoration: const InputDecoration(hintText: '0', isDense: true),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.isEmpty) return '不能为空';
|
|
||||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 金额
|
// 金额
|
||||||
_cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
|
_cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
|
||||||
// 操作
|
// 操作
|
||||||
@@ -636,6 +670,48 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _qtyField(_ItemRow item) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: item.qtyCtrl,
|
||||||
|
decoration: const InputDecoration(hintText: '0', isDense: true),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return '不能为空';
|
||||||
|
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
|
||||||
|
Widget _buildItemCard(int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||||
|
final price = item.unitPrice ?? 0;
|
||||||
|
final amount = qty * price;
|
||||||
|
|
||||||
|
return MobileListCard(
|
||||||
|
title: Text(item.productName),
|
||||||
|
subtitle: item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null,
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
|
||||||
|
onPressed: () => _removeItem(index),
|
||||||
|
tooltip: '删除',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
fields: [
|
||||||
|
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||||
|
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
|
||||||
|
MobileCardField('单价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'),
|
||||||
|
MobileCardField('数量', null, valueWidget: _qtyField(item)),
|
||||||
|
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _cell(Widget child) => Padding(
|
Widget _cell(Widget child) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
child: child,
|
child: child,
|
||||||
|
|||||||
Reference in New Issue
Block a user