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 createState() => _MediaListPageState(); } class _MediaListPageState extends ConsumerState { final _scrollController = ScrollController(); final _searchController = TextEditingController(); List _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 _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 _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 _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( 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( 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 = { 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 _delete(String id) async { final confirmed = await showDialog( 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( 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), ), ); } }