545 lines
18 KiB
Dart
545 lines
18 KiB
Dart
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!);
|
||
},
|
||
);
|
||
}
|
||
}
|