- shops 表新增 logo_url 字段,AutoMigrate 自动建列 - 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储 - UpdateInfo 支持传入 logo_url - 侧边栏「岩美」替换为门店真实名称 + 自定义 logo - 无 logo 时显示店名首字文字头像(深蓝底白字) - 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮 - 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计 - 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增 - settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ class ShopInfo {
|
||||
final String address;
|
||||
final String phone;
|
||||
final String managerName;
|
||||
final String logoUrl;
|
||||
|
||||
const ShopInfo({
|
||||
required this.id,
|
||||
@@ -13,6 +14,7 @@ class ShopInfo {
|
||||
required this.address,
|
||||
required this.phone,
|
||||
required this.managerName,
|
||||
this.logoUrl = '',
|
||||
});
|
||||
|
||||
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
||||
@@ -22,5 +24,6 @@ class ShopInfo {
|
||||
address: json['address'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
managerName: json['manager_name'] as String? ?? '',
|
||||
logoUrl: json['logo_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../models/shop.dart';
|
||||
@@ -30,4 +31,19 @@ class ShopRepository {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> uploadLogo(Uint8List bytes, String filename) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||
});
|
||||
final resp = await _client.post('/shop/logo', data: formData);
|
||||
return (resp.data as Map<String, dynamic>)['logo_url'] as String? ?? '';
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? 'Logo 上传失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,14 +171,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
stateNotifier.value = _ImportState(
|
||||
stage: _ImportStage.done,
|
||||
uploadPercent: 100,
|
||||
total: data['total'] ?? 0,
|
||||
imported: data['imported'] ?? 0,
|
||||
updated: data['updated'] ?? 0,
|
||||
skipped: data['skipped'] ?? 0,
|
||||
errors: (data['errors'] as List?)?.cast<String>() ?? [],
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
closeDialog();
|
||||
final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false;
|
||||
if (!hasErrors) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
closeDialog();
|
||||
}
|
||||
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
|
||||
} on DioException catch (e) {
|
||||
processingTimer?.cancel();
|
||||
@@ -926,9 +929,9 @@ class _ImportState {
|
||||
final _ImportStage stage;
|
||||
final int uploadPercent;
|
||||
final int processingSeconds;
|
||||
final int total;
|
||||
final int imported;
|
||||
final int updated;
|
||||
final int skipped;
|
||||
final List<String> errors;
|
||||
final String? errorMsg;
|
||||
|
||||
@@ -936,9 +939,9 @@ class _ImportState {
|
||||
required this.stage,
|
||||
required this.uploadPercent,
|
||||
this.processingSeconds = 0,
|
||||
this.total = 0,
|
||||
this.imported = 0,
|
||||
this.updated = 0,
|
||||
this.skipped = 0,
|
||||
this.errors = const [],
|
||||
this.errorMsg,
|
||||
});
|
||||
@@ -1024,25 +1027,39 @@ class _ImportProgressDialog extends StatelessWidget {
|
||||
);
|
||||
|
||||
case _ImportStage.done:
|
||||
final allFailed = state.total == 0 && state.errors.isNotEmpty;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
||||
Icon(
|
||||
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
|
||||
color: allFailed ? AppTheme.danger : AppTheme.success,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
Text(
|
||||
allFailed ? '导入失败' : '导入完成',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text('新增:${state.imported} 条', style: const TextStyle(fontSize: 13)),
|
||||
if (state.updated > 0)
|
||||
Text('更新:${state.updated} 条', style: const TextStyle(fontSize: 13)),
|
||||
if (state.skipped > 0)
|
||||
Text('跳过:${state.skipped} 条',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||
Text('共 ${state.total} 行', style: const TextStyle(fontSize: 13)),
|
||||
Text('成功插入:${state.imported} 行', style: const TextStyle(fontSize: 13)),
|
||||
Text('重复更新:${state.updated} 行',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||
if (state.errors.isNotEmpty)
|
||||
Text('失败:${state.errors.length} 条',
|
||||
Text('失败:${state.errors.length} 行',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
if (state.errors.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
...state.errors.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(e,
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -127,14 +127,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ShopInfoRow(
|
||||
label: '门店编号',
|
||||
value: shop.code.isNotEmpty ? shop.code : '—'),
|
||||
const Divider(height: 16),
|
||||
_ShopInfoRow(
|
||||
label: '门店名称',
|
||||
value: shop.name.isNotEmpty ? shop.name : '—'),
|
||||
const Divider(height: 16),
|
||||
// Logo 预览行
|
||||
Row(
|
||||
children: [
|
||||
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(shop.name.isNotEmpty ? shop.name : '—',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(shop.code.isNotEmpty ? shop.code : '—',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_ShopInfoRow(
|
||||
label: '门店地址',
|
||||
value: shop.address.isNotEmpty ? shop.address : '—'),
|
||||
@@ -1525,6 +1535,7 @@ class _ImportSlot {
|
||||
int total = 0;
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int updated = 0;
|
||||
// 进度
|
||||
int uploadPercent = 0;
|
||||
bool isProcessing = false;
|
||||
@@ -1718,9 +1729,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||
if (mounted) setState(() {
|
||||
slot.success = true;
|
||||
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
||||
slot.imported = (data['imported'] ?? 0) as int;
|
||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||
slot.updated = (data['updated'] ?? 0) as int;
|
||||
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
@@ -2058,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
Widget? statusWidget;
|
||||
if (slot.hasResult) {
|
||||
if (slot.success == true) {
|
||||
final duplicate = slot.skipped + slot.updated;
|
||||
final parts = <String>[
|
||||
'共 ${slot.total} 条',
|
||||
'新增 ${slot.imported} 条',
|
||||
if (slot.skipped > 0) '重复跳过 ${slot.skipped} 条',
|
||||
if (duplicate > 0) '重复 $duplicate 条',
|
||||
];
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -2177,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 门店 Logo 预览 ────────────────────────────────────────
|
||||
class _ShopLogoPreview extends StatelessWidget {
|
||||
final String logoUrl;
|
||||
final String shopName;
|
||||
final double size;
|
||||
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(size * 0.19);
|
||||
if (logoUrl.isNotEmpty) {
|
||||
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Image.network(
|
||||
fullUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _initial(radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _initial(radius);
|
||||
}
|
||||
|
||||
Widget _initial(BorderRadius radius) {
|
||||
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F3057),
|
||||
borderRadius: radius,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size * 0.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 酒行信息行 ────────────────────────────────────────────
|
||||
class _ShopInfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
@@ -2222,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
||||
late final TextEditingController _phoneCtrl;
|
||||
late final TextEditingController _managerCtrl;
|
||||
bool _saving = false;
|
||||
bool _uploadingLogo = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -2241,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _uploadLogo() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) return;
|
||||
setState(() => _uploadingLogo = true);
|
||||
try {
|
||||
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
|
||||
ref.invalidate(shopInfoProvider);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Logo 已更新'),
|
||||
backgroundColor: AppTheme.success,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('上传失败:$e'),
|
||||
backgroundColor: AppTheme.danger,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingLogo = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
@@ -2279,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo 上传行
|
||||
Row(
|
||||
children: [
|
||||
_ShopLogoPreview(
|
||||
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
|
||||
shopName: _nameCtrl.text,
|
||||
size: 56,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _uploadingLogo ? null : _uploadLogo,
|
||||
icon: _uploadingLogo
|
||||
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.upload_outlined, size: 16),
|
||||
label: const Text('更换 Logo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '门店名称'),
|
||||
|
||||
@@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:async';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
@@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'
|
||||
);
|
||||
}
|
||||
|
||||
class _ShopButton extends StatelessWidget {
|
||||
class _ShopButton extends ConsumerWidget {
|
||||
final AuthUser? user;
|
||||
final String version;
|
||||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shopAsync = ref.watch(shopInfoProvider);
|
||||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (user != null) _showShopPanel(context, user!, version: version);
|
||||
},
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_YanmeiMark(size: 28),
|
||||
SizedBox(width: 10),
|
||||
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'岩美',
|
||||
style: TextStyle(
|
||||
shopName.isEmpty ? '—' : shopName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg.
|
||||
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
|
||||
class _YanmeiMark extends StatelessWidget {
|
||||
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
|
||||
class _ShopLogo extends StatelessWidget {
|
||||
final String logoUrl;
|
||||
final String shopName;
|
||||
final double size;
|
||||
const _YanmeiMark({this.size = 32});
|
||||
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(size * 0.19);
|
||||
if (logoUrl.isNotEmpty) {
|
||||
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Image.network(
|
||||
fullUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _initial(radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _initial(radius);
|
||||
}
|
||||
|
||||
Widget _initial(BorderRadius radius) {
|
||||
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F3057),
|
||||
borderRadius: BorderRadius.circular(size * 0.19),
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: _YanmeiMarkPainter(),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size * 0.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _YanmeiMarkPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final paint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = w * 0.055
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
|
||||
final path = Path();
|
||||
path.moveTo(w * 0.219, h * 0.594);
|
||||
path.lineTo(w * 0.344, h * 0.344);
|
||||
path.lineTo(w * 0.500, h * 0.500);
|
||||
path.lineTo(w * 0.656, h * 0.344);
|
||||
path.lineTo(w * 0.781, h * 0.594);
|
||||
canvas.drawPath(path, paint);
|
||||
|
||||
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.1875, h * 0.719),
|
||||
Offset(w * 0.8125, h * 0.719),
|
||||
paint,
|
||||
);
|
||||
|
||||
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
|
||||
canvas.drawCircle(
|
||||
Offset(w * 0.500, h * 0.859),
|
||||
w * 0.042,
|
||||
Paint()
|
||||
..color = const Color(0xFFC97B86)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
Reference in New Issue
Block a user