import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../../models/intro_category.dart'; import '../../../models/store_info.dart'; import '../../../services/api_client.dart'; import '../../../services/intro_service.dart'; import '../../../services/upload_service.dart'; import '../../../utils/i10n.dart'; /// 公司介绍内容页。 /// /// 展示单个门店下的公司介绍数据,按「内容类型(categoryType)」分类, /// 每个类型下再按「小类 -> 详情」层级展开。 class IntroStoreContentPage extends ConsumerStatefulWidget { const IntroStoreContentPage({ super.key, required this.storeId, this.store, }); /// 门店组织 ID。 final String storeId; /// 门店基本信息(可选,由列表页传入以快速展示)。 final StoreInfo? store; @override ConsumerState createState() => _IntroStoreContentPageState(); } class _IntroStoreContentPageState extends ConsumerState { final _service = IntroService(); final _uploadService = UploadService(); final _imagePicker = ImagePicker(); StoreInfo? _store; List _categories = []; final Map> _subcategories = {}; final Map> _details = {}; bool _loading = false; String? _error; // 展开状态:分类 ID / 小类 ID。 final Set _expandedCategories = {}; final Set _expandedSubcategories = {}; @override void initState() { super.initState(); _store = widget.store; _loadData(); } Future _loadData() async { setState(() { _loading = true; _error = null; }); try { // 1) 加载当前门店下的大类(与 Web 端一致走 /list 并按 orgId 服务端过滤)。 final storeOrgId = int.tryParse(widget.storeId) ?? widget.storeId; _categories = await _service.getIntroCategoryList( page: 1, limit: 500, orgId: storeOrgId, ); _categories.sort((a, b) => a.categoryType.compareTo(b.categoryType)); // 2) 并行加载每个大类下的小类。 _subcategories.clear(); await Future.wait( _categories.map((cat) => _loadSubcategories(cat.id)), ); // 3) 并行加载每个小类下的详情。 _details.clear(); final allSubIds = _subcategories.values.expand((list) => list).toList(); await Future.wait( allSubIds.map((sub) => _loadDetails(sub.id)), ); if (!mounted) return; setState(() => _loading = false); } catch (e) { if (!mounted) return; setState(() { _error = formatApiError(e, fallback: '数据加载失败,请稍后重试'); _loading = false; }); } } Future _loadSubcategories(String categoryId) async { try { final list = await _service.getIntroSubcategoryListByCategory(categoryId); _subcategories[categoryId] = list; } catch (_) { _subcategories[categoryId] = []; } } Future _loadDetails(String subcategoryId) async { try { final list = await _service.getIntroDetailListBySubcategory(subcategoryId); _details[subcategoryId] = list; } catch (_) { _details[subcategoryId] = []; } } void _toggleCategory(String categoryId) { setState(() { if (_expandedCategories.contains(categoryId)) { _expandedCategories.remove(categoryId); } else { _expandedCategories.add(categoryId); } }); } void _toggleSubcategory(String subcategoryId) { setState(() { if (_expandedSubcategories.contains(subcategoryId)) { _expandedSubcategories.remove(subcategoryId); } else { _expandedSubcategories.add(subcategoryId); } }); } /// 按 categoryType 把大类分组,相同类型合并为一个可展开的分组。 Map> _groupCategoriesByType() { final groups = >{}; for (final cat in _categories) { groups.putIfAbsent(cat.categoryType, () => []).add(cat); } return groups; } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final storeName = _store?.name ?? t.unknown; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.introCategoryTitle), Text( storeName, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), body: _buildBody(), floatingActionButton: FloatingActionButton( onPressed: _showAddCategoryForm, child: const Icon(Icons.add), ), ); } Widget _buildBody() { final t = AppLocalizations.of(context); if (_loading && _categories.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (_error != null && _categories.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('${t.loadFailed}:$_error'), const SizedBox(height: 8), FilledButton.tonal( onPressed: _loadData, child: Text(t.retry), ), ], ), ); } if (_categories.isEmpty) { return RefreshIndicator( onRefresh: _loadData, child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ const SizedBox(height: 200), Center(child: Text(t.noData)), ], ), ); } final groups = _groupCategoriesByType(); final typeKeys = groups.keys.toList()..sort(); return RefreshIndicator( onRefresh: _loadData, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(vertical: 8), itemCount: typeKeys.length, itemBuilder: (context, index) { final type = typeKeys[index]; final cats = groups[type]!; return _buildTypeSection(type, cats); }, ), ); } /// 内容类型分组卡片。 Widget _buildTypeSection(String type, List categories) { final theme = Theme.of(context); return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: ExpansionTile( title: Text( type.isEmpty ? '未分类' : type, style: theme.textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), subtitle: Text( '${categories.length} 个分类', style: theme.textTheme.bodySmall, ), initiallyExpanded: true, children: categories.map((cat) => _buildCategoryTile(cat)).toList(), ), ); } /// 单个大类:可展开查看其下小类。 Widget _buildCategoryTile(IntroCategory category) { final t = AppLocalizations.of(context); final subs = _subcategories[category.id] ?? []; final isExpanded = _expandedCategories.contains(category.id); return Column( children: [ ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16), leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, child: Text( category.name.isNotEmpty ? category.name.substring(0, 1) : '-', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold, ), ), ), title: Text(category.name), subtitle: Text('${t.introTypeLabel}:${category.categoryType}'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.add, size: 20), tooltip: t.introSubcategoryAdd, onPressed: () => _showSubcategoryForm(categoryId: category.id), ), IconButton( icon: Icon( isExpanded ? Icons.expand_less : Icons.expand_more, size: 20, ), onPressed: () => _toggleCategory(category.id), ), ], ), onTap: () => _toggleCategory(category.id), ), if (isExpanded) Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), child: subs.isEmpty ? ListTile( dense: true, title: Text( t.noData, style: TextStyle( color: Theme.of(context).colorScheme.outline, ), ), ) : Column( children: subs .map((sub) => _buildSubcategoryTile(category, sub)) .toList(), ), ), ], ); } /// 单个小类:可展开查看其下详情。 Widget _buildSubcategoryTile( IntroCategory category, IntroSubcategory sub, ) { final t = AppLocalizations.of(context); final details = _details[sub.id] ?? []; final isExpanded = _expandedSubcategories.contains(sub.id); return Card( margin: const EdgeInsets.symmetric(vertical: 4), child: Column( children: [ ListTile( dense: true, leading: sub.mediaUrl.isNotEmpty && sub.mediaType == 'image' ? ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.network( sub.mediaUrl, width: 40, height: 40, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 32), ), ) : const Icon(Icons.folder, size: 32), title: Text( sub.title, style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: Text( sub.description.isNotEmpty ? sub.description : '${details.length} 个内容', maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.add, size: 18), tooltip: t.introDetailAdd, onPressed: () => _showDetailForm( categoryId: category.id, subcategoryId: sub.id, ), ), Icon( isExpanded ? Icons.expand_less : Icons.expand_more, size: 18, ), ], ), onTap: () => _toggleSubcategory(sub.id), ), if (isExpanded) Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), child: details.isEmpty ? ListTile( dense: true, title: Text( t.noData, style: TextStyle( color: Theme.of(context).colorScheme.outline, ), ), ) : Column( children: details .map((d) => _buildDetailTile(category, sub, d)) .toList(), ), ), ], ), ); } /// 单个详情内容项。 Widget _buildDetailTile( IntroCategory category, IntroSubcategory sub, IntroDetail detail, ) { final t = AppLocalizations.of(context); return ListTile( dense: true, leading: detail.mediaUrl.isNotEmpty && detail.mediaType == 'image' ? ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.network( detail.mediaUrl, width: 40, height: 40, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 32), ), ) : const Icon(Icons.article, size: 32), title: Text(detail.title), subtitle: detail.description.isNotEmpty ? Text( detail.description, maxLines: 1, overflow: TextOverflow.ellipsis, ) : null, trailing: PopupMenuButton( onSelected: (v) { if (v == 'edit') { _showDetailForm( categoryId: category.id, subcategoryId: sub.id, editing: detail, ); } else if (v == 'delete') { _deleteDetail(detail); } }, itemBuilder: (_) => [ PopupMenuItem(value: 'edit', child: Text(t.edit)), PopupMenuItem(value: 'delete', child: Text(t.delete)), ], ), ); } // ========== 表单入口 ========== void _showAddCategoryForm() { _showCategoryForm(); } void _showCategoryForm({IntroCategory? editing}) { final t = AppLocalizations.of(context); final isEdit = editing != null; final nameCtrl = TextEditingController(text: editing?.name ?? ''); final typeCtrl = TextEditingController(text: editing?.categoryType ?? ''); showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheet) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, ), child: Padding( padding: const EdgeInsets.all(16), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( isEdit ? t.introCategoryEdit : t.introCategoryAdd, style: Theme.of(ctx).textTheme.titleMedium, ), const SizedBox(height: 16), TextField( controller: nameCtrl, decoration: InputDecoration( labelText: t.introCategoryName, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: typeCtrl, decoration: InputDecoration( labelText: t.introCategoryTypeCode, border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), FilledButton( onPressed: () async { final name = nameCtrl.text.trim(); final categoryType = typeCtrl.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introCategoryNameRequired)), ); return; } if (categoryType.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introCategoryTypeRequired)), ); return; } if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introCategoryTypeNoChinese)), ); return; } final data = { if (isEdit) 'id': editing.id, 'orgId': int.tryParse(widget.storeId) ?? widget.storeId, 'name': name, 'categoryType': categoryType, }; try { await _service.saveIntroCategory(data); if (!ctx.mounted) return; Navigator.pop(ctx); _loadData(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.saved)), ); } catch (e) { if (!ctx.mounted) return; ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(formatApiError(e))), ); } }, child: Text(t.save), ), ], ), ), ), ), ), ); } void _showSubcategoryForm({ required String categoryId, IntroSubcategory? editing, }) { final t = AppLocalizations.of(context); final isEdit = editing != null; final titleCtrl = TextEditingController(text: editing?.title ?? ''); final descCtrl = TextEditingController(text: editing?.description ?? ''); final mediaUrlCtrl = TextEditingController(text: editing?.mediaUrl ?? ''); final sortCtrl = TextEditingController( text: (editing?.sort ?? 0).toString()); // 小类的 categoryType 预填父大类值,与 Web 端一致;为空时不下发,避免覆盖后端默认值。 String parentCategoryType = ''; for (final c in _categories) { if (c.id == categoryId) { parentCategoryType = c.categoryType; break; } } final categoryTypeCtrl = TextEditingController( text: editing?.categoryType ?? parentCategoryType); int statusValue = editing?.status ?? 1; XFile? pickedFile; bool uploading = false; showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheet) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, ), child: Padding( padding: const EdgeInsets.all(16), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( isEdit ? t.introSubcategoryEdit : t.introSubcategoryAdd, style: Theme.of(ctx).textTheme.titleMedium, ), const SizedBox(height: 16), TextField( controller: titleCtrl, decoration: InputDecoration( labelText: t.introTitle, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: descCtrl, maxLines: 2, decoration: InputDecoration( labelText: t.introDescription, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: categoryTypeCtrl, decoration: InputDecoration( labelText: t.introCategoryTypeCode, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: sortCtrl, decoration: InputDecoration( labelText: t.introSortOrder, border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), const SizedBox(height: 12), if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) _buildImagePreview( ctx: ctx, url: mediaUrlCtrl.text, pickedFile: pickedFile, uploading: uploading, onClear: () => setSheet(() { mediaUrlCtrl.clear(); pickedFile = null; }), ), const SizedBox(height: 12), TextField( controller: mediaUrlCtrl, decoration: InputDecoration( labelText: t.introMediaUrl, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: uploading ? null : () async { final file = await _imagePicker.pickImage( source: ImageSource.gallery); if (file == null) return; final typeErr = _mediaTypeError(file, false, ctx); if (typeErr != null) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(typeErr)), ); } return; } final size = await file.length(); if (size > _subImageMaxBytes) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introFileTooLarge)), ); } return; } setSheet(() { pickedFile = file; uploading = true; }); try { final url = await _uploadService.uploadImage(file); setSheet(() { mediaUrlCtrl.text = url; uploading = false; }); } catch (e) { setSheet(() => uploading = false); if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text(e is ApiException ? e.message : t.introUploadFailed)), ); } } }, icon: uploading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload), label: Text(uploading ? t.introUploading : t.introUploadImage), ), const SizedBox(height: 12), DropdownButtonFormField( value: statusValue, decoration: InputDecoration( labelText: t.status, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: 1, child: Text(t.introEnabled)), DropdownMenuItem(value: 0, child: Text(t.introDisabled)), ], onChanged: (v) { if (v != null) setSheet(() => statusValue = v); }, ), const SizedBox(height: 16), FilledButton( onPressed: () async { final title = titleCtrl.text.trim(); final categoryType = categoryTypeCtrl.text.trim(); final mediaUrl = mediaUrlCtrl.text.trim(); if (title.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introTitleRequired)), ); return; } if (categoryType.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introCategoryTypeRequired)), ); return; } if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introCategoryTypeNoChinese)), ); return; } if (mediaUrl.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introMediaUrlRequired)), ); return; } final data = { if (isEdit) 'id': editing.id, 'categoryId': int.tryParse(categoryId) ?? 0, if (categoryType.isNotEmpty) 'categoryType': categoryType, 'title': title, 'description': descCtrl.text.trim(), 'mediaType': 'image', 'mediaUrl': mediaUrl, 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, 'status': statusValue, }; try { await _service.saveIntroSubcategory(data); if (!ctx.mounted) return; Navigator.pop(ctx); _loadData(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.saved)), ); } catch (e) { if (!ctx.mounted) return; ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(formatApiError(e))), ); } }, child: Text(t.save), ), ], ), ), ), ), ), ); } void _showDetailForm({ required String categoryId, required String subcategoryId, IntroDetail? editing, }) { final t = AppLocalizations.of(context); final isEdit = editing != null; final titleCtrl = TextEditingController(text: editing?.title ?? ''); final descCtrl = TextEditingController(text: editing?.description ?? ''); final mediaUrlCtrl = TextEditingController(text: editing?.mediaUrl ?? ''); final sortCtrl = TextEditingController( text: (editing?.sort ?? 0).toString()); String mediaType = editing?.mediaType ?? 'image'; int statusValue = editing?.status ?? 1; XFile? pickedFile; bool uploading = false; showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheet) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, ), child: Padding( padding: const EdgeInsets.all(16), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( isEdit ? t.introDetailEdit : t.introDetailAdd, style: Theme.of(ctx).textTheme.titleMedium, ), const SizedBox(height: 16), TextField( controller: titleCtrl, decoration: InputDecoration( labelText: t.introTitle, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: descCtrl, maxLines: 3, decoration: InputDecoration( labelText: t.introDescription, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: sortCtrl, decoration: InputDecoration( labelText: t.introSortOrder, border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), const SizedBox(height: 12), DropdownButtonFormField( value: mediaType, decoration: InputDecoration( labelText: t.introMediaType, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: 'image', child: Text(t.introImage)), DropdownMenuItem(value: 'video', child: Text(t.introVideo)), ], onChanged: (v) { if (v != null) setSheet(() => mediaType = v); }, ), const SizedBox(height: 12), if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) _buildImagePreview( ctx: ctx, url: mediaUrlCtrl.text, pickedFile: pickedFile, uploading: uploading, onClear: () => setSheet(() { mediaUrlCtrl.clear(); pickedFile = null; }), ), const SizedBox(height: 12), TextField( controller: mediaUrlCtrl, decoration: InputDecoration( labelText: t.introMediaUrl, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: uploading ? null : () async { final isVideo = mediaType == 'video'; final file = isVideo ? await _imagePicker.pickVideo( source: ImageSource.gallery) : await _imagePicker.pickImage( source: ImageSource.gallery); if (file == null) return; final typeErr = _mediaTypeError(file, isVideo, ctx); if (typeErr != null) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(typeErr)), ); } return; } final size = await file.length(); final maxBytes = isVideo ? _detailVideoMaxBytes : _detailImageMaxBytes; if (size > maxBytes) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introFileTooLarge)), ); } return; } setSheet(() { pickedFile = file; uploading = true; }); try { final url = await _uploadService.uploadImage( file, mediaType: isVideo ? 'video' : 'image', ); setSheet(() { mediaUrlCtrl.text = url; uploading = false; }); } catch (e) { setSheet(() => uploading = false); if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text(e is ApiException ? e.message : t.introUploadFailed)), ); } } }, icon: uploading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload), label: Text(uploading ? t.introUploading : t.introUploadImage), ), const SizedBox(height: 12), DropdownButtonFormField( value: statusValue, decoration: InputDecoration( labelText: t.status, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: 1, child: Text(t.introEnabled)), DropdownMenuItem(value: 0, child: Text(t.introDisabled)), ], onChanged: (v) { if (v != null) setSheet(() => statusValue = v); }, ), const SizedBox(height: 16), FilledButton( onPressed: () async { final title = titleCtrl.text.trim(); final mediaUrl = mediaUrlCtrl.text.trim(); if (title.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introTitleRequired)), ); return; } if (mediaUrl.isEmpty) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(t.introMediaUrlRequired)), ); return; } final data = { if (isEdit) 'id': editing.id, 'subcategoryId': int.tryParse(subcategoryId) ?? 0, 'title': title, 'description': descCtrl.text.trim(), 'mediaType': mediaType, 'mediaUrl': mediaUrl, 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, 'status': statusValue, }; try { await _service.saveIntroDetail(data); if (!ctx.mounted) return; Navigator.pop(ctx); _loadData(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.saved)), ); } catch (e) { if (!ctx.mounted) return; ScaffoldMessenger.of(ctx).showSnackBar( SnackBar(content: Text(formatApiError(e))), ); } }, child: Text(t.save), ), ], ), ), ), ), ), ); } Future _deleteDetail(IntroDetail item) async { final t = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.confirmDelete), content: Text(t.deleteConfirmContent(item.title)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t.delete)), ], ), ); if (confirmed != true) return; try { await _service.deleteIntroDetail([int.tryParse(item.id) ?? 0]); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deleted))); _loadData(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(formatApiError(e))), ); } } // 与 Web 端 company-intro 上传校验保持一致。 static const List _imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']; static const List _videoExts = ['mp4', 'webm', 'mov', 'm4v']; static const int _subImageMaxBytes = 300 * 1024; // 300KB static const int _detailImageMaxBytes = 3 * 1024 * 1024; // 3MB static const int _detailVideoMaxBytes = 30 * 1024 * 1024; // 30MB String? _mediaTypeError(XFile file, bool isVideo, BuildContext ctx) { final t = AppLocalizations.of(ctx); final ext = file.path.split('.').last.toLowerCase(); final allowed = isVideo ? _videoExts : _imageExts; if (!allowed.contains(ext)) { return isVideo ? t.introVideoTypeInvalid : t.introImageTypeInvalid; } return null; } Widget _buildImagePreview({ required BuildContext ctx, required String url, required XFile? pickedFile, required bool uploading, required VoidCallback onClear, }) { final t = AppLocalizations.of(ctx); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.introImagePreview, style: Theme.of(ctx).textTheme.labelLarge), const SizedBox(height: 6), Stack( alignment: Alignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: pickedFile != null ? Image.file( File(pickedFile.path), width: double.infinity, height: 160, fit: BoxFit.cover, ) : url.isNotEmpty ? Image.network( url, width: double.infinity, height: 160, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: double.infinity, height: 160, color: Theme.of(ctx).colorScheme.surfaceContainerHighest, child: const Icon(Icons.broken_image, size: 48), ), ) : Container( width: double.infinity, height: 160, color: Theme.of(ctx).colorScheme.surfaceContainerHighest, child: const Icon(Icons.image, size: 48), ), ), if (uploading) Container( width: double.infinity, height: 160, color: Colors.black54, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const SizedBox(height: 8), Text(t.introUploading, style: const TextStyle(color: Colors.white)), ], ), ), Positioned( top: 4, right: 4, child: IconButton.filledTonal( onPressed: onClear, icon: const Icon(Icons.clear), style: IconButton.styleFrom( minimumSize: const Size(32, 32), ), ), ), ], ), ], ); } }