feat(client): 商品详情加价格历史卡(接 /products/:id/price-history)

repo.getPriceHistory → 详情异步加载,新增价格历史卡:date + 进价,按相邻点
算涨跌(绿涨/红跌箭头)。至此商品详情 100% 对齐原型富数据版
(KPI + 各仓库分布 + 价格历史 + 近期流水)。golden 三主题已含该卡。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 16:09:43 +08:00
parent deb5e503f6
commit 8e01e7afe7
6 changed files with 85 additions and 1 deletions
@@ -87,6 +87,27 @@ class ProductRepository {
}
}
/// 进价历史点(date/price),按日期倒序。失败抛 AppException。
Future<List<({String date, double price})>> getPriceHistory(int id) async {
try {
final resp = await _client.get('/products/$id/price-history');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data.map((e) {
final m = e as Map<String, dynamic>;
final d = (m['date'] as String? ?? '');
return (
date: d.length >= 10 ? d.substring(0, 10) : d,
price: (m['price'] as num).toDouble(),
);
}).toList();
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '获取价格历史失败',
statusCode: e.response?.statusCode,
);
}
}
Future<ProductImage> uploadImage(int productId, String? filePath,
{Uint8List? bytes, String? fileName}) async {
try {
@@ -38,9 +38,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
bool _savingDesc = false;
bool _descChanged = false;
// 富数据(库存聚合 + 流水),随详情异步加载,失败不阻塞主数据。
// 富数据(库存聚合 + 流水 + 价格历史),随详情异步加载,失败不阻塞主数据。
List<Inventory> _invRows = [];
List<InventoryLog> _logs = [];
List<({String date, double price})> _prices = [];
late TextEditingController _descCtrl;
@@ -82,10 +83,14 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
await repo.listInventory(productId: widget.productId, pageSize: 1000);
final logs =
await repo.listLogs(productId: widget.productId, pageSize: 100);
final prices = await ref
.read(productRepositoryProvider)
.getPriceHistory(widget.productId);
if (mounted) {
setState(() {
_invRows = inv.data;
_logs = logs.data;
_prices = prices;
});
}
} catch (_) {
@@ -311,6 +316,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
const SizedBox(height: 20),
_buildWarehouseDist(p),
],
if (_prices.isNotEmpty) ...[
const SizedBox(height: 20),
_buildPriceHistory(),
],
if (_logs.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRecentLogs(),
@@ -452,6 +461,52 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
);
}
Widget _buildPriceHistory() {
return _statCard(
title: '价格历史',
hint: Text('进价变更',
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
body: Column(
children: _prices.asMap().entries.map((e) {
final i = e.key;
final p = e.value;
// _prices 倒序(新→旧):i+1 是更早一笔,据此算涨跌。
final prev = i + 1 < _prices.length ? _prices[i + 1].price : null;
final up = prev != null && p.price > prev;
final down = prev != null && p.price < prev;
final color = up
? context.tokens.success
: down
? context.tokens.danger
: context.tokens.heading;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
child: Row(
children: [
SizedBox(
width: 96,
child: Text(p.date,
style: TextStyle(
fontFamily: 'monospace',
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
if (up || down)
Icon(up ? Icons.arrow_drop_up : Icons.arrow_drop_down,
size: 18, color: color),
Text('¥${p.price.toStringAsFixed(2)}',
style: TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
color: color)),
],
),
);
}).toList(),
),
);
}
Widget _buildRecentLogs() {
return _statCard(
title: '近期流水',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 60 KiB

@@ -25,6 +25,14 @@ class _FakeProductRepo implements ProductRepository {
series: '飞天', spec: '500ml×6', brand: '茅台',
purchasePrice: 2680, categoryName: '酱香');
@override
Future<List<({String date, double price})>> getPriceHistory(int id) async =>
const [
(date: '2026-06-12', price: 2680),
(date: '2026-04-20', price: 2620),
(date: '2026-02-08', price: 2700),
(date: '2025-11-15', price: 2580),
];
@override
dynamic noSuchMethod(Invocation invocation) =>
super.noSuchMethod(invocation);
}