mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat: enhance admin pages with filtering and pagination for members
This commit is contained in:
@@ -4,8 +4,8 @@ const model = defineModel<boolean>({ required: true })
|
||||
|
||||
<template>
|
||||
<button type="button" role="switch" :aria-checked="model"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-yellow-400/50 focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||
:class="model ? 'bg-yellow-400' : 'bg-slate-600'" @click="model = !model">
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-[#095DF2]/50 focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||
:class="model ? 'bg-[#095DF2]' : 'bg-slate-600'" @click="model = !model">
|
||||
<span class="pointer-events-none inline-block size-5 rounded-full bg-white shadow-lg transition duration-300 ease-in-out"
|
||||
:class="model ? 'translate-x-5' : 'translate-x-0'" />
|
||||
</button>
|
||||
|
||||
+24
-11
@@ -33,13 +33,13 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-light min-h-screen bg-[#f8fafc]">
|
||||
<div class="admin-light min-h-screen bg-white">
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 transform border-r border-slate-200 bg-[#095DF2] transition-transform lg:translate-x-0 border-l-4 border-l-blue-700">
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 transform border-r border-slate-200 bg-white transition-transform lg:translate-x-0 border-l-4 border-l-blue-700">
|
||||
<div class="flex h-16 items-center justify-between border-white/20 px-5">
|
||||
<NuxtLink to="/admin" class="flex items-center gap-3">
|
||||
<AppIcon name="film" class="size-5 text-white" />
|
||||
<span class="text-lg font-black text-white tracking-tight">Admin Management</span>
|
||||
<AppIcon name="film" class="size-5 text-[#095DF2]" />
|
||||
<span class="text-lg font-black text-slate-900 tracking-tight">Admin Management</span>
|
||||
</NuxtLink>
|
||||
<button type="button"
|
||||
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 lg:hidden"
|
||||
@@ -51,21 +51,21 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
<nav class="flex flex-col gap-1 p-3">
|
||||
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
|
||||
class="group relative flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold transition" :class="isActive(item.to)
|
||||
? 'bg-white/15 text-white font-semibold'
|
||||
: 'text-blue-100 hover:bg-white/10 hover:text-white'">
|
||||
? 'bg-[#095DF2]/10 text-[#095DF2] font-semibold'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'">
|
||||
<AppIcon :name="item.icon" class="size-5 transition group-hover:scale-110" />
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/20 p-3">
|
||||
<div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-slate-200 p-3">
|
||||
<NuxtLink to="/"
|
||||
class="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-blue-100 transition hover:bg-white/10 hover:text-white">
|
||||
class="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-900">
|
||||
<AppIcon name="home" class="size-5" />
|
||||
Về trang chủ
|
||||
</NuxtLink>
|
||||
<button type="button"
|
||||
class="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-red-500 transition hover:bg-white/10 hover:text-red-600"
|
||||
class="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold text-red-500 transition hover:bg-red-50 hover:text-red-600"
|
||||
@click="handleLogout">
|
||||
<AppIcon name="log-out" class="size-5" />
|
||||
Đăng xuất
|
||||
@@ -220,12 +220,25 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
||||
.admin-light .text-red-400,
|
||||
.admin-light .text-red-300 {
|
||||
color: #dc2626 !important;
|
||||
/* Red-600 */
|
||||
}
|
||||
|
||||
.admin-light .text-emerald-400 {
|
||||
color: #059669 !important;
|
||||
/* Emerald-600 */
|
||||
}
|
||||
|
||||
/* --- Table overrides for light mode --- */
|
||||
|
||||
/* White text inside admin card tables → readable in light mode */
|
||||
.admin-light .admin-card table td[class*="text-white"],
|
||||
.admin-light .admin-card table th[class*="text-white"],
|
||||
.admin-light .admin-card tbody tr td[class*="text-white"],
|
||||
.admin-light .admin-card tbody tr td[class*="text-black"] {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Table row hover in light mode (white/2 too subtle → use slate-100) */
|
||||
.admin-light .admin-card table tbody tr:hover {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
/* Input placeholder */
|
||||
|
||||
@@ -277,7 +277,7 @@ const syncSourceOptions = [
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Phim
|
||||
<AppIcon :name="sortIcon('name')" class="size-3"
|
||||
:class="sortBy === 'name' ? 'text-yellow-400' : 'opacity-50'" />
|
||||
:class="sortBy === 'name' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||
</div>
|
||||
</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Nguồn</th>
|
||||
@@ -288,7 +288,7 @@ const syncSourceOptions = [
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Lượt xem
|
||||
<AppIcon :name="sortIcon('views')" class="size-3"
|
||||
:class="sortBy === 'views' ? 'text-yellow-400' : 'opacity-50'" />
|
||||
:class="sortBy === 'views' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||
</div>
|
||||
</th>
|
||||
<th style="border-bottom: 3px solid #eeee;"
|
||||
@@ -297,7 +297,7 @@ const syncSourceOptions = [
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Cập nhật API
|
||||
<AppIcon :name="sortIcon('apiUpdatedAt')" class="size-3"
|
||||
:class="sortBy === 'apiUpdatedAt' ? 'text-yellow-400' : 'opacity-50'" />
|
||||
:class="sortBy === 'apiUpdatedAt' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||
</div>
|
||||
</th>
|
||||
<th style="border-bottom: 3px solid #eeee;"
|
||||
@@ -306,7 +306,7 @@ const syncSourceOptions = [
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Trạng thái
|
||||
<AppIcon :name="sortIcon('active')" class="size-3"
|
||||
:class="sortBy === 'active' ? 'text-yellow-400' : 'opacity-50'" />
|
||||
:class="sortBy === 'active' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||
</div>
|
||||
</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Tuỳ chỉnh</th>
|
||||
@@ -394,22 +394,22 @@ const syncSourceOptions = [
|
||||
<div class="relative inline-flex">
|
||||
<button type="button" class="grid size-9 place-items-center rounded-lg
|
||||
text-zinc-500 transition
|
||||
hover:bg-white/5 hover:text-zinc-300" title="Thao tác"
|
||||
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
|
||||
@click="menuOpen = menuOpen === movie.id ? null : movie.id">
|
||||
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
||||
</button>
|
||||
<Transition name="dropdown-fade">
|
||||
<div v-if="menuOpen === movie.id" class="absolute right-0 top-full z-50 mt-1 min-w-40
|
||||
rounded-lg border border-white/8
|
||||
bg-[#131418] py-1 shadow-xl">
|
||||
rounded-lg border border-slate-200
|
||||
bg-white py-1 shadow-xl">
|
||||
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
|
||||
text-sm text-slate-300 transition
|
||||
hover:bg-white/5 hover:text-white" @click="menuOpen = null">
|
||||
text-sm text-slate-700 transition
|
||||
hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null">
|
||||
<AppIcon name="pencil" class="size-4" />
|
||||
Chỉnh sửa
|
||||
</NuxtLink>
|
||||
<button type="button" class="flex w-full items-center gap-2 px-3 py-2
|
||||
text-sm text-red-400 transition hover:bg-white/5" @click="
|
||||
text-sm text-red-500 transition hover:bg-red-50" @click="
|
||||
menuOpen = null;
|
||||
selectedMovie = movie;
|
||||
deleteConfirmOpen = true
|
||||
@@ -444,7 +444,7 @@ const syncSourceOptions = [
|
||||
<span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
|
||||
<button v-else type="button"
|
||||
class="grid size-9 place-items-center rounded-lg border text-sm font-semibold transition" :class="page === currentPage
|
||||
? 'border-yellow-400 bg-yellow-400 text-slate-950'
|
||||
? 'border-[#095DF2] bg-[#095DF2] text-white'
|
||||
: 'border-white/8 text-slate-400 hover:bg-white/5 hover:text-white'" @click="currentPage = page">
|
||||
{{ page }}
|
||||
</button>
|
||||
@@ -462,41 +462,41 @@ const syncSourceOptions = [
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
|
||||
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="syncOpen = false" />
|
||||
<div class="relative w-full max-w-md rounded-2xl border border-white/8 bg-[#131418] p-6 shadow-2xl">
|
||||
<div class="relative w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
|
||||
<button type="button"
|
||||
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
class="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||
@click="syncOpen = false">
|
||||
<AppIcon name="x" class="size-5" />
|
||||
</button>
|
||||
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Chọn nguồn để đồng bộ phim vào hệ thống</p>
|
||||
<h2 class="text-xl font-black text-slate-900">Đồng bộ phim</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Chọn nguồn để đồng bộ phim vào hệ thống</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label v-for="source in syncSourceOptions" :key="source.key"
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl border border-white/6 bg-white/3 p-4 transition hover:border-white/10 hover:bg-white/5">
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-4 transition hover:border-slate-300 hover:bg-slate-100">
|
||||
<div>
|
||||
<p class="text-sm font-bold text-white">{{ source.label }}</p>
|
||||
<p class="text-xs text-slate-400">{{ source.domain }}</p>
|
||||
<p class="text-sm font-bold text-slate-900">{{ source.label }}</p>
|
||||
<p class="text-xs text-slate-500">{{ source.domain }}</p>
|
||||
</div>
|
||||
<AdminToggle v-model="syncSources[source.key]" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="syncResult" class="mt-4 rounded-xl border border-white/6 bg-white/3 p-4">
|
||||
<p class="text-sm font-bold text-white">Kết quả đồng bộ</p>
|
||||
<div class="mt-2 space-y-1 text-xs text-slate-400">
|
||||
<p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p>
|
||||
<p>Đã tạo: <span class="font-bold text-emerald-400">{{ syncResult.created }}</span></p>
|
||||
<p>Đã cập nhật: <span class="font-bold text-yellow-400">{{ syncResult.updated }}</span></p>
|
||||
<div v-if="syncResult" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-sm font-bold text-slate-900">Kết quả đồng bộ</p>
|
||||
<div class="mt-2 space-y-1 text-xs text-slate-500">
|
||||
<p>Tổng phim lấy được: <span class="font-bold text-slate-900">{{ syncResult.total }}</span></p>
|
||||
<p>Đã tạo: <span class="font-bold text-emerald-500">{{ syncResult.created }}</span></p>
|
||||
<p>Đã cập nhật: <span class="font-bold text-amber-500">{{ syncResult.updated }}</span></p>
|
||||
<div v-if="syncResult.sourceStats" class="mt-2">
|
||||
<p class="font-semibold text-white">Theo nguồn:</p>
|
||||
<p class="font-semibold text-slate-900">Theo nguồn:</p>
|
||||
<ul class="mt-1 space-y-0.5">
|
||||
<li v-for="(stats, source) in syncResult.sourceStats" :key="source">
|
||||
<span class="uppercase text-white">{{ source }}</span>: {{ stats.fetched }} phim
|
||||
<span v-if="stats.error" class="text-red-400">({{ stats.error }})</span>
|
||||
<span class="uppercase text-slate-900">{{ source }}</span>: {{ stats.fetched }} phim
|
||||
<span v-if="stats.error" class="text-red-500">({{ stats.error }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -518,12 +518,12 @@ const syncSourceOptions = [
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
|
||||
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="deleteConfirmOpen = false" />
|
||||
<div class="relative w-full max-w-sm rounded-2xl border border-white/8 bg-[#131418] p-6 shadow-2xl">
|
||||
<div class="relative w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
|
||||
<div class="mb-4 grid size-12 place-items-center rounded-full bg-red-500/10">
|
||||
<AppIcon name="trash" class="size-6 text-red-400" />
|
||||
<AppIcon name="trash" class="size-6 text-red-500" />
|
||||
</div>
|
||||
<h2 class="text-xl font-black text-white">Xoá tất cả phim?</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||
<h2 class="text-xl font-black text-slate-900">Xoá tất cả phim?</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Hành động này sẽ xoá toàn bộ {{ data?.total || 0 }} phim trong hệ thống, bao gồm cả các tuỳ chỉnh. Không
|
||||
thể
|
||||
hoàn tác.
|
||||
|
||||
+127
-34
@@ -9,6 +9,9 @@ useHead({
|
||||
|
||||
const searchInput = ref('')
|
||||
const debouncedKeyword = ref('')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -16,12 +19,21 @@ watch(searchInput, (val) => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
debouncedKeyword.value = val.trim()
|
||||
currentPage.value = 1
|
||||
}, 300)
|
||||
})
|
||||
|
||||
watch([roleFilter, statusFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
const { data, refresh } = await useFetch('/api/admin/thanh-vien', {
|
||||
query: computed(() => ({
|
||||
keyword: debouncedKeyword.value,
|
||||
role: roleFilter.value,
|
||||
status: statusFilter.value,
|
||||
page: currentPage.value,
|
||||
limit: 20,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -54,6 +66,42 @@ async function deleteMember(member: any) {
|
||||
}
|
||||
|
||||
const members = computed(() => data.value?.items || [])
|
||||
const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
const pages: (number | 'ellipsis')[] = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 4) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
const start = Math.max(2, Math.min(current - 1, total - 4))
|
||||
const end = Math.min(total - 1, Math.max(current + 1, 5))
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total - 1) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
// 3-dot menu state
|
||||
const menuOpen = ref<number | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,44 +112,51 @@ const members = computed(() => data.value?.items || [])
|
||||
</div>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="flex items-center gap-3 border-b border-white/6 p-4">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="grid grid-cols-1 gap-3 border-b border-slate-200 p-4
|
||||
md:grid-cols-2 xl:grid-cols-[minmax(280px,1fr)_180px_160px] xl:items-center">
|
||||
<div class="relative min-w-0 w-full">
|
||||
<AppIcon name="search"
|
||||
class="pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-slate-400" />
|
||||
|
||||
<input v-model="searchInput" type="text" placeholder="Tìm kiếm thành viên..."
|
||||
class="admin-input h-10 w-full pl-10! pr-3">
|
||||
</div>
|
||||
|
||||
<select v-model="roleFilter" class="admin-input h-10 w-full min-w-0 px-3">
|
||||
<option value="" class="bg-white">Tất cả vai trò</option>
|
||||
<option value="user" class="bg-white">User</option>
|
||||
<option value="moderator" class="bg-white">Moderator</option>
|
||||
<option value="admin" class="bg-white">Admin</option>
|
||||
</select>
|
||||
|
||||
<select v-model="statusFilter" class="admin-input h-10 w-full min-w-0 px-3">
|
||||
<option value="" class="bg-white">Tất cả trạng thái</option>
|
||||
<option value="active" class="bg-white">Đang hoạt động</option>
|
||||
<option value="inactive" class="bg-white">Chưa hoạt động</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto admin-scrollbar">
|
||||
<table class="w-full">
|
||||
<table class="w-full" style="border-collapse: collapse; border-spacing: 0;">
|
||||
<thead>
|
||||
<tr class="border-b border-white/6 text-left text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<th class="w-14 px-5 py-3">STT</th>
|
||||
<th class="px-5 py-3">Thành viên</th>
|
||||
<th class="px-5 py-3">Vai trò</th>
|
||||
<th class="px-5 py-3">Trạng thái</th>
|
||||
<th class="px-5 py-3">Tham gia</th>
|
||||
<th class="px-5 py-3 text-right">Thao tác</th>
|
||||
<tr class="text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">STT</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Thành viên</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Vai trò</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Trạng thái</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Tham gia</th>
|
||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/4">
|
||||
<tbody class="divide-y divide-[#eeee]">
|
||||
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2">
|
||||
<td class="px-5 py-3.5 text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="grid size-9 place-items-center rounded-full bg-linear-to-br from-slate-700 to-slate-800 text-sm font-bold text-slate-300">
|
||||
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-bold text-slate-900">{{ member.name || 'Chưa có tên' }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<td class="px-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<td class="px-4 py-3.5 text-center">
|
||||
<p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-center">
|
||||
<select :value="member.role"
|
||||
class="h-8 rounded-full border border-slate-200 bg-slate-100 px-3 text-xs font-bold text-slate-900 outline-none transition hover:bg-slate-100/60 focus:border-sky-500"
|
||||
@change="changeRole(member, ($event.target as HTMLSelectElement).value)">
|
||||
@@ -110,21 +165,38 @@ const members = computed(() => data.value?.items || [])
|
||||
<option value="admin" class="bg-white">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<td class="px-4 py-3.5 text-center">
|
||||
<AdminToggle :model-value="member.active"
|
||||
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-sm text-slate-400">
|
||||
<td class="px-4 py-3.5 text-center text-sm text-slate-400">
|
||||
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-right">
|
||||
<td class="px-4 py-3.5 text-center">
|
||||
<div class="relative inline-flex">
|
||||
<button type="button"
|
||||
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
|
||||
:disabled="currentUser?.id === member.id"
|
||||
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
|
||||
@click="deleteMember(member)">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
class="grid size-9 place-items-center rounded-lg text-zinc-500 transition
|
||||
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
|
||||
@click="menuOpen = menuOpen === member.id ? null : member.id">
|
||||
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
||||
</button>
|
||||
<Transition name="dropdown-fade">
|
||||
<div v-if="menuOpen === member.id" class="absolute right-0 top-full z-50 mt-1 min-w-40
|
||||
rounded-lg border border-slate-200
|
||||
bg-white py-1 shadow-xl">
|
||||
<button type="button"
|
||||
v-if="currentUser?.id !== member.id"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-500 transition hover:bg-red-50"
|
||||
@click="menuOpen = null; deleteMember(member)">
|
||||
<AppIcon name="trash" class="size-4" />
|
||||
Xoá thành viên
|
||||
</button>
|
||||
<div v-else class="px-3 py-2 text-xs text-slate-400">
|
||||
Không thể xoá chính mình
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!members.length">
|
||||
@@ -136,8 +208,29 @@ const members = computed(() => data.value?.items || [])
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-white/6 p-4">
|
||||
<p class="text-sm text-slate-400">Hiển thị {{ members.length }} / {{ data?.total || 0 }} thành viên</p>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between border-t border-slate-200 p-4">
|
||||
<p class="text-sm text-slate-500">Trang {{ currentPage }} / {{ totalPages }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
class="grid size-9 place-items-center rounded-lg border border-slate-300 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
:disabled="currentPage <= 1" @click="currentPage--">
|
||||
<AppIcon name="chevron-left" class="size-4" />
|
||||
</button>
|
||||
<template v-for="(page, index) in visiblePages" :key="index">
|
||||
<span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
|
||||
<button v-else type="button"
|
||||
class="grid size-9 place-items-center rounded-lg border text-sm font-semibold transition" :class="page === currentPage
|
||||
? 'border-[#095DF2] bg-[#095DF2] text-white'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-100 hover:text-slate-800'" @click="currentPage = page">
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
<button type="button"
|
||||
class="grid size-9 place-items-center rounded-lg border border-slate-300 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
:disabled="currentPage >= totalPages" @click="currentPage++">
|
||||
<AppIcon name="chevron-right" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user