392 lines
15 KiB
Dart
392 lines
15 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
|
||
|
|
import '../../../models/media_asset.dart';
|
||
|
|
import '../../../services/media_service.dart';
|
||
|
|
import '../../../services/api_client.dart';
|
||
|
|
|
||
|
|
/// 媒资管理页。
|
||
|
|
class MediaListPage extends ConsumerStatefulWidget {
|
||
|
|
const MediaListPage({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
ConsumerState<MediaListPage> createState() => _MediaListPageState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _MediaListPageState extends ConsumerState<MediaListPage> {
|
||
|
|
final _scrollController = ScrollController();
|
||
|
|
final _searchController = TextEditingController();
|
||
|
|
|
||
|
|
List<MediaAsset> _items = [];
|
||
|
|
bool _loading = false;
|
||
|
|
bool _hasMore = true;
|
||
|
|
int _page = 1;
|
||
|
|
String? _error;
|
||
|
|
String _keyword = '';
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_scrollController.addListener(_onScroll);
|
||
|
|
_loadData();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_searchController.dispose();
|
||
|
|
_scrollController.removeListener(_onScroll);
|
||
|
|
_scrollController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onScroll() {
|
||
|
|
if (_scrollController.position.pixels >=
|
||
|
|
_scrollController.position.maxScrollExtent - 200 &&
|
||
|
|
!_loading &&
|
||
|
|
_hasMore) {
|
||
|
|
_loadMore();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadData() async {
|
||
|
|
setState(() {
|
||
|
|
_loading = true;
|
||
|
|
_error = null;
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
final list = await MediaService().getMediaAssetList(page: 1, limit: 20);
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_items = list;
|
||
|
|
_loading = false;
|
||
|
|
_hasMore = list.length >= 20;
|
||
|
|
_page = 1;
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_loading = false;
|
||
|
|
_error = e is ApiException ? e.message : '加载失败';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadMore() async {
|
||
|
|
if (_loading) return;
|
||
|
|
setState(() => _loading = true);
|
||
|
|
try {
|
||
|
|
final list = await MediaService().getMediaAssetList(page: _page + 1, limit: 20);
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_items.addAll(list);
|
||
|
|
_loading = false;
|
||
|
|
_hasMore = list.length >= 20;
|
||
|
|
_page++;
|
||
|
|
});
|
||
|
|
} catch (_) {
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() => _loading = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _refresh() async {
|
||
|
|
_page = 1;
|
||
|
|
_hasMore = true;
|
||
|
|
await _loadData();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _showForm({MediaAsset? editing}) {
|
||
|
|
final isEdit = editing != null;
|
||
|
|
final nameCtrl = TextEditingController(text: editing?.name ?? '');
|
||
|
|
final codeCtrl = TextEditingController(text: editing?.code ?? '');
|
||
|
|
final coverCtrl = TextEditingController(text: editing?.coverUrl ?? '');
|
||
|
|
final playCtrl = TextEditingController(text: editing?.playUrl ?? '');
|
||
|
|
final durationCtrl =
|
||
|
|
TextEditingController(text: editing?.duration.toString() ?? '');
|
||
|
|
String typeValue = editing?.type.isNotEmpty == true ? editing!.type : 'video';
|
||
|
|
int statusValue = editing?.status ?? 0;
|
||
|
|
|
||
|
|
showModalBottomSheet(
|
||
|
|
useSafeArea: true,
|
||
|
|
context: context,
|
||
|
|
isScrollControlled: true,
|
||
|
|
builder: (ctx) => StatefulBuilder(
|
||
|
|
builder: (ctx, setSheet) => Padding(
|
||
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(isEdit ? '编辑媒资' : '新增媒资',
|
||
|
|
style: Theme.of(ctx).textTheme.titleMedium),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
TextField(
|
||
|
|
controller: nameCtrl,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '名称', border: OutlineInputBorder())),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(
|
||
|
|
controller: codeCtrl,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '编码', border: OutlineInputBorder())),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
DropdownButtonFormField<String>(
|
||
|
|
value: typeValue,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '类型', border: OutlineInputBorder()),
|
||
|
|
items: const [
|
||
|
|
DropdownMenuItem(value: 'video', child: Text('影视')),
|
||
|
|
DropdownMenuItem(value: 'audio', child: Text('音乐')),
|
||
|
|
DropdownMenuItem(value: 'image', child: Text('图片')),
|
||
|
|
],
|
||
|
|
onChanged: (v) {
|
||
|
|
if (v != null) setSheet(() => typeValue = v);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(
|
||
|
|
controller: coverCtrl,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '封面地址', border: OutlineInputBorder())),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(
|
||
|
|
controller: playCtrl,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '播放地址', border: OutlineInputBorder())),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
TextField(
|
||
|
|
controller: durationCtrl,
|
||
|
|
keyboardType: TextInputType.number,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '时长(秒)', border: OutlineInputBorder())),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
DropdownButtonFormField<int>(
|
||
|
|
value: statusValue,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '状态', border: OutlineInputBorder()),
|
||
|
|
items: const [
|
||
|
|
DropdownMenuItem(value: 0, child: Text('正常')),
|
||
|
|
DropdownMenuItem(value: 1, child: Text('禁用')),
|
||
|
|
],
|
||
|
|
onChanged: (v) {
|
||
|
|
if (v != null) setSheet(() => statusValue = v);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
FilledButton(
|
||
|
|
onPressed: () async {
|
||
|
|
final data = <String, dynamic>{
|
||
|
|
if (isEdit) 'id': editing!.id,
|
||
|
|
'name': nameCtrl.text,
|
||
|
|
'code': codeCtrl.text,
|
||
|
|
'type': typeValue,
|
||
|
|
'coverUrl': coverCtrl.text,
|
||
|
|
'playUrl': playCtrl.text,
|
||
|
|
'duration': int.tryParse(durationCtrl.text) ?? 0,
|
||
|
|
'status': statusValue,
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
await MediaService().saveMediaAsset(data);
|
||
|
|
if (!ctx.mounted) return;
|
||
|
|
Navigator.pop(ctx);
|
||
|
|
_refresh();
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(content: Text('保存成功')),
|
||
|
|
);
|
||
|
|
} catch (e) {
|
||
|
|
if (!ctx.mounted) return;
|
||
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||
|
|
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
child: const Text('保存'),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _delete(String id) async {
|
||
|
|
final confirmed = await showDialog<bool>(
|
||
|
|
context: context,
|
||
|
|
builder: (ctx) => AlertDialog(
|
||
|
|
title: const Text('确认删除'),
|
||
|
|
content: const Text('确定要删除此媒资吗?'),
|
||
|
|
actions: [
|
||
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||
|
|
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (confirmed != true) return;
|
||
|
|
try {
|
||
|
|
await MediaService().deleteMediaAsset([int.tryParse(id) ?? 0]);
|
||
|
|
_refresh();
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String _typeLabel(String type) {
|
||
|
|
switch (type.toLowerCase()) {
|
||
|
|
case 'video':
|
||
|
|
return '影视';
|
||
|
|
case 'audio':
|
||
|
|
return '音乐';
|
||
|
|
case 'image':
|
||
|
|
return '图片';
|
||
|
|
default:
|
||
|
|
return type;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String _formatDuration(int seconds) {
|
||
|
|
final h = seconds ~/ 3600;
|
||
|
|
final m = (seconds % 3600) ~/ 60;
|
||
|
|
final s = seconds % 60;
|
||
|
|
if (h > 0) return '${h}h${m.toString().padLeft(2, '0')}m';
|
||
|
|
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final filtered = _keyword.isEmpty
|
||
|
|
? _items
|
||
|
|
: _items
|
||
|
|
.where((e) => e.name.toLowerCase().contains(_keyword.toLowerCase()))
|
||
|
|
.toList();
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('媒资管理')),
|
||
|
|
body: Column(
|
||
|
|
children: [
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||
|
|
child: TextField(
|
||
|
|
controller: _searchController,
|
||
|
|
decoration: InputDecoration(
|
||
|
|
hintText: '搜索媒资名称',
|
||
|
|
prefixIcon: const Icon(Icons.search),
|
||
|
|
suffixIcon: _keyword.isEmpty
|
||
|
|
? null
|
||
|
|
: IconButton(
|
||
|
|
icon: const Icon(Icons.clear),
|
||
|
|
onPressed: () {
|
||
|
|
_searchController.clear();
|
||
|
|
setState(() => _keyword = '');
|
||
|
|
},
|
||
|
|
),
|
||
|
|
border: const OutlineInputBorder(),
|
||
|
|
),
|
||
|
|
onChanged: (v) => setState(() => _keyword = v),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: _error != null
|
||
|
|
? Center(child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(_error!),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
|
||
|
|
],
|
||
|
|
))
|
||
|
|
: RefreshIndicator(
|
||
|
|
onRefresh: _refresh,
|
||
|
|
child: filtered.isEmpty && !_loading
|
||
|
|
? const Center(child: Text('暂无数据'))
|
||
|
|
: ListView.builder(
|
||
|
|
controller: _scrollController,
|
||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
||
|
|
itemCount: filtered.length + (_loading ? 1 : 0),
|
||
|
|
itemBuilder: (ctx, i) {
|
||
|
|
if (i >= filtered.length) {
|
||
|
|
return const Padding(
|
||
|
|
padding: EdgeInsets.all(16),
|
||
|
|
child: Center(child: CircularProgressIndicator()),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
final item = filtered[i];
|
||
|
|
return Card(
|
||
|
|
child: ListTile(
|
||
|
|
leading: item.coverUrl.isNotEmpty
|
||
|
|
? ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
child: Image.network(
|
||
|
|
item.coverUrl,
|
||
|
|
width: 50,
|
||
|
|
height: 70,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) =>
|
||
|
|
Container(
|
||
|
|
width: 50,
|
||
|
|
height: 70,
|
||
|
|
color: Colors.grey.shade300,
|
||
|
|
child: const Icon(Icons.video_library, size: 24),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: Container(
|
||
|
|
width: 50,
|
||
|
|
height: 70,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.grey.shade300,
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
),
|
||
|
|
child: const Icon(Icons.video_library, size: 24),
|
||
|
|
),
|
||
|
|
title: Text(item.name,
|
||
|
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
|
|
subtitle: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Text('编码: ${item.code}'),
|
||
|
|
Text('类型: ${_typeLabel(item.type)}'),
|
||
|
|
if (item.duration > 0)
|
||
|
|
Text('时长: ${_formatDuration(item.duration)}'),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Chip(
|
||
|
|
label: Text(item.status == 0 ? '正常' : '禁用'),
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
trailing: PopupMenuButton<String>(
|
||
|
|
onSelected: (v) {
|
||
|
|
if (v == 'edit') _showForm(editing: item);
|
||
|
|
if (v == 'delete') _delete(item.id);
|
||
|
|
},
|
||
|
|
itemBuilder: (_) => [
|
||
|
|
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||
|
|
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
floatingActionButton: FloatingActionButton(
|
||
|
|
onPressed: () => _showForm(),
|
||
|
|
child: const Icon(Icons.add),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|