Files
voidea/flutter/lib/features/admin/screens/admin_screen.dart
T

545 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/client.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/exceptions.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/admin.dart';
import '../../../models/tariff.dart';
import '../../../models/auth.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../widgets/empty_state.dart';
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 7, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Админ-панель'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(text: 'Статистика'),
Tab(text: 'Пользователи'),
Tab(text: 'Агенты'),
Tab(text: 'Логи'),
Tab(text: 'Тарифы'),
Tab(text: 'Бот'),
Tab(text: 'Сервисы'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_StatsTab(),
_UsersTab(),
_AgentsTab(),
_LogsTab(),
_TariffsTab(),
_BotTab(),
_ServicesTab(),
],
),
);
}
}
class _StatsTab extends ConsumerWidget {
const _StatsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<AdminStats>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminStats);
return AdminStats.fromJson(resp.data as Map<String, dynamic>);
},
builder: (stats) => GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_StatCard(label: 'Пользователи', value: '${stats.totalUsers}', icon: Icons.people),
_StatCard(label: 'Идеи', value: '${stats.totalIdeas}', icon: Icons.lightbulb),
_StatCard(label: 'Голосовые сессии', value: '${stats.totalVoiceSessions}', icon: Icons.mic),
_StatCard(label: 'Раб. пространства', value: '${stats.totalWorkspaces}', icon: Icons.workspaces),
_StatCard(label: 'Активные агенты', value: '${stats.activeAgents}', icon: Icons.smart_toy),
_StatCard(label: 'Уведомления', value: '${stats.totalNotifications}', icon: Icons.notifications),
],
),
);
}
}
class _StatCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatCard({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: VoIdeaColors.primary, size: 28),
const SizedBox(height: 8),
Text(value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
)),
Text(label,
style: const TextStyle(fontSize: 12, color: VoIdeaColors.muted)),
],
),
),
);
}
}
class _UsersTab extends ConsumerWidget {
const _UsersTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<User>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminUsers);
return (resp.data as List<dynamic>)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (users) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: users.length,
itemBuilder: (_, i) {
final u = users[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: VoIdeaColors.primary.withOpacity(0.1),
child: Text(u.name.isNotEmpty ? u.name[0].toUpperCase() : '?',
style: const TextStyle(color: VoIdeaColors.primary)),
),
title: Text(u.name),
subtitle: Text('${u.email} · ${DateFormat('d MMM yyyy').format(u.createdAt)}',
style: const TextStyle(fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (u.isAdmin)
const Chip(label: Text('Admin', style: TextStyle(fontSize: 10))),
if (u.isTwoFactorEnabled)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.security, size: 16, color: VoIdeaColors.success),
),
],
),
),
);
},
),
);
}
}
class _AgentsTab extends ConsumerWidget {
const _AgentsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<AgentConfig>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminAgents);
return (resp.data as List<dynamic>)
.map((e) => AgentConfig.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (agents) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _runAllAgents(ref),
icon: const Icon(Icons.play_arrow),
label: const Text('Запустить всех'),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: agents.length,
itemBuilder: (_, i) {
final agent = agents[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
leading: Icon(
agent.isActive ? Icons.smart_toy : Icons.smart_toy_outlined,
color: agent.isActive ? VoIdeaColors.success : VoIdeaColors.muted,
),
title: Text(agent.name),
subtitle: Text(agent.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: () => _runAgent(ref, agent.name),
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Расписание: ${agent.schedule}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Активен: ${agent.isActive ? "Да" : "Нет"}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Конфиг: ${agent.config.toString()}',
style: const TextStyle(fontSize: 12)),
],
),
),
],
),
);
},
),
),
],
),
);
}
void _runAgent(WidgetRef ref, String name) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentRun(name));
} catch (_) {}
}
void _runAllAgents(WidgetRef ref) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentsRunAll);
} catch (_) {}
}
}
class _LogsTab extends ConsumerWidget {
const _LogsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<LogEntry>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminLogs);
return (resp.data as List<dynamic>)
.map((e) => LogEntry.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (logs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: logs.length,
itemBuilder: (_, i) {
final log = logs[i];
final color = log.level == 'ERROR'
? VoIdeaColors.error
: log.level == 'WARNING'
? VoIdeaColors.warning
: VoIdeaColors.muted;
return Card(
margin: const EdgeInsets.only(bottom: 4),
child: ListTile(
dense: true,
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
title: Text(log.message,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
subtitle: Row(
children: [
Text(log.level, style: TextStyle(fontSize: 11, color: color)),
const SizedBox(width: 8),
if (log.source != null)
Text(log.source!,
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
const Spacer(),
Text(DateFormat('HH:mm:ss').format(log.createdAt),
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
],
),
),
);
},
),
);
}
}
class _TariffsTab extends ConsumerWidget {
const _TariffsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<Tariff>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminTariffs);
return (resp.data as List<dynamic>)
.map((e) => Tariff.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (tariffs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: tariffs.length,
itemBuilder: (_, i) {
final t = tariffs[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(t.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const Spacer(),
Text('${t.price} ${t.currency}/мес',
style: TextStyle(
color: VoIdeaColors.primary,
fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
...t.features.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check, size: 14, color: VoIdeaColors.success),
const SizedBox(width: 8),
Text('${e.key}: ${e.value}',
style: const TextStyle(fontSize: 13)),
],
),
)),
],
),
),
);
},
),
);
}
}
class _BotTab extends ConsumerWidget {
const _BotTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<BotCommand>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminBotCommands);
return (resp.data as List<dynamic>)
.map((e) => BotCommand.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (commands) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: commands.length,
itemBuilder: (_, i) {
final cmd = commands[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
cmd.isEnabled ? Icons.check_circle : Icons.cancel,
color: cmd.isEnabled ? VoIdeaColors.success : VoIdeaColors.error,
),
title: Text('/${cmd.command}'),
subtitle: Text(cmd.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Switch(
value: cmd.isEnabled,
onChanged: (v) => _toggleCommand(ref, cmd.id, v),
),
),
);
},
),
);
}
void _toggleCommand(WidgetRef ref, String id, bool enabled) async {
try {
final api = ref.read(apiClientProvider);
await api.patch('${Endpoints.adminBotCommands}/$id', data: {
'is_enabled': enabled,
});
} catch (_) {}
}
}
class _ServicesTab extends ConsumerWidget {
const _ServicesTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<HealthStatus>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminHealth);
return HealthStatus.fromJson(resp.data as Map<String, dynamic>);
},
builder: (health) => ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
health.status == 'ok'
? Icons.check_circle
: Icons.error,
color: health.status == 'ok'
? VoIdeaColors.success
: VoIdeaColors.error,
),
const SizedBox(width: 8),
Text('Статус: ${health.status}',
style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 12),
Text('Версия: ${health.version}',
style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4),
Text('Uptime: ${health.uptimeSeconds.toStringAsFixed(0)}с',
style: const TextStyle(fontSize: 13)),
],
),
),
),
const SizedBox(height: 16),
const Text('Сервисы',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 8),
...health.services.entries.map((e) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
e.value == 'ok' || e.value == true
? Icons.check_circle
: Icons.error,
color: e.value == 'ok' || e.value == true
? VoIdeaColors.success
: VoIdeaColors.error,
size: 20,
),
title: Text(e.key),
subtitle: Text('${e.value}'),
),
)),
],
),
);
}
}
class _FutureBuilder<T> extends StatelessWidget {
final Future<T> Function() future;
final Widget Function(T data) builder;
const _FutureBuilder({required this.future, required this.builder});
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future(),
builder: (ctx, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingIndicator();
}
if (snapshot.hasError) {
return ErrorDisplay(message: snapshot.error.toString());
}
if (!snapshot.hasData) {
return const EmptyState(
icon: Icons.info_outline, title: 'Нет данных');
}
return builder(snapshot.data!);
},
);
}
}