Files
Lucky_Wheel/apps/admin/src/views/managers/AdminFormDrawer.vue
T
s.esmaeili c5a841c35f feat(rbac): implement full admin management, dynamic roles, permissions matrix, and workspace access control
- Add Workspace, Role, Permission, RolePermission, AdminRoleAssignment, and AdminWorkspaceAssignment models to Prisma schema\n- Create database migration and RBAC seed script\n- Implement PermissionsGuard, @RequirePermission decorator, and real-time permission resolution in JwtAdminStrategy\n- Implement AdminManagementModule with endpoints for managers CRUD, roles matrix, permissions catalog, and workspaces\n- Secure all sensitive admin controllers with granular permission requirements\n- Add useAuthStore helpers (can, canAny, fetchMe) and v-permission directive\n- Implement Desktop views: AdminListView, AdminFormDrawer (RTL right-side), RoleMatrixView, WorkspaceListView, and ForbiddenView\n- Implement Mobile views: MAdminListView, MAdminFormView (stepped mobile form), MRoleMatrixView, MWorkspaceListView, and MForbiddenView\n- Fix sticky header behavior across desktop and mobile shells\n- Update route guards and device detection logic for mobile prefixing
2026-10-02 16:29:23 +03:30

390 lines
12 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 {
ApartmentOutlined,
KeyOutlined,
SafetyCertificateOutlined,
SaveOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { apiClient } from '../../services/api/client';
const props = defineProps<{
open: boolean;
adminId?: string | null;
}>();
const emit = defineEmits<{
(e: 'update:open', val: boolean): void;
(e: 'saved'): void;
}>();
const loading = ref(false);
const submitting = ref(false);
const rolesList = ref<any[]>([]);
const workspacesList = ref<any[]>([]);
const campaignsList = ref<any[]>([]);
const formState = reactive({
name: '',
email: '',
mobile: '',
password: '',
status: 'ACTIVE',
isSuperAdmin: false,
roleIds: [] as string[],
workspaceId: '',
scopeType: 'ALL',
specificCampaignIds: [] as string[],
});
const isEdit = computed(() => !!props.adminId);
async function loadMetadata() {
try {
const [rolesRes, wsRes, campRes] = await Promise.all([
apiClient.get('/admin/roles'),
apiClient.get('/admin/workspaces'),
apiClient.get('/admin/campaigns', { params: { pageSize: 100 } }),
]);
rolesList.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
workspacesList.value = Array.isArray(wsRes.data) ? wsRes.data : wsRes.data?.data || [];
campaignsList.value = Array.isArray(campRes.data) ? campRes.data : campRes.data?.data || [];
} catch (err) {
// Handled
}
}
async function loadAdminData(id: string) {
loading.value = true;
try {
const res = await apiClient.get(`/admin/managers/${id}`);
const a = res.data;
formState.name = a.name || '';
formState.email = a.email || '';
formState.mobile = a.mobile || '';
formState.password = '';
formState.status = a.status || 'ACTIVE';
formState.isSuperAdmin = !!a.isSuperAdmin;
formState.roleIds = a.roles ? a.roles.map((r: any) => r.role?.id || r.roleId) : [];
if (a.workspaceAssignments && a.workspaceAssignments.length > 0) {
const wa = a.workspaceAssignments[0];
formState.workspaceId = wa.workspaceId || wa.workspace?.id || '';
formState.scopeType = wa.scopeType || 'ALL';
formState.specificCampaignIds = Array.isArray(wa.specificCampaignIds) ? wa.specificCampaignIds : [];
} else {
formState.workspaceId = workspacesList.value[0]?.id || '';
formState.scopeType = 'ALL';
formState.specificCampaignIds = [];
}
} finally {
loading.value = false;
}
}
function resetForm() {
formState.name = '';
formState.email = '';
formState.mobile = '';
formState.password = '';
formState.status = 'ACTIVE';
formState.isSuperAdmin = false;
formState.roleIds = [];
formState.workspaceId = workspacesList.value[0]?.id || '';
formState.scopeType = 'ALL';
formState.specificCampaignIds = [];
}
watch(
() => props.open,
(val) => {
if (val) {
loadMetadata();
if (props.adminId) {
loadAdminData(props.adminId);
} else {
resetForm();
}
}
},
);
async function onSubmit() {
if (!formState.name.trim()) {
message.error('لطفاً نام و نام خانوادگی مدیر را وارد کنید.');
return;
}
if (!formState.email.trim() || !formState.email.includes('@')) {
message.error('لطفاً یک آدرس ایمیل معتبر وارد کنید.');
return;
}
if (!formState.mobile.trim() || !/^09\d{9}$/.test(formState.mobile.trim())) {
message.error('شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.');
return;
}
if (!isEdit.value && (!formState.password || formState.password.length < 6)) {
message.error('کلمه عبور باید حداقل ۶ کاراکتر باشد.');
return;
}
submitting.value = true;
try {
const payload: any = {
name: formState.name.trim(),
email: formState.email.trim().toLowerCase(),
mobile: formState.mobile.trim(),
status: formState.status,
isSuperAdmin: formState.isSuperAdmin,
roleIds: formState.roleIds,
};
if (formState.password) {
payload.password = formState.password;
}
if (formState.workspaceId) {
payload.workspaceAssignments = [
{
workspaceId: formState.workspaceId,
scopeType: formState.scopeType,
specificCampaignIds:
formState.scopeType === 'CUSTOM_CAMPAIGNS' ? formState.specificCampaignIds : undefined,
},
];
}
if (isEdit.value && props.adminId) {
await apiClient.patch(`/admin/managers/${props.adminId}`, payload);
message.success('مشخصات مدیر با موفقیت به‌روزرسانی شد.');
} else {
await apiClient.post('/admin/managers', payload);
message.success('مدیر جدید با موفقیت ایجاد شد.');
}
emit('saved');
emit('update:open', false);
} finally {
submitting.value = false;
}
}
</script>
<template>
<a-drawer
:open="open"
:title="isEdit ? 'ویرایش اطلاعات مدیر' : 'افزودن مدیر جدید به سامانه'"
:width="540"
placement="right"
:maskClosable="false"
rootClassName="admin-form-drawer-rtl"
@close="emit('update:open', false)"
>
<a-spin :spinning="loading">
<a-form layout="vertical">
<a-tabs defaultActiveKey="1" class="admin-form-tabs">
<!-- TAB 1: اطلاعات فردی و ورود -->
<a-tab-pane key="1">
<template #tab>
<span><UserOutlined /> اطلاعات هویتی</span>
</template>
<a-form-item label="نام و نام خانوادگی" required>
<a-input
v-model:value="formState.name"
placeholder="مثال: علی رضایی"
size="large"
/>
</a-form-item>
<a-form-item label="شماره موبایل سازمانی" required>
<a-input
v-model:value="formState.mobile"
placeholder="09xxxxxxxxx"
dir="ltr"
size="large"
maxLength="11"
/>
</a-form-item>
<a-form-item label="ایمیل سازمانی (نام کاربری ورود)" required>
<a-input
v-model:value="formState.email"
placeholder="admin@example.com"
dir="ltr"
size="large"
/>
</a-form-item>
<a-form-item
:label="isEdit ? 'کلمه عبور جدید (در صورت نیاز به تغییر)' : 'کلمه عبور ورود'"
:required="!isEdit"
>
<a-input-password
v-model:value="formState.password"
placeholder="حداقل ۶ کاراکتر"
size="large"
dir="ltr"
/>
</a-form-item>
<a-form-item label="وضعیت حساب کاربری">
<a-radio-group v-model:value="formState.status" buttonStyle="solid">
<a-radio-button value="ACTIVE">فعال</a-radio-button>
<a-radio-button value="DISABLED">غیرفعال / مسدود</a-radio-button>
</a-radio-group>
</a-form-item>
</a-tab-pane>
<!-- TAB 2: نقش‌ها و اختیارات -->
<a-tab-pane key="2">
<template #tab>
<span><SafetyCertificateOutlined /> نقش‌ها و اختیارات</span>
</template>
<div class="super-admin-banner">
<a-checkbox v-model:checked="formState.isSuperAdmin">
<strong style="color: #7c3aed; font-size: 15px">مدیر ارشد سامانه (Super Admin)</strong>
</a-checkbox>
<p class="banner-sub">
مدیر ارشد دسترسی کامل و بی‌قیدوشرط به تمامی بخش‌ها، ایجاد مدیران و تنظیمات کلی سامانه دارد.
</p>
</div>
<a-divider style="margin: 16px 0" />
<a-form-item label="نقش‌های سازمانی (می‌توانید چند نقش همزمان انتخاب کنید)">
<div v-if="rolesList.length === 0" style="color: #94a3b8">
در حال بارگذاری نقش‌ها...
</div>
<a-checkbox-group
v-else
v-model:value="formState.roleIds"
style="width: 100%"
:disabled="formState.isSuperAdmin"
>
<a-row :gutter="[12, 12]">
<a-col :span="24" v-for="r in rolesList" :key="r.id">
<div class="role-item-card" :class="{ 'is-selected': formState.roleIds.includes(r.id) }">
<a-checkbox :value="r.id">
<span class="role-name">{{ r.name }}</span>
<a-tag v-if="r.isSystem" color="blue" size="small" style="margin-right: 6px">سیستمی</a-tag>
</a-checkbox>
<div class="role-desc">{{ r.description || 'بدون توضیح' }}</div>
</div>
</a-col>
</a-row>
</a-checkbox-group>
</a-form-item>
</a-tab-pane>
<!-- TAB 3: فضاهای کاری و اسکوپ دسترسی -->
<a-tab-pane key="3">
<template #tab>
<span><ApartmentOutlined /> قلمرو و فضای کاری</span>
</template>
<a-form-item label="فضای کاری مجاز">
<a-select
v-model:value="formState.workspaceId"
placeholder="انتخاب فضای کاری"
size="large"
style="width: 100%"
>
<a-select-option v-for="ws in workspacesList" :key="ws.id" :value="ws.id">
{{ ws.name }} ({{ ws.slug }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="قلمرو دسترسی کمپین‌ها (Scope)">
<a-radio-group v-model:value="formState.scopeType" style="margin-bottom: 12px">
<a-radio value="ALL">دسترسی به تمامی کمپین‌های این فضای کاری</a-radio>
<a-radio value="CUSTOM_CAMPAIGNS">محدود به کمپین‌های خاص</a-radio>
</a-radio-group>
<div v-if="formState.scopeType === 'CUSTOM_CAMPAIGNS'" style="margin-top: 8px">
<a-select
v-model:value="formState.specificCampaignIds"
mode="multiple"
placeholder="کمپین‌های مجاز را انتخاب کنید"
size="large"
style="width: 100%"
>
<a-select-option v-for="c in campaignsList" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</div>
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
</a-spin>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px">
<a-button @click="emit('update:open', false)">انصراف</a-button>
<a-button type="primary" :loading="submitting" @click="onSubmit">
<template #icon><SaveOutlined /></template>
{{ isEdit ? 'ذخیره تغییرات' : 'ثبت مدیر' }}
</a-button>
</div>
</template>
</a-drawer>
</template>
<style scoped>
.super-admin-banner {
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 12px 16px;
}
.banner-sub {
margin: 6px 0 0 24px;
font-size: 12px;
color: #6b7280;
line-height: 1.6;
}
.role-item-card {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
background: #fff;
}
.role-item-card.is-selected {
border-color: #3b82f6;
background: #eff6ff;
}
.role-name {
font-weight: 600;
font-size: 14px;
color: #1e293b;
}
.role-desc {
margin-right: 24px;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
:deep(.ant-drawer-content) {
direction: rtl;
text-align: right;
}
:deep(.ant-drawer-header) {
direction: rtl;
text-align: right;
}
:deep(.ant-drawer-title) {
text-align: right;
}
:deep(.ant-drawer-close) {
margin-right: 0;
margin-left: var(--ant-margin-xs, 8px);
}
</style>