1145 lines
42 KiB
Dart
1145 lines
42 KiB
Dart
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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|