Files
imen_pishghaman/apps/admin/src/pages/UsersPage.vue
T

334 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>