369 lines
14 KiB
Dart
369 lines
14 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';
|
||
|
|
|
||
|
|
/// 媒资排行榜 Top10 管理页。
|
||
|
|
class MediaRankPage extends ConsumerStatefulWidget {
|
||
|
|
const MediaRankPage({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
ConsumerState<MediaRankPage> createState() => _MediaRankPageState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _MediaRankPageState extends ConsumerState<MediaRankPage> {
|
||
|
|
List<MediaRankItem> _items = [];
|
||
|
|
bool _loading = false;
|
||
|
|
String? _error;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_loadData();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadData() async {
|
||
|
|
setState(() {
|
||
|
|
_loading = true;
|
||
|
|
_error = null;
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
final list = await MediaService().getMediaAssetRankTop10();
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_items = list;
|
||
|
|
_loading = false;
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_loading = false;
|
||
|
|
_error = e is ApiException ? e.message : '加载失败';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _moveUp(String code) async {
|
||
|
|
try {
|
||
|
|
await MediaService().moveUpMediaAssetRank(code);
|
||
|
|
_loadData();
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _moveDown(String code) async {
|
||
|
|
try {
|
||
|
|
await MediaService().moveDownMediaAssetRank(code);
|
||
|
|
_loadData();
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _remove(String code) 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().removeMediaAssetRank(code);
|
||
|
|
_loadData();
|
||
|
|
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 : '移除失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _clearAll() 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().clearMediaAssetRank();
|
||
|
|
_loadData();
|
||
|
|
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 : '操作失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _showAddDialog() {
|
||
|
|
final searchCtrl = TextEditingController();
|
||
|
|
List<MediaAsset> allMedia = [];
|
||
|
|
bool searching = false;
|
||
|
|
|
||
|
|
showModalBottomSheet<void>(
|
||
|
|
useSafeArea: true,
|
||
|
|
context: context,
|
||
|
|
isScrollControlled: true,
|
||
|
|
builder: (sheetCtx) {
|
||
|
|
return StatefulBuilder(
|
||
|
|
builder: (ctx, setSheet) {
|
||
|
|
Future<void> doSearch() async {
|
||
|
|
setSheet(() => searching = true);
|
||
|
|
try {
|
||
|
|
allMedia = await MediaService().getMediaAssetList(page: 1, limit: 50);
|
||
|
|
} catch (_) {
|
||
|
|
allMedia = [];
|
||
|
|
} finally {
|
||
|
|
setSheet(() => searching = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (allMedia.isEmpty && !searching) {
|
||
|
|
doSearch();
|
||
|
|
}
|
||
|
|
|
||
|
|
final kw = searchCtrl.text.trim().toLowerCase();
|
||
|
|
final filtered = kw.isEmpty
|
||
|
|
? allMedia
|
||
|
|
: allMedia.where((m) => m.name.toLowerCase().contains(kw)).toList();
|
||
|
|
final existingCodes = _items.map((e) => e.code).toSet();
|
||
|
|
final available = filtered.where((m) => !existingCodes.contains(m.code)).toList();
|
||
|
|
|
||
|
|
return SizedBox(
|
||
|
|
height: MediaQuery.of(ctx).size.height * 0.7,
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Text('选择媒资', style: Theme.of(ctx).textTheme.titleLarge),
|
||
|
|
const Spacer(),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.close),
|
||
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
|
|
child: TextField(
|
||
|
|
controller: searchCtrl,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
hintText: '搜索媒资名称',
|
||
|
|
prefixIcon: Icon(Icons.search),
|
||
|
|
border: OutlineInputBorder(),
|
||
|
|
),
|
||
|
|
onChanged: (_) => setSheet(() {}),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Expanded(
|
||
|
|
child: searching
|
||
|
|
? const Center(child: CircularProgressIndicator())
|
||
|
|
: available.isEmpty
|
||
|
|
? const Center(child: Text('无可选媒资'))
|
||
|
|
: ListView.builder(
|
||
|
|
itemCount: available.length,
|
||
|
|
itemBuilder: (_, index) {
|
||
|
|
final media = available[index];
|
||
|
|
return ListTile(
|
||
|
|
leading: media.coverUrl.isNotEmpty
|
||
|
|
? ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(4),
|
||
|
|
child: Image.network(
|
||
|
|
media.coverUrl,
|
||
|
|
width: 40,
|
||
|
|
height: 56,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) =>
|
||
|
|
const SizedBox(
|
||
|
|
width: 40,
|
||
|
|
height: 56,
|
||
|
|
child: Icon(Icons.video_library)),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: const SizedBox(
|
||
|
|
width: 40,
|
||
|
|
height: 56,
|
||
|
|
child: Icon(Icons.video_library),
|
||
|
|
),
|
||
|
|
title: Text(media.name),
|
||
|
|
subtitle: Text(media.code),
|
||
|
|
trailing: FilledButton.tonal(
|
||
|
|
onPressed: () => _addToRank(ctx, media.code, media.name),
|
||
|
|
child: const Text('添加'),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _addToRank(BuildContext sheetCtx, String code, String name) async {
|
||
|
|
try {
|
||
|
|
await MediaService().setMediaAssetRank([code]);
|
||
|
|
if (!mounted) return;
|
||
|
|
Navigator.of(sheetCtx).pop();
|
||
|
|
_loadData();
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text('已添加「$name」到排行榜')),
|
||
|
|
);
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text(e is ApiException ? e.message : '添加失败')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
automaticallyImplyLeading: false,
|
||
|
|
title: const Text('媒资排行榜 Top10'),
|
||
|
|
actions: [
|
||
|
|
if (_items.isNotEmpty)
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.delete_sweep),
|
||
|
|
onPressed: _clearAll,
|
||
|
|
tooltip: '清空排行榜',
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
body: _loading
|
||
|
|
? const Center(child: CircularProgressIndicator())
|
||
|
|
: _error != null
|
||
|
|
? Center(child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(_error!),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
FilledButton.tonal(onPressed: _loadData, child: const Text('重试')),
|
||
|
|
],
|
||
|
|
))
|
||
|
|
: _items.isEmpty
|
||
|
|
? const Center(child: Text('排行榜为空,点击+添加'))
|
||
|
|
: ListView.builder(
|
||
|
|
padding: const EdgeInsets.all(8),
|
||
|
|
itemCount: _items.length,
|
||
|
|
itemBuilder: (ctx, i) {
|
||
|
|
final item = _items[i];
|
||
|
|
final rank = i + 1;
|
||
|
|
return Card(
|
||
|
|
child: ListTile(
|
||
|
|
leading: Row(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 36,
|
||
|
|
height: 48,
|
||
|
|
alignment: Alignment.center,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: rank <= 3
|
||
|
|
? [Colors.amber, Colors.grey, Colors.brown][rank - 1]
|
||
|
|
: Theme.of(ctx).colorScheme.primaryContainer,
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
),
|
||
|
|
child: Text(
|
||
|
|
'$rank',
|
||
|
|
style: TextStyle(
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
color: rank <= 3 ? Colors.white : null,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
if (item.coverUrl.isNotEmpty)
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(4),
|
||
|
|
child: Image.network(
|
||
|
|
item.coverUrl,
|
||
|
|
width: 36,
|
||
|
|
height: 48,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) =>
|
||
|
|
const SizedBox(
|
||
|
|
width: 36,
|
||
|
|
height: 48,
|
||
|
|
child: Icon(Icons.video_library, size: 20)),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else
|
||
|
|
const SizedBox(
|
||
|
|
width: 36,
|
||
|
|
height: 48,
|
||
|
|
child: Icon(Icons.video_library, size: 20),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
title: Text(item.mediaName.isNotEmpty
|
||
|
|
? item.mediaName
|
||
|
|
: item.code),
|
||
|
|
subtitle: Text(item.code),
|
||
|
|
trailing: Row(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.arrow_upward),
|
||
|
|
onPressed: i > 0 ? () => _moveUp(item.code) : null,
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.arrow_downward),
|
||
|
|
onPressed: i < _items.length - 1
|
||
|
|
? () => _moveDown(item.code)
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.close),
|
||
|
|
onPressed: () => _remove(item.code),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
floatingActionButton: FloatingActionButton(
|
||
|
|
onPressed: _showAddDialog,
|
||
|
|
child: const Icon(Icons.add),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|