feat(client): 商品基础数据加香型字典 tab(接后端 categories CRUD)

新增 ProductCategoryOption 模型 + repo(list/create/update/deleteCategory)+
provider(productCategoryListProvider),基础数据页加「香型」tab(增删改查、
搜索、导出,仅名称字段)。接通后端 /product-options/categories。
(原型 5-tab 整合是更大 IA 任务,本次先把香型管理补齐可用。)

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 15:42:23 +08:00
parent 355ac0ebc3
commit 38e2ea1c6c
4 changed files with 210 additions and 0 deletions
+14
View File
@@ -42,6 +42,20 @@ class ProductSeriesOption {
);
}
/// 香型 / 分类字典项(对应后端 ProductCategory)。
class ProductCategoryOption {
final int id;
final String name;
const ProductCategoryOption({required this.id, required this.name});
factory ProductCategoryOption.fromJson(Map<String, dynamic> json) =>
ProductCategoryOption(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
);
}
class ProductSpecOption {
final int id;
final String? code;
@@ -281,3 +281,42 @@ class ProductDescriptionDocListNotifier extends AsyncNotifier<List<ProductDescri
reload();
}
}
// ── 香型 / 分类字典 ──────────────────────────────────────────────
final productCategoryListProvider = AsyncNotifierProvider<
ProductCategoryListNotifier, List<ProductCategoryOption>>(
ProductCategoryListNotifier.new,
);
class ProductCategoryListNotifier
extends AsyncNotifier<List<ProductCategoryOption>> {
@override
Future<List<ProductCategoryOption>> build() async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(productOptionRepositoryProvider).listCategories();
}
void reload() {
state = const AsyncValue.loading();
ref.read(productOptionRepositoryProvider).listCategories().then(
(data) => state = AsyncValue.data(data),
onError: (e, st) => state = AsyncValue.error(e, st),
);
}
Future<void> create(Map<String, dynamic> data) async {
await ref.read(productOptionRepositoryProvider).createCategory(data);
reload();
}
Future<void> updateItem(int id, Map<String, dynamic> data) async {
await ref.read(productOptionRepositoryProvider).updateCategory(id, data);
reload();
}
Future<void> delete(int id) async {
await ref.read(productOptionRepositoryProvider).deleteCategory(id);
reload();
}
}
@@ -88,6 +88,48 @@ class ProductOptionRepository {
}
}
// ── 香型 / 分类 ──────────────────────────────────────────────
Future<List<ProductCategoryOption>> listCategories() async {
try {
final resp = await _client.get('/product-options/categories');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data
.map((e) => ProductCategoryOption.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '获取香型列表失败',
statusCode: e.response?.statusCode);
}
}
Future<void> createCategory(Map<String, dynamic> data) async {
try {
await _client.post('/product-options/categories', data: data);
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
statusCode: e.response?.statusCode);
}
}
Future<void> updateCategory(int id, Map<String, dynamic> data) async {
try {
await _client.put('/product-options/categories/$id', data: data);
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
statusCode: e.response?.statusCode);
}
}
Future<void> deleteCategory(int id) async {
try {
await _client.delete('/product-options/categories/$id');
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
statusCode: e.response?.statusCode);
}
}
// ── 规格 ────────────────────────────────────────────────────
Future<List<ProductSpecOption>> listSpecs() async {
@@ -5,6 +5,7 @@ import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../models/warehouse.dart';
import '../../providers/product_option_provider.dart';
import '../../models/product_option.dart' show ProductCategoryOption;
import '../../providers/warehouse_provider.dart';
import '../../providers/shop_provider.dart';
import '../../widgets/data_table_card.dart';
@@ -31,6 +32,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
final _shelfLifeSearchCtrl = TextEditingController();
final _storageSearchCtrl = TextEditingController();
final _descDocSearchCtrl = TextEditingController();
final _categorySearchCtrl = TextEditingController();
int _namePage = 1;
int _namePageSize = 20;
@@ -46,6 +48,8 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
int _storagePageSize = 20;
int _descDocPage = 1;
int _descDocPageSize = 20;
int _categoryPage = 1;
int _categoryPageSize = 20;
@override
void dispose() {
@@ -56,6 +60,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
_shelfLifeSearchCtrl.dispose();
_storageSearchCtrl.dispose();
_descDocSearchCtrl.dispose();
_categorySearchCtrl.dispose();
super.dispose();
}
@@ -67,6 +72,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
Tab(text: '商品名称'),
Tab(text: '系列'),
Tab(text: '规格'),
Tab(text: '香型'),
Tab(text: '产地'),
Tab(text: '保质期'),
Tab(text: '储存方式'),
@@ -77,6 +83,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
_buildNameTab(),
_buildSeriesTab(),
_buildSpecTab(),
_buildCategoryTab(),
_buildOriginTab(),
_buildShelfLifeTab(),
_buildStorageTab(),
@@ -356,6 +363,114 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
);
}
// ── 香型 tabProductCategory,仅名称) ──────────────────────
Widget _buildCategoryTab() {
final async = ref.watch(productCategoryListProvider);
return async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _buildError(
() => ref.read(productCategoryListProvider.notifier).reload()),
data: (items) {
final keyword = _categorySearchCtrl.text.toLowerCase();
final filtered = keyword.isEmpty
? items
: items
.where((it) => it.name.toLowerCase().contains(keyword))
.toList();
final paged = filtered
.skip((_categoryPage - 1) * _categoryPageSize)
.take(_categoryPageSize)
.toList();
final notifier = ref.read(productCategoryListProvider.notifier);
void edit(ProductCategoryOption it) => _showOptionDialog(
title: '编辑香型',
hasQuantity: false,
initial: {'name': it.name},
onSave: (data) =>
notifier.updateItem(it.id, {'name': data['name']}),
);
List<Widget> rowActions(ProductCategoryOption it) =>
WriteGuard.isReadonly(ref)
? const []
: [
WriteGuard(
child: TextButton(
onPressed: () => edit(it),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
),
WriteGuard(
child: TextButton(
onPressed: () => _confirmDelete(
'删除香型「${it.name}」?',
() => notifier.delete(it.id),
),
child: Text('删除',
style: TextStyle(
fontSize: 12, color: context.tokens.danger)),
),
),
];
return DataTableCard(
totalCount: filtered.length,
page: _categoryPage,
pageSize: _categoryPageSize,
onPageChanged: (p) => setState(() => _categoryPage = p),
onPageSizeChanged: (s) => setState(() {
_categoryPageSize = s;
_categoryPage = 1;
}),
toolbar: _buildToolbar(
searchCtrl: _categorySearchCtrl,
hint: '搜索香型',
onSearchChanged: () => setState(() => _categoryPage = 1),
onAdd: () => _showOptionDialog(
title: '新建香型',
hasQuantity: false,
onSave: (data) => notifier.create({'name': data['name']}),
),
onExport: () => exportExcel(
filename: '商品香型',
headers: ['香型名称'],
rows: items.map((it) => [it.name]).toList(),
),
),
mobileCards: paged
.map((it) => MobileListCard(
title: Text(it.name),
actions: rowActions(it).isEmpty ? null : rowActions(it),
))
.toList(),
columns: const [
DataColumn(label: Text('香型名称')),
DataColumn(label: Text('操作')),
],
rows: paged.isEmpty
? [
DataRow(cells: [
DataCell(Text('暂无数据',
style: TextStyle(color: context.tokens.muted))),
const DataCell(SizedBox()),
])
]
: paged
.map((it) => DataRow(cells: [
DataCell(Text(it.name,
style:
const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: rowActions(it))),
]))
.toList(),
);
},
);
}
// ── 规格 tab ──────────────────────────────────────────────
Widget _buildSpecTab() {