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