Files
CloudHome-Ops/lib/screens/admin/app/tv_app_page.dart

347 lines
13 KiB
Dart
Raw Permalink Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/tv_app.dart';
import '../../../services/app_service.dart';
import '../../../services/api_client.dart';
/// TV端应用管理页。
class TvAppPage extends ConsumerStatefulWidget {
const TvAppPage({super.key});
@override
ConsumerState<TvAppPage> createState() => _TvAppPageState();
}
class _TvAppPageState extends ConsumerState<TvAppPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
List<TvApp> _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 kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: 1, limit: 20, appName: kw);
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 kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: _page + 1, limit: 20, appName: kw);
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({TvApp? editing}) {
final isEdit = editing != null;
final nameCtrl = TextEditingController(text: editing?.appName ?? '');
final pkgCtrl = TextEditingController(text: editing?.packageName ?? '');
final catCtrl = TextEditingController(text: editing?.appCategory ?? '');
final iconCtrl = TextEditingController(text: editing?.iconUrl ?? '');
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
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: pkgCtrl,
decoration: const InputDecoration(
labelText: '包名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: catCtrl,
decoration: const InputDecoration(
labelText: '分类', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: iconCtrl,
decoration: const InputDecoration(
labelText: '图标地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: orgIdCtrl,
decoration: const InputDecoration(
labelText: '组织ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: groupIdCtrl,
decoration: const InputDecoration(
labelText: '分组ID', 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,
'appName': nameCtrl.text,
'packageName': pkgCtrl.text,
'appCategory': catCtrl.text,
'iconUrl': iconCtrl.text,
'orgId': orgIdCtrl.text,
'groupId': groupIdCtrl.text,
'status': statusValue,
};
try {
await AppService().saveTvApp(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 AppService().deleteTvApp([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 : '删除失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('TV端应用管理')),
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 = '');
_refresh();
},
),
border: const OutlineInputBorder(),
),
onSubmitted: (v) {
setState(() => _keyword = v);
_refresh();
},
),
),
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: _items.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[i];
return Card(
child: ListTile(
leading: item.iconUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
item.iconUrl,
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.apps, size: 40),
),
)
: const CircleAvatar(child: Icon(Icons.apps)),
title: Text(item.appName,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('包名: ${item.packageName}'),
if (item.appCategory.isNotEmpty)
Text('分类: ${item.appCategory}'),
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),
),
);
}
}