Files
CloudHome-Ops/lib/screens/admin/intro/intro_store_content_page.dart

1145 lines
42 KiB
Dart
Raw Normal View History

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<IntroStoreContentPage> createState() =>
_IntroStoreContentPageState();
}
class _IntroStoreContentPageState extends ConsumerState<IntroStoreContentPage> {
final _service = IntroService();
final _uploadService = UploadService();
final _imagePicker = ImagePicker();
StoreInfo? _store;
List<IntroCategory> _categories = [];
final Map<String, List<IntroSubcategory>> _subcategories = {};
final Map<String, List<IntroDetail>> _details = {};
bool _loading = false;
String? _error;
// 展开状态:分类 ID / 小类 ID。
final Set<String> _expandedCategories = {};
final Set<String> _expandedSubcategories = {};
@override
void initState() {
super.initState();
_store = widget.store;
_loadData();
}
Future<void> _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<void> _loadSubcategories(String categoryId) async {
try {
final list = await _service.getIntroSubcategoryListByCategory(categoryId);
_subcategories[categoryId] = list;
} catch (_) {
_subcategories[categoryId] = [];
}
}
Future<void> _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<String, List<IntroCategory>> _groupCategoriesByType() {
final groups = <String, List<IntroCategory>>{};
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<IntroCategory> 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<String>(
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<void>(
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 = <String, dynamic>{
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<void>(
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<int>(
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 = <String, dynamic>{
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<void>(
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<String>(
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<int>(
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 = <String, dynamic>{
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<void> _deleteDetail(IntroDetail item) async {
final t = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<String> _imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
static const List<String> _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),
),
),
),
],
),
],
);
}
}