334 lines
14 KiB
Vue
334 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
type AdminUserDto,
|
||
ROLE_LABELS,
|
||
ROLES,
|
||
toIssues,
|
||
userCreateSchema,
|
||
userUpdateSchema,
|
||
} from "@imen/shared";
|
||
import { message } from "ant-design-vue";
|
||
import { computed, onMounted, type Ref, ref } from "vue";
|
||
import type { z } from "zod";
|
||
import { api, errorMessage } from "@/api/http";
|
||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||
import FormField from "@/components/FormField.vue";
|
||
import PageHeader from "@/components/PageHeader.vue";
|
||
import { useZodForm } from "@/composables/useZodForm";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import { jalali } from "@/utils/format";
|
||
|
||
const auth = useAuthStore();
|
||
const rows = ref<AdminUserDto[]>([]);
|
||
const loading = ref(false);
|
||
|
||
/** null = closed, 0 = new user, id = editing */
|
||
const editing = ref<number | null>(null);
|
||
const saving = ref(false);
|
||
|
||
// one form model for both create and update; the matching schema validates on save
|
||
type Form = z.input<typeof userCreateSchema> & { isActive: boolean };
|
||
const empty: Form = { phone: "", name: "", role: "EDITOR", password: "", isActive: true };
|
||
const form = useZodForm(userCreateSchema, empty);
|
||
const model = form.model as unknown as Ref<Form>;
|
||
const isNew = computed(() => editing.value === 0);
|
||
const ROLE_OPTIONS = ROLES.map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
try {
|
||
rows.value = await api<AdminUserDto[]>("/admin/users");
|
||
} catch (e) {
|
||
void message.error(errorMessage(e));
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function openNew() {
|
||
form.reset(empty);
|
||
editing.value = 0;
|
||
}
|
||
function openEdit(u: AdminUserDto) {
|
||
form.reset({ phone: u.phone, name: u.name, role: u.role, isActive: u.isActive, password: "" } as Form);
|
||
editing.value = u.id;
|
||
}
|
||
|
||
async function save() {
|
||
saving.value = true;
|
||
try {
|
||
if (isNew.value) {
|
||
const data = form.validate();
|
||
if (!data) return;
|
||
await api("/admin/users", { method: "POST", body: data });
|
||
} else {
|
||
const { name, role, isActive, password } = model.value;
|
||
const parsed = userUpdateSchema.safeParse({ name, role, isActive, password: password || undefined });
|
||
if (!parsed.success) {
|
||
form.errors.value = Object.fromEntries(toIssues(parsed.error).map((i) => [i.path, i.message]));
|
||
return;
|
||
}
|
||
await api(`/admin/users/${editing.value}`, { method: "PUT", body: parsed.data });
|
||
}
|
||
void message.success("ذخیره شد");
|
||
editing.value = null;
|
||
await load();
|
||
} catch (e) {
|
||
form.handleError(e);
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function remove(u: AdminUserDto) {
|
||
try {
|
||
await api(`/admin/users/${u.id}`, { method: "DELETE" });
|
||
void message.success("کاربر حذف شد");
|
||
await load();
|
||
} catch (e) {
|
||
void message.error(errorMessage(e));
|
||
}
|
||
}
|
||
|
||
const columns = [
|
||
{ title: "نام", dataIndex: "name" },
|
||
{ title: "موبایل", key: "phone" },
|
||
{ title: "نقش", key: "role", width: 110 },
|
||
{ title: "وضعیت", key: "active", width: 100 },
|
||
{ title: "آخرین ورود", key: "login", width: 170 },
|
||
{ title: "", key: "actions", width: 100 },
|
||
];
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخشها دسترسی دارد؛ ویرایشگر به جز کاربران و تنظیمات سایت">
|
||
<a-button type="primary" @click="openNew">
|
||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||
کاربر جدید
|
||
</a-button>
|
||
</PageHeader>
|
||
|
||
<!-- Desktop Table (Preserved for lg+) -->
|
||
<a-card :body-style="{ padding: 0 }" class="max-lg:hidden">
|
||
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 700 }">
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
|
||
<template v-else-if="column.key === 'role'">
|
||
<a-tag :color="record.role === 'ADMIN' ? 'orange' : 'blue'">{{ ROLE_LABELS[record.role as keyof typeof ROLE_LABELS] }}</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'active'">
|
||
<a-tag :color="record.isActive ? 'green' : 'default'">{{ record.isActive ? "فعال" : "غیرفعال" }}</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'login'">{{ jalali(record.lastLoginAt) }}</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<a-space :size="4">
|
||
<a-button size="small" @click="openEdit(record as AdminUserDto)"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
|
||
<a-popconfirm
|
||
v-if="record.id !== auth.user?.id"
|
||
title="این کاربر حذف شود؟"
|
||
ok-text="حذف"
|
||
cancel-text="انصراف"
|
||
@confirm="remove(record as AdminUserDto)"
|
||
>
|
||
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
|
||
</a-popconfirm>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</a-card>
|
||
|
||
<!-- Mobile User Cards (App Experience for <lg) -->
|
||
<div class="lg:hidden space-y-3">
|
||
<a-spin :spinning="loading">
|
||
<div v-if="!rows.length && !loading" class="rounded-xl border border-dashed border-gray-200 bg-white p-8 text-center">
|
||
<div class="text-sm text-gray-500">کاربری یافت نشد</div>
|
||
</div>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="user in rows"
|
||
:key="user.id"
|
||
class="rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-all"
|
||
>
|
||
<!-- Header: User Avatar & Role Tag -->
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="flex items-center gap-3">
|
||
<div
|
||
class="flex size-11 flex-shrink-0 items-center justify-center rounded-full font-bold text-white shadow-sm"
|
||
:class="user.role === 'ADMIN' ? 'bg-gradient-to-tr from-orange-600 to-amber-500' : 'bg-gradient-to-tr from-blue-600 to-sky-500'"
|
||
>
|
||
{{ user.name.slice(0, 1) }}
|
||
</div>
|
||
<div>
|
||
<div class="font-bold text-gray-900">{{ user.name }}</div>
|
||
<a
|
||
:href="`tel:${user.phone}`"
|
||
class="mt-0.5 inline-block text-xs font-mono text-gray-500 hover:text-orange-600"
|
||
dir="ltr"
|
||
>
|
||
{{ user.phone }}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col items-end gap-1">
|
||
<a-tag :color="user.role === 'ADMIN' ? 'orange' : 'blue'" class="!m-0 text-[11px]">
|
||
{{ ROLE_LABELS[user.role as keyof typeof ROLE_LABELS] }}
|
||
</a-tag>
|
||
<a-tag :color="user.isActive ? 'green' : 'default'" class="!m-0 text-[10px]">
|
||
{{ user.isActive ? "فعال" : "غیرفعال" }}
|
||
</a-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom Meta & Actions -->
|
||
<div class="mt-3.5 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400">
|
||
<div class="flex items-center gap-1.5">
|
||
<span>آخرین ورود:</span>
|
||
<span class="font-medium text-gray-600">{{ jalali(user.lastLoginAt) }}</span>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-1.5">
|
||
<a-button size="small" class="!flex !items-center !gap-1" @click="openEdit(user)">
|
||
<template #icon><VuesaxIcon name="edit-2" :size="14" /></template>
|
||
<span>ویرایش</span>
|
||
</a-button>
|
||
|
||
<a-popconfirm
|
||
v-if="user.id !== auth.user?.id"
|
||
title="این کاربر حذف شود؟"
|
||
ok-text="حذف"
|
||
cancel-text="انصراف"
|
||
@confirm="remove(user)"
|
||
>
|
||
<a-button size="small" danger class="!px-2">
|
||
<template #icon><VuesaxIcon name="trash" :size="14" /></template>
|
||
</a-button>
|
||
</a-popconfirm>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
|
||
<!-- Mobile Floating Action Button (FAB) for New User -->
|
||
<button
|
||
type="button"
|
||
class="lg:hidden fixed bottom-20 left-4 z-30 flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
|
||
title="کاربر جدید"
|
||
@click="openNew"
|
||
>
|
||
<VuesaxIcon name="add" :size="24" />
|
||
</button>
|
||
|
||
<!-- Mobile Collapsible Role Permissions Guide -->
|
||
<details class="lg:hidden mt-4 rounded-xl border border-gray-200 bg-white p-3 text-xs shadow-sm">
|
||
<summary class="cursor-pointer font-bold text-gray-700 flex items-center justify-between select-none">
|
||
<span class="flex items-center gap-1.5">
|
||
<VuesaxIcon name="info-circle" :size="16" class="text-orange-500" />
|
||
راهنمای نقشها و مجوزهای دسترسی
|
||
</span>
|
||
<span class="text-gray-400 text-[10px]">لمس برای مشاهده</span>
|
||
</summary>
|
||
<div class="mt-3 space-y-3 pt-3 border-t border-gray-100">
|
||
<div class="rounded-lg border border-orange-200 bg-orange-50/40 p-2.5">
|
||
<div class="flex items-center gap-2 font-bold text-orange-700">
|
||
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
|
||
<span class="text-[11px]">دسترسی ارشد سیستمی</span>
|
||
</div>
|
||
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
|
||
<li>مدیریت کاربران، نقشها و تنظیمات امنیتی</li>
|
||
<li>تنظیمات پایه سایت، سئو، شبکهها و ساعات کاری</li>
|
||
<li>حذف دادهها (محصولات، دستهها، پیامها، رسانهها)</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="rounded-lg border border-blue-200 bg-blue-50/40 p-2.5">
|
||
<div class="flex items-center gap-2 font-bold text-blue-700">
|
||
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
|
||
<span class="text-[11px]">دسترسی محتوایی</span>
|
||
</div>
|
||
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
|
||
<li>ایجاد، ویرایش و انتشار محصولات و دستهبندیها</li>
|
||
<li>ویرایش محتوای بخشهای صفحه اصلی</li>
|
||
<li>آپلود تصاویر در رسانه و مشاهده پیامهای تماس</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Desktop Role Permissions Reference Cards -->
|
||
<div class="mt-6 max-lg:hidden grid gap-4 md:grid-cols-2">
|
||
<a-card size="small" class="border-orange-200 bg-orange-50/30">
|
||
<div class="flex items-center gap-2 font-bold text-orange-700">
|
||
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
|
||
<span class="text-xs">دسترسی ارشد سیستمی</span>
|
||
</div>
|
||
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
|
||
<li>مدیریت کاربران، افزودن کاربر و تغییر نقشها و وضعیت حساب</li>
|
||
<li>دسترسی کامل به تنظیمات پایه سایت، ساعات کاری، شبکهها و سئو</li>
|
||
<li>مجوز حذف محصولات، دستهبندیها، رسانهها و پیامهای تماس</li>
|
||
<li>مشاهده لاگهای امنیتی و رویدادهای کامل سامانه</li>
|
||
</ul>
|
||
</a-card>
|
||
|
||
<a-card size="small" class="border-blue-200 bg-blue-50/30">
|
||
<div class="flex items-center gap-2 font-bold text-blue-700">
|
||
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
|
||
<span class="text-xs">دسترسی محتوایی</span>
|
||
</div>
|
||
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
|
||
<li>ایجاد، ویرایش، کپی و انتشار محصولات و گروهها</li>
|
||
<li>ویرایش محتوای بخشهای صفحه اصلی و فعال/مخفیسازی نمایش آنها</li>
|
||
<li>آپلود و ویرایش اطلاعات رسانهها در کتابخانه رسانه</li>
|
||
<li>مشاهده پیامهای تماس و بهروزرسانی وضعیت (بدون دسترسی حذف یا مدیریت کاربران)</li>
|
||
</ul>
|
||
</a-card>
|
||
</div>
|
||
|
||
<a-modal
|
||
:open="editing !== null"
|
||
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
|
||
ok-text="ذخیره"
|
||
cancel-text="انصراف"
|
||
:confirm-loading="saving"
|
||
destroy-on-close
|
||
@ok="save"
|
||
@cancel="editing = null"
|
||
>
|
||
<a-form layout="vertical" class="mt-4" @submit.prevent="save">
|
||
<FormField path="phone" label="شماره موبایل" required>
|
||
<a-input
|
||
v-model:value="model.phone"
|
||
class="ltr-input"
|
||
:disabled="!isNew"
|
||
inputmode="tel"
|
||
placeholder="09121234567"
|
||
/>
|
||
</FormField>
|
||
<FormField path="name" label="نام" required>
|
||
<a-input v-model:value="model.name" />
|
||
</FormField>
|
||
<FormField path="role" label="نقش" required>
|
||
<a-select
|
||
v-model:value="model.role"
|
||
:disabled="editing === auth.user?.id"
|
||
:options="ROLE_OPTIONS"
|
||
/>
|
||
<div class="mt-1.5 text-[11px] text-gray-500">
|
||
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف دادهها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخشها و پیامها (بدون حذف)' }}
|
||
</div>
|
||
</FormField>
|
||
<FormField v-if="!isNew" path="isActive">
|
||
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox>
|
||
</FormField>
|
||
<FormField path="password" :label="isNew ? 'رمز عبور' : 'رمز عبور جدید (اختیاری)'" :required="isNew" hint="حداقل ۸ کاراکتر">
|
||
<a-input-password v-model:value="model.password" class="ltr-input" autocomplete="new-password" />
|
||
</FormField>
|
||
</a-form>
|
||
</a-modal>
|
||
</template>
|