feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-4xl mx-auto" v-if="group">
|
||||
<div class="flex items-center gap-4 mb-8 text-sm">
|
||||
<NuxtLink to="/admin/groups" class="text-slate-500 hover:text-white transition">Группы</NuxtLink>
|
||||
<span class="text-slate-700">/</span>
|
||||
<span class="text-slate-300">{{ group.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-8 mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-white">{{ group.name }}</h1>
|
||||
<button @click="deleteGroup" class="px-4 py-2 bg-red-900/20 text-red-400 hover:bg-red-900/40 rounded-lg text-sm transition">Удалить группу</button>
|
||||
</div>
|
||||
<p class="text-slate-400">{{ group.description || 'Нет описания' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Members Section -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden mb-8">
|
||||
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Участники ({{ members.length }})</h2>
|
||||
<button @click="showAddMemberModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition">+ Добавить</button>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="m in members" :key="m.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center font-bold text-slate-400">
|
||||
{{ m.user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-200 font-medium">{{ m.user.username }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<select
|
||||
v-if="group.ownerId === auth.user.value?.id || auth.user.value?.role === 'admin'"
|
||||
:value="m.role"
|
||||
@change="(e) => updateMemberRole(m.userId, e.target.value)"
|
||||
class="bg-transparent border-none p-0 focus:ring-0 text-blue-400 cursor-pointer outline-none"
|
||||
:disabled="m.userId === group.ownerId"
|
||||
>
|
||||
<option value="admin">Админ</option>
|
||||
<option value="editor">Редактор</option>
|
||||
<option value="viewer">Зритель</option>
|
||||
</select>
|
||||
<span v-else>{{ m.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="m.role !== 'admin'" @click="removeMember(m.userId)" class="text-slate-600 hover:text-red-400 transition text-xl">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases Section -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden">
|
||||
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Карты группы ({{ group.canvases?.length || 0 }})</h2>
|
||||
<NuxtLink to="/canvases" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-bold transition">Управлять в Проектах</NuxtLink>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="c in group.canvases" :key="c.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-900/20 rounded-lg flex items-center justify-center text-xl">🗺️</div>
|
||||
<div>
|
||||
<div class="text-slate-200 font-medium">{{ c.name }}</div>
|
||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider">Обновлена: {{ new Date(c.updatedAt).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="unassignCanvas(c.id)" class="text-slate-600 hover:text-red-400 transition text-sm font-bold">Отвязать</button>
|
||||
</div>
|
||||
<div v-if="!group.canvases?.length" class="p-8 text-center text-slate-500 italic">
|
||||
В этой группе пока нет карт.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showAddMemberModal = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-3xl p-8 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span>👤</span> Добавить участника
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поиск пользователя</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searchUsers"
|
||||
placeholder="Введите имя или email..."
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white outline-none focus:border-blue-500 transition"
|
||||
/>
|
||||
<div v-if="searching" class="absolute right-4 top-3.5">
|
||||
<div class="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div v-if="searchResults.length > 0" class="max-h-60 overflow-y-auto bg-slate-800/50 rounded-xl border border-slate-700 divide-y divide-slate-700">
|
||||
<div v-for="u in searchResults" :key="u.id" class="p-3 flex items-center justify-between hover:bg-slate-700/50 transition">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-white">{{ u.username }}</span>
|
||||
<span class="text-[10px] text-slate-500">{{ u.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="addMember(u.id)"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-lg transition"
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="searchQuery.length >= 2" class="text-center py-4 text-slate-500 text-sm">
|
||||
Пользователи не найдены
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button @click="showAddMemberModal = false" class="w-full py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuth()
|
||||
const group = ref(null)
|
||||
const members = ref([])
|
||||
const showAddMemberModal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searching = ref(false)
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (searchQuery.value.length < 2) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/users/search', {
|
||||
params: { q: searchQuery.value },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
searchResults.value = data.users || []
|
||||
} catch (e) {
|
||||
console.error('Search failed', e)
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (userId) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/add-member`, {
|
||||
method: 'POST',
|
||||
body: { userId, role: 'member' },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showAddMemberModal.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert(e.data?.message || 'Ошибка при добавлении')
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (userId, role) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'PATCH',
|
||||
body: { userId, role },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при смене роли')
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (userId) => {
|
||||
if (!confirm('Удалить участника из группы?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'DELETE',
|
||||
body: { userId },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
const unassignCanvas = async (canvasId) => {
|
||||
if (!confirm('Отвязать карту от группы? Она станет личной картой владельца.')) return
|
||||
try {
|
||||
await $fetch(`/api/canvases/${canvasId}`, {
|
||||
method: 'PATCH',
|
||||
body: { groupId: null },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при отвязке карты')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGroup = async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/groups/${route.params.id}`, {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
group.value = data.group
|
||||
members.value = data.group.members || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch group', e)
|
||||
navigateTo('/admin/groups')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async () => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту группу?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
navigateTo('/admin/groups')
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchGroup)
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
|
||||
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
|
||||
>
|
||||
<span class="text-xl">+</span> Новая Группа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Grid -->
|
||||
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
|
||||
{{ group.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
|
||||
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||
<div class="flex -space-x-2">
|
||||
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
|
||||
U{{ i }}
|
||||
</div>
|
||||
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
|
||||
+{{ group.memberCount - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
|
||||
<div class="text-6xl mb-4">👥</div>
|
||||
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
|
||||
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
|
||||
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
|
||||
<input
|
||||
v-model="newGroup.name"
|
||||
type="text"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
|
||||
placeholder="Например: Отдел разработки"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
|
||||
<textarea
|
||||
v-model="newGroup.description"
|
||||
rows="3"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
|
||||
placeholder="Чем занимается эта группа?"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
|
||||
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
|
||||
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-8">
|
||||
<button
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="createGroup"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
|
||||
:disabled="creating"
|
||||
>
|
||||
{{ creating ? 'Создание...' : 'Создать' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const groups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const newGroup = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: true
|
||||
})
|
||||
|
||||
const fetchGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/groups', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
groups.value = data.groups || []
|
||||
} catch (e) {
|
||||
console.error('[Groups Page] Failed to fetch groups', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
if (!newGroup.name.trim()) return
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
body: newGroup,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newGroup.name = ''
|
||||
newGroup.description = ''
|
||||
await fetchGroups()
|
||||
} catch (e) {
|
||||
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupSettings = (group) => {
|
||||
navigateTo(`/admin/groups/${group.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Canvases</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<NuxtLink
|
||||
to="/admin/groups"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
|
||||
>
|
||||
👥 Manage Groups & Teams
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="refreshData"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Users -->
|
||||
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
|
||||
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th class="text-left p-3">Email</th>
|
||||
<th class="text-left p-3">Username</th>
|
||||
<th class="text-left p-3">Role</th>
|
||||
<th class="text-left p-3">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
|
||||
<td class="p-3">{{ user.email }}</td>
|
||||
<td class="p-3">{{ user.username }}</td>
|
||||
<td class="p-3">
|
||||
<span :class="roleBadge(user.role)">{{ user.role }}</span>
|
||||
</td>
|
||||
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
|
||||
{{ canvas.name }}
|
||||
</NuxtLink>
|
||||
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
|
||||
</div>
|
||||
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
|
||||
No canvases yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const users = ref([])
|
||||
const canvases = ref([])
|
||||
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const refreshData = async () => {
|
||||
if (!auth.token.value) return
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/admin/stats', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
users.value = data.users || []
|
||||
canvases.value = data.canvases || []
|
||||
stats.users = data.stats?.users || 0
|
||||
stats.canvases = data.stats?.canvases || 0
|
||||
stats.nodes = data.stats?.nodes || 0
|
||||
stats.pushes = data.stats?.pushes || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const roleBadge = (role) => {
|
||||
return role === 'admin'
|
||||
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
|
||||
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'N/A'
|
||||
return new Date(date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ждем инициализации пользователя если нужно
|
||||
if (!auth.user.value) {
|
||||
await auth.initAuth()
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user