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:
@@ -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> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 香型 tab(ProductCategory,仅名称) ──────────────────────
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user