feat(client): 商品详情升级富数据版(KPI + 仓库分布 + 流水)
接后端新能力:listInventory 加 productId 透传;详情异步拉本商品库存+流水(失败 静默不阻塞主数据),渲染原型富数据三件套: - 3 KPI 卡(当前库存 / 库存货值=库存×进价 / 近30天出库=出库流水30天聚合) - 各仓库库存分布(按仓库分组 数量+货值) - 近期流水(StatusPill 入/出 + 增减量,最近 8 条) 价格历史待后端第二步。整屏 golden ×三主题入回归闸。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
@@ -11,6 +11,7 @@ class InventoryRepository {
|
||||
|
||||
Future<PageResult<Inventory>> listInventory({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
String? keyword,
|
||||
List<String>? series,
|
||||
List<String>? spec,
|
||||
@@ -22,6 +23,7 @@ class InventoryRepository {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (warehouseId != null) 'warehouse_id': warehouseId,
|
||||
if (productId != null) 'product_id': productId,
|
||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||
if (series != null && series.isNotEmpty) 'series': series.join(','),
|
||||
if (spec != null && spec.isNotEmpty) 'spec': spec.join(','),
|
||||
|
||||
@@ -12,10 +12,15 @@ import '../../widgets/label_preview_dialog.dart';
|
||||
import '../../widgets/fullscreen_image_viewer.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../models/product_image.dart';
|
||||
import '../../models/inventory.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||
import '../../widgets/kpi_card.dart';
|
||||
|
||||
class ProductDetailScreen extends ConsumerStatefulWidget {
|
||||
final int productId;
|
||||
@@ -33,6 +38,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
bool _savingDesc = false;
|
||||
bool _descChanged = false;
|
||||
|
||||
// 富数据(库存聚合 + 流水),随详情异步加载,失败不阻塞主数据。
|
||||
List<Inventory> _invRows = [];
|
||||
List<InventoryLog> _logs = [];
|
||||
|
||||
late TextEditingController _descCtrl;
|
||||
|
||||
@override
|
||||
@@ -60,10 +69,30 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
_product = p;
|
||||
_descCtrl.text = p.description ?? '';
|
||||
_descChanged = false;
|
||||
_loadStats();
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
/// 拉本商品的库存聚合 + 流水(fire-and-forget,失败不影响主数据展示)。
|
||||
Future<void> _loadStats() async {
|
||||
try {
|
||||
final repo = ref.read(inventoryRepositoryProvider);
|
||||
final inv =
|
||||
await repo.listInventory(productId: widget.productId, pageSize: 1000);
|
||||
final logs =
|
||||
await repo.listLogs(productId: widget.productId, pageSize: 100);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_invRows = inv.data;
|
||||
_logs = logs.data;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 富数据失败静默——详情主数据照常展示
|
||||
}
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _load());
|
||||
|
||||
Future<void> _saveDesc() async {
|
||||
@@ -273,9 +302,19 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildStatKpis(p),
|
||||
const SizedBox(height: 20),
|
||||
_buildImageSection(p),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoSection(p),
|
||||
if (_invRows.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildWarehouseDist(p),
|
||||
],
|
||||
if (_logs.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildRecentLogs(),
|
||||
],
|
||||
if (p.salePrice != null && p.salePrice! > 0) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceSection(p),
|
||||
@@ -296,6 +335,164 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 富数据:KPI / 各仓库分布 / 近期流水(还原原型详情页) ──────────────
|
||||
|
||||
Widget _statCard({required String title, Widget? hint, required Widget body}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
side: BorderSide(color: context.tokens.border, width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsTitle,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.tokens.heading)),
|
||||
const Spacer(),
|
||||
if (hint != null) hint,
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
body,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatKpis(Product p) {
|
||||
final currentStock = _invRows.fold<num>(0, (s, i) => s + i.quantity);
|
||||
final cost = p.purchasePrice ?? 0;
|
||||
final stockValue = currentStock * cost;
|
||||
final now = DateTime.now();
|
||||
final out30 = _logs.where((l) {
|
||||
if (l.direction != 'out') return false;
|
||||
final d = DateTime.tryParse(l.createdAt ?? '');
|
||||
return d != null && now.difference(d).inDays <= 30;
|
||||
}).fold<num>(0, (s, l) => s + l.quantity);
|
||||
|
||||
final cards = <Widget>[
|
||||
KpiCard(
|
||||
title: '当前库存',
|
||||
value: currentStock.toStringAsFixed(0),
|
||||
icon: Icons.inventory_2,
|
||||
tone: KpiTone.info,
|
||||
delta: '件'),
|
||||
KpiCard(
|
||||
title: '库存货值',
|
||||
value: '¥${(stockValue / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.account_balance_wallet,
|
||||
tone: KpiTone.ok),
|
||||
KpiCard(
|
||||
title: '近30天出库',
|
||||
value: out30.toStringAsFixed(0),
|
||||
icon: Icons.upload_outlined,
|
||||
tone: KpiTone.blue,
|
||||
delta: '件'),
|
||||
];
|
||||
final mobile = context.isMobile;
|
||||
final row = <Widget>[];
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (i > 0) row.add(const SizedBox(width: AppDims.sp3));
|
||||
row.add(mobile
|
||||
? SizedBox(width: 150, child: cards[i])
|
||||
: Expanded(child: cards[i]));
|
||||
}
|
||||
return mobile
|
||||
? SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: IntrinsicHeight(child: Row(children: row)))
|
||||
: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, children: row));
|
||||
}
|
||||
|
||||
Widget _buildWarehouseDist(Product p) {
|
||||
final cost = p.purchasePrice ?? 0;
|
||||
final byWh = <String, num>{};
|
||||
for (final inv in _invRows) {
|
||||
final wh = inv.warehouseName.isEmpty ? '—' : inv.warehouseName;
|
||||
byWh[wh] = (byWh[wh] ?? 0) + inv.quantity;
|
||||
}
|
||||
final total = byWh.values.fold<num>(0, (s, v) => s + v);
|
||||
return _statCard(
|
||||
title: '各仓库库存分布',
|
||||
hint: Text('合计 ${total.toStringAsFixed(0)} 件',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
|
||||
body: Column(
|
||||
children: byWh.entries.map((e) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(e.key)),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text('${e.value.toStringAsFixed(0)} 件',
|
||||
textAlign: TextAlign.right)),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'¥${((e.value * cost) / 10000).toStringAsFixed(2)}万',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: context.tokens.muted))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecentLogs() {
|
||||
return _statCard(
|
||||
title: '近期流水',
|
||||
hint: Text('最近 ${_logs.length > 8 ? 8 : _logs.length} 条',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
|
||||
body: Column(
|
||||
children: _logs.take(8).map((l) {
|
||||
final isIn = l.direction == 'in';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(l.createdAt?.substring(0, 10) ?? '-',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: AppDims.fsSm,
|
||||
color: context.tokens.muted)),
|
||||
),
|
||||
StatusPill(
|
||||
label: isIn ? '入库' : '出库',
|
||||
color: isIn ? context.tokens.success : context.tokens.danger,
|
||||
background:
|
||||
isIn ? context.tokens.okSoft : context.tokens.dangerBg,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${isIn ? '+' : '-'}${l.quantity.toStringAsFixed(0)}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isIn ? context.tokens.success : context.tokens.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageSection(Product p) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:jiu_client/core/models/page_result.dart';
|
||||
import 'package:jiu_client/models/product.dart';
|
||||
import 'package:jiu_client/models/inventory.dart';
|
||||
import 'package:jiu_client/providers/product_provider.dart';
|
||||
import 'package:jiu_client/providers/inventory_provider.dart';
|
||||
import 'package:jiu_client/repositories/product_repository.dart';
|
||||
import 'package:jiu_client/repositories/inventory_repository.dart';
|
||||
import 'package:jiu_client/screens/products/product_detail_screen.dart';
|
||||
|
||||
import '../support/golden_harness.dart';
|
||||
|
||||
/// design-distill 阶段4:商品详情「富数据版」golden × 三主题。
|
||||
/// 验证 3 KPI(当前库存/货值/近30天出库)+ 各仓库分布 + 近期流水(接后端
|
||||
/// /inventory?product_id + /inventory/logs?product_id 聚合)。
|
||||
/// 更新基准:flutter test --update-goldens test/golden/product_detail_golden_test.dart
|
||||
|
||||
class _FakeProductRepo implements ProductRepository {
|
||||
@override
|
||||
Future<Product> getDetail(int id) async => const Product(
|
||||
id: 1, code: 'MT-FT-500', name: '茅台 飞天 53°', unit: '件',
|
||||
series: '飞天', spec: '500ml×6', brand: '茅台',
|
||||
purchasePrice: 2680, categoryName: '酱香');
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeInvRepo implements InventoryRepository {
|
||||
@override
|
||||
Future<PageResult<Inventory>> listInventory({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
String? keyword,
|
||||
List<String>? series,
|
||||
List<String>? spec,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async =>
|
||||
const PageResult(data: [
|
||||
Inventory(id: 1, productId: 1, quantity: 92, warehouseName: '主仓'),
|
||||
Inventory(id: 2, productId: 1, quantity: 28, warehouseName: '二号仓'),
|
||||
Inventory(id: 3, productId: 1, quantity: 8, warehouseName: '冷藏库'),
|
||||
], total: 3, page: 1, pageSize: 1000);
|
||||
|
||||
@override
|
||||
Future<PageResult<InventoryLog>> listLogs({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async =>
|
||||
const PageResult(data: [
|
||||
InventoryLog(
|
||||
warehouseId: 1, productId: 1, direction: 'in', quantity: 24,
|
||||
createdAt: '2026-06-20T09:00:00'),
|
||||
InventoryLog(
|
||||
warehouseId: 1, productId: 1, direction: 'out', quantity: 12,
|
||||
createdAt: '2026-06-18T09:00:00'),
|
||||
InventoryLog(
|
||||
warehouseId: 2, productId: 1, direction: 'out', quantity: 18,
|
||||
createdAt: '2026-06-15T09:00:00'),
|
||||
InventoryLog(
|
||||
warehouseId: 1, productId: 1, direction: 'in', quantity: 36,
|
||||
createdAt: '2026-06-12T09:00:00'),
|
||||
], total: 4, page: 1, pageSize: 100);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
List<Override> _overrides() => [
|
||||
productRepositoryProvider.overrideWithValue(_FakeProductRepo()),
|
||||
inventoryRepositoryProvider.overrideWithValue(_FakeInvRepo()),
|
||||
];
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
goldenAcrossThemes(
|
||||
'product detail 富数据(桌面)',
|
||||
goldenPrefix: 'product_detail',
|
||||
child: () => const Scaffold(body: ProductDetailScreen(productId: 1)),
|
||||
overrides: _overrides,
|
||||
logical: const Size(1100, 1300),
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ class _FakeInventoryRepository extends InventoryRepository {
|
||||
@override
|
||||
Future<PageResult<Inventory>> listInventory({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
String? keyword,
|
||||
List<String>? series,
|
||||
List<String>? spec,
|
||||
|
||||
Reference in New Issue
Block a user