mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 17:07:46 +00:00
Compare commits
2
Commits
79a565f16b
...
f6532d992d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6532d992d | ||
|
|
31ddec0739 |
@@ -4,8 +4,8 @@ const model = defineModel<boolean>({ required: true })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button type="button" role="switch" :aria-checked="model"
|
<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="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-yellow-400' : 'bg-slate-600'" @click="model = !model">
|
: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"
|
<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'" />
|
:class="model ? 'translate-x-5' : 'translate-x-0'" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+24
-11
@@ -33,13 +33,13 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-light min-h-screen bg-[#f8fafc]">
|
<div class="admin-light min-h-screen bg-white">
|
||||||
<aside
|
<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">
|
<div class="flex h-16 items-center justify-between border-white/20 px-5">
|
||||||
<NuxtLink to="/admin" class="flex items-center gap-3">
|
<NuxtLink to="/admin" class="flex items-center gap-3">
|
||||||
<AppIcon name="film" class="size-5 text-white" />
|
<AppIcon name="film" class="size-5 text-[#095DF2]" />
|
||||||
<span class="text-lg font-black text-white tracking-tight">Admin Management</span>
|
<span class="text-lg font-black text-slate-900 tracking-tight">Admin Management</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button"
|
<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"
|
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">
|
<nav class="flex flex-col gap-1 p-3">
|
||||||
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
|
<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)
|
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'
|
? 'bg-[#095DF2]/10 text-[#095DF2] font-semibold'
|
||||||
: 'text-blue-100 hover:bg-white/10 hover:text-white'">
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'">
|
||||||
<AppIcon :name="item.icon" class="size-5 transition group-hover:scale-110" />
|
<AppIcon :name="item.icon" class="size-5 transition group-hover:scale-110" />
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</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="/"
|
<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" />
|
<AppIcon name="home" class="size-5" />
|
||||||
Về trang chủ
|
Về trang chủ
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button"
|
<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">
|
@click="handleLogout">
|
||||||
<AppIcon name="log-out" class="size-5" />
|
<AppIcon name="log-out" class="size-5" />
|
||||||
Đăng xuất
|
Đăng xuất
|
||||||
@@ -220,12 +220,25 @@ const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
|
|||||||
.admin-light .text-red-400,
|
.admin-light .text-red-400,
|
||||||
.admin-light .text-red-300 {
|
.admin-light .text-red-300 {
|
||||||
color: #dc2626 !important;
|
color: #dc2626 !important;
|
||||||
/* Red-600 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-light .text-emerald-400 {
|
.admin-light .text-emerald-400 {
|
||||||
color: #059669 !important;
|
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 */
|
/* Input placeholder */
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ const syncSourceOptions = [
|
|||||||
<div class="flex items-center justify-center gap-1">
|
<div class="flex items-center justify-center gap-1">
|
||||||
Phim
|
Phim
|
||||||
<AppIcon :name="sortIcon('name')" class="size-3"
|
<AppIcon :name="sortIcon('name')" class="size-3"
|
||||||
:class="sortBy === 'name' ? 'text-yellow-400' : 'opacity-50'" />
|
:class="sortBy === 'name' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Nguồn</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">
|
<div class="flex items-center justify-center gap-1">
|
||||||
Lượt xem
|
Lượt xem
|
||||||
<AppIcon :name="sortIcon('views')" class="size-3"
|
<AppIcon :name="sortIcon('views')" class="size-3"
|
||||||
:class="sortBy === 'views' ? 'text-yellow-400' : 'opacity-50'" />
|
:class="sortBy === 'views' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th style="border-bottom: 3px solid #eeee;"
|
<th style="border-bottom: 3px solid #eeee;"
|
||||||
@@ -297,7 +297,7 @@ const syncSourceOptions = [
|
|||||||
<div class="flex items-center justify-center gap-1">
|
<div class="flex items-center justify-center gap-1">
|
||||||
Cập nhật API
|
Cập nhật API
|
||||||
<AppIcon :name="sortIcon('apiUpdatedAt')" class="size-3"
|
<AppIcon :name="sortIcon('apiUpdatedAt')" class="size-3"
|
||||||
:class="sortBy === 'apiUpdatedAt' ? 'text-yellow-400' : 'opacity-50'" />
|
:class="sortBy === 'apiUpdatedAt' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th style="border-bottom: 3px solid #eeee;"
|
<th style="border-bottom: 3px solid #eeee;"
|
||||||
@@ -306,7 +306,7 @@ const syncSourceOptions = [
|
|||||||
<div class="flex items-center justify-center gap-1">
|
<div class="flex items-center justify-center gap-1">
|
||||||
Trạng thái
|
Trạng thái
|
||||||
<AppIcon :name="sortIcon('active')" class="size-3"
|
<AppIcon :name="sortIcon('active')" class="size-3"
|
||||||
:class="sortBy === 'active' ? 'text-yellow-400' : 'opacity-50'" />
|
:class="sortBy === 'active' ? 'text-[#095DF2]' : 'opacity-50'" />
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Tuỳ chỉnh</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">
|
<div class="relative inline-flex">
|
||||||
<button type="button" class="grid size-9 place-items-center rounded-lg
|
<button type="button" class="grid size-9 place-items-center rounded-lg
|
||||||
text-zinc-500 transition
|
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">
|
@click="menuOpen = menuOpen === movie.id ? null : movie.id">
|
||||||
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
||||||
</button>
|
</button>
|
||||||
<Transition name="dropdown-fade">
|
<Transition name="dropdown-fade">
|
||||||
<div v-if="menuOpen === movie.id" class="absolute right-0 top-full z-50 mt-1 min-w-40
|
<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
|
rounded-lg border border-slate-200
|
||||||
bg-[#131418] py-1 shadow-xl">
|
bg-white py-1 shadow-xl">
|
||||||
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
|
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
|
||||||
text-sm text-slate-300 transition
|
text-sm text-slate-700 transition
|
||||||
hover:bg-white/5 hover:text-white" @click="menuOpen = null">
|
hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null">
|
||||||
<AppIcon name="pencil" class="size-4" />
|
<AppIcon name="pencil" class="size-4" />
|
||||||
Chỉnh sửa
|
Chỉnh sửa
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button" class="flex w-full items-center gap-2 px-3 py-2
|
<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;
|
menuOpen = null;
|
||||||
selectedMovie = movie;
|
selectedMovie = movie;
|
||||||
deleteConfirmOpen = true
|
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>
|
<span v-if="page === 'ellipsis'" class="grid size-9 place-items-center text-sm text-slate-500">...</span>
|
||||||
<button v-else type="button"
|
<button v-else type="button"
|
||||||
class="grid size-9 place-items-center rounded-lg border text-sm font-semibold transition" :class="page === currentPage
|
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">
|
: 'border-white/8 text-slate-400 hover:bg-white/5 hover:text-white'" @click="currentPage = page">
|
||||||
{{ page }}
|
{{ page }}
|
||||||
</button>
|
</button>
|
||||||
@@ -462,41 +462,41 @@ const syncSourceOptions = [
|
|||||||
<Transition name="modal-fade">
|
<Transition name="modal-fade">
|
||||||
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
|
<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="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"
|
<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">
|
@click="syncOpen = false">
|
||||||
<AppIcon name="x" class="size-5" />
|
<AppIcon name="x" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<h2 class="text-xl font-black text-white">Đồng bộ phim</h2>
|
<h2 class="text-xl font-black text-slate-900">Đồ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>
|
<p class="mt-1 text-sm text-slate-500">Chọn nguồn để đồng bộ phim vào hệ thống</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<label v-for="source in syncSourceOptions" :key="source.key"
|
<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>
|
<div>
|
||||||
<p class="text-sm font-bold text-white">{{ source.label }}</p>
|
<p class="text-sm font-bold text-slate-900">{{ source.label }}</p>
|
||||||
<p class="text-xs text-slate-400">{{ source.domain }}</p>
|
<p class="text-xs text-slate-500">{{ source.domain }}</p>
|
||||||
</div>
|
</div>
|
||||||
<AdminToggle v-model="syncSources[source.key]" />
|
<AdminToggle v-model="syncSources[source.key]" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="syncResult" class="mt-4 rounded-xl border border-white/6 bg-white/3 p-4">
|
<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-white">Kết quả đồng bộ</p>
|
<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-400">
|
<div class="mt-2 space-y-1 text-xs text-slate-500">
|
||||||
<p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p>
|
<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-400">{{ syncResult.created }}</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-yellow-400">{{ syncResult.updated }}</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">
|
<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">
|
<ul class="mt-1 space-y-0.5">
|
||||||
<li v-for="(stats, source) in syncResult.sourceStats" :key="source">
|
<li v-for="(stats, source) in syncResult.sourceStats" :key="source">
|
||||||
<span class="uppercase text-white">{{ source }}</span>: {{ stats.fetched }} phim
|
<span class="uppercase text-slate-900">{{ source }}</span>: {{ stats.fetched }} phim
|
||||||
<span v-if="stats.error" class="text-red-400">({{ stats.error }})</span>
|
<span v-if="stats.error" class="text-red-500">({{ stats.error }})</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,12 +518,12 @@ const syncSourceOptions = [
|
|||||||
<Transition name="modal-fade">
|
<Transition name="modal-fade">
|
||||||
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
|
<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="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">
|
<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>
|
</div>
|
||||||
<h2 class="text-xl font-black text-white">Xoá tất cả phim?</h2>
|
<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-400">
|
<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
|
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ể
|
thể
|
||||||
hoàn tác.
|
hoàn tác.
|
||||||
|
|||||||
+103
-22
@@ -9,6 +9,9 @@ useHead({
|
|||||||
|
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
const debouncedKeyword = ref('')
|
const debouncedKeyword = ref('')
|
||||||
|
const roleFilter = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
@@ -16,12 +19,21 @@ watch(searchInput, (val) => {
|
|||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
debouncedKeyword.value = val.trim()
|
debouncedKeyword.value = val.trim()
|
||||||
|
currentPage.value = 1
|
||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([roleFilter, statusFilter], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
const { data, refresh } = await useFetch('/api/admin/thanh-vien', {
|
const { data, refresh } = await useFetch('/api/admin/thanh-vien', {
|
||||||
query: computed(() => ({
|
query: computed(() => ({
|
||||||
keyword: debouncedKeyword.value,
|
keyword: debouncedKeyword.value,
|
||||||
|
role: roleFilter.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
limit: 20,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,6 +66,39 @@ async function deleteMember(member: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const members = computed(() => data.value?.items || [])
|
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
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -64,44 +109,59 @@ const members = computed(() => data.value?.items || [])
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card overflow-hidden">
|
<div class="admin-card overflow-hidden">
|
||||||
<div class="flex items-center gap-3 border-b border-white/6 p-4">
|
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
|
||||||
<div class="relative flex-1 min-w-0">
|
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"
|
<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" />
|
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..."
|
<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">
|
class="admin-input h-10 w-full pl-10! pr-3">
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="overflow-x-auto admin-scrollbar">
|
<div class="overflow-x-auto admin-scrollbar">
|
||||||
<table class="w-full">
|
<table class="w-full" style="border-collapse: collapse; border-spacing: 0;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-white/6 text-left text-xs font-bold uppercase tracking-wide text-slate-400">
|
<tr class="text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||||
<th class="w-14 px-5 py-3">STT</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">STT</th>
|
||||||
<th class="px-5 py-3">Thành viên</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Thành viên</th>
|
||||||
<th class="px-5 py-3">Vai trò</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Vai trò</th>
|
||||||
<th class="px-5 py-3">Trạng thái</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Trạng thái</th>
|
||||||
<th class="px-5 py-3">Tham gia</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Tham gia</th>
|
||||||
<th class="px-5 py-3 text-right">Thao tác</th>
|
<th style="border-bottom: 3px solid #eeee;" class="px-4 py-3 text-center">Thao tác</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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">
|
<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-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}
|
||||||
<td class="px-5 py-3.5">
|
</td>
|
||||||
<div class="flex items-center gap-3">
|
<td class="min-w-0 px-4 py-3.5 text-center">
|
||||||
|
<div class="flex items-center gap-3 justify-center">
|
||||||
<div
|
<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">
|
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 shrink-0">
|
||||||
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
|
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<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-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
|
||||||
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-3.5">
|
<td class="px-4 py-3.5 text-center">
|
||||||
<select :value="member.role"
|
<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"
|
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)">
|
@change="changeRole(member, ($event.target as HTMLSelectElement).value)">
|
||||||
@@ -110,14 +170,14 @@ const members = computed(() => data.value?.items || [])
|
|||||||
<option value="admin" class="bg-white">Admin</option>
|
<option value="admin" class="bg-white">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-3.5">
|
<td class="px-4 py-3.5 text-center">
|
||||||
<AdminToggle :model-value="member.active"
|
<AdminToggle :model-value="member.active"
|
||||||
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
|
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
|
||||||
</td>
|
</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') }}
|
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-3.5 text-right">
|
<td class="px-4 py-3.5 text-center">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
|
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
|
||||||
:disabled="currentUser?.id === member.id"
|
:disabled="currentUser?.id === member.id"
|
||||||
@@ -136,8 +196,29 @@ const members = computed(() => data.value?.items || [])
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t border-white/6 p-4">
|
<div v-if="totalPages > 1" 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>
|
<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-white/8 text-slate-500 transition hover:bg-white/5 hover:text-white 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-white/8 text-slate-500 hover:bg-white/5 hover:text-white'" @click="currentPage = page">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button type="button"
|
||||||
|
class="grid size-9 place-items-center rounded-lg border border-white/8 text-slate-500 transition hover:bg-white/5 hover:text-white disabled:opacity-30"
|
||||||
|
:disabled="currentPage >= totalPages" @click="currentPage++">
|
||||||
|
<AppIcon name="chevron-right" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user