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

1145 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
),
),
],
),
],
);
}
}