chore: dockerize monorepo with multi-stage builds and update frontend
This commit is contained in:
+14
-1
@@ -1,4 +1,17 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
.env
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.npm
|
||||
.pnpm-store
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @lucky-wheel/admin build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
|
||||
|
||||
# Configure Nginx for Single Page Application
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name localhost; \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html index.htm; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+69
-5
@@ -7,9 +7,15 @@ dayjs.locale('fa');
|
||||
|
||||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
borderRadius: 6,
|
||||
colorPrimary: '#4f46e5',
|
||||
fontFamily: 'IRANSans, "IRANSansWeb(FaNum)", Tahoma, sans-serif',
|
||||
borderRadius: 8,
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgLayout: '#f4f6fa',
|
||||
colorSuccess: '#10b981',
|
||||
colorWarning: '#f59e0b',
|
||||
colorError: '#ef4444',
|
||||
controlHeight: 36,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -25,7 +31,65 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
direction: rtl;
|
||||
font-family: 'IRANSans', Tahoma, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif;
|
||||
background-color: #f4f6fa;
|
||||
color: #1e293b;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Modern Ant Table Global Enhancements */
|
||||
.ant-table-wrapper .ant-table {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
background: #f8fafc !important;
|
||||
color: #475569 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 13px !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
padding: 13px 16px !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr > td {
|
||||
padding: 12px 16px !important;
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
|
||||
background-color: #f8faff !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-pagination {
|
||||
margin: 16px 0 8px 0 !important;
|
||||
}
|
||||
|
||||
/* Action Dropdown Menu Modern Style */
|
||||
.action-dropdown-menu.ant-dropdown-menu {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f1f5f9;
|
||||
padding: 6px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.action-dropdown-menu .ant-dropdown-menu-item {
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-dropdown-menu .ant-dropdown-menu-item:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Ant Modal Global Viewport Safeguards */
|
||||
.ant-modal {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.ant-modal .ant-modal-content {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AuditOutlined,
|
||||
CloseCircleOutlined,
|
||||
DashboardOutlined,
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
GiftOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MessageOutlined,
|
||||
PieChartOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { computed, createVNode, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
@@ -21,6 +29,45 @@ const authStore = useAuthStore();
|
||||
|
||||
const collapsed = ref(false);
|
||||
const selectedKeys = ref<string[]>([route.name as string || 'dashboard']);
|
||||
const searchQuery = ref('');
|
||||
|
||||
interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const mainItems: NavItem[] = [
|
||||
{ key: 'dashboard', label: 'داشبورد آماری', icon: DashboardOutlined, path: '/dashboard' },
|
||||
{ key: 'campaigns', label: 'مدیریت کمپینها', icon: PieChartOutlined, path: '/campaigns' },
|
||||
{ key: 'users', label: 'مدیریت کاربران', icon: UserOutlined, path: '/users' },
|
||||
{ key: 'winners', label: 'مدیریت برندگان', icon: TrophyOutlined, path: '/winners' },
|
||||
{ key: 'spins', label: 'تاریخچه اسپینها', icon: ReloadOutlined, path: '/spins' },
|
||||
];
|
||||
|
||||
const utilityItems: NavItem[] = [
|
||||
{ key: 'sms', label: 'لاگهای پیامک', icon: MessageOutlined, path: '/sms' },
|
||||
{ key: 'reports', label: 'گزارشها و KPI', icon: LineChartOutlined, path: '/reports' },
|
||||
{ key: 'audit', label: 'لاگ بازرسی (Audit)', icon: AuditOutlined, path: '/audit' },
|
||||
];
|
||||
|
||||
const filteredMainItems = computed(() => {
|
||||
if (!searchQuery.value.trim()) return mainItems;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
return mainItems.filter(item => item.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const filteredUtilityItems = computed(() => {
|
||||
if (!searchQuery.value.trim()) return utilityItems;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
return utilityItems.filter(item => item.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function navigateTo(item: NavItem) {
|
||||
selectedKeys.value = [item.key];
|
||||
router.push(item.path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
@@ -32,9 +79,75 @@ watch(
|
||||
const adminName = computed(() => authStore.admin?.name || 'مدیر سیستم');
|
||||
const adminRole = computed(() => authStore.admin?.role || 'OPERATOR');
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
router.push('/login');
|
||||
const isProfileModalVisible = ref(false);
|
||||
const isUpdatingProfile = ref(false);
|
||||
const profileForm = reactive({
|
||||
name: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
function openProfileModal() {
|
||||
profileForm.name = authStore.admin?.name || '';
|
||||
profileForm.mobile = authStore.admin?.mobile || '';
|
||||
profileForm.email = authStore.admin?.email || '';
|
||||
profileForm.password = '';
|
||||
isProfileModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleProfileSubmit() {
|
||||
if (!profileForm.name.trim()) {
|
||||
message.error('لطفاً نام و نام خانوادگی را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!profileForm.mobile.trim() || !/^09\d{9}$/.test(profileForm.mobile.trim())) {
|
||||
message.error('شماره موبایل باید ۱۱ رقمی باشد و با ۰۹ شروع شود.');
|
||||
return;
|
||||
}
|
||||
if (!profileForm.email.trim()) {
|
||||
message.error('لطفاً ایمیل را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (profileForm.password && profileForm.password.length < 6) {
|
||||
message.error('کلمه عبور جدید باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingProfile.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
name: profileForm.name.trim(),
|
||||
mobile: profileForm.mobile.trim(),
|
||||
email: profileForm.email.trim(),
|
||||
};
|
||||
if (profileForm.password) {
|
||||
payload.password = profileForm.password;
|
||||
}
|
||||
await authStore.updateProfile(payload);
|
||||
message.success('اطلاعات کاربر با موفقیت بهروزرسانی شد.');
|
||||
isProfileModalVisible.value = false;
|
||||
} catch (error: any) {
|
||||
const errMessage = error?.response?.data?.message || 'خطا در ویرایش اطلاعات کاربر.';
|
||||
message.error(typeof errMessage === 'string' ? errMessage : 'اطلاعات وارد شده نامعتبر است.');
|
||||
} finally {
|
||||
isUpdatingProfile.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmLogout() {
|
||||
Modal.confirm({
|
||||
title: 'خروج از حساب کاربری',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: 'آیا اطمینان دارید که میخواهید از پنل مدیریت خارج شوید؟',
|
||||
okText: 'خروج',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await authStore.logout();
|
||||
router.push('/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,125 +155,677 @@ async function handleLogout() {
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
width="240"
|
||||
theme="dark"
|
||||
style="box-shadow: 2px 0 8px rgba(0,0,0,0.15); z-index: 10"
|
||||
:width="236"
|
||||
:collapsedWidth="72"
|
||||
theme="light"
|
||||
class="app-sider"
|
||||
>
|
||||
<!-- Top Logo & Toggle Bar -->
|
||||
<div class="logo-box">
|
||||
<div class="logo-icon">🎡</div>
|
||||
<h2 v-if="!collapsed" class="logo-text">گردونه شانس</h2>
|
||||
<div class="brand-wrap" @click="router.push('/dashboard')">
|
||||
<div class="logo-emblem">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="url(#brandGrad)" />
|
||||
<circle cx="12" cy="12" r="7.5" stroke="white" stroke-width="1.8" stroke-dasharray="3 2" />
|
||||
<path d="M12 4.5V12L16.5 7.5" stroke="white" stroke-width="1.8" stroke-linecap="round" />
|
||||
<circle cx="12" cy="12" r="2" fill="white" />
|
||||
<defs>
|
||||
<linearGradient id="brandGrad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7c3aed" />
|
||||
<stop offset="1" stop-color="#4f46e5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 v-if="!collapsed" class="logo-text">گردونه شانس</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!collapsed"
|
||||
type="button"
|
||||
class="sider-collapse-btn"
|
||||
title="بستن سایدبار"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<MenuFoldOutlined />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="sider-collapse-btn collapsed-toggle"
|
||||
title="باز کردن سایدبار"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<MenuUnfoldOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
|
||||
<a-menu-item key="dashboard" @click="router.push('/dashboard')">
|
||||
<template #icon><DashboardOutlined /></template>
|
||||
<span>داشبورد آماری</span>
|
||||
</a-menu-item>
|
||||
<!-- Search Box (Repliq Style) -->
|
||||
<div v-if="!collapsed" class="search-container">
|
||||
<div class="search-bar">
|
||||
<SearchOutlined class="search-bar-icon" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="جستجو در منو..."
|
||||
class="search-bar-input"
|
||||
/>
|
||||
<CloseCircleOutlined
|
||||
v-if="searchQuery"
|
||||
class="clear-search-icon"
|
||||
@click="searchQuery = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu-item key="campaigns" @click="router.push('/campaigns')">
|
||||
<template #icon><PieChartOutlined /></template>
|
||||
<span>مدیریت کمپینها</span>
|
||||
</a-menu-item>
|
||||
<!-- Navigation Menu -->
|
||||
<div class="menu-scroll-wrap">
|
||||
<nav class="sider-nav">
|
||||
<!-- Main Section -->
|
||||
<div v-if="filteredMainItems.length > 0" class="nav-group">
|
||||
<div v-if="!collapsed" class="nav-group-header">اصلی</div>
|
||||
<div class="nav-group-items">
|
||||
<a-tooltip
|
||||
v-for="item in filteredMainItems"
|
||||
:key="item.key"
|
||||
:title="collapsed ? item.label : ''"
|
||||
placement="left"
|
||||
>
|
||||
<div
|
||||
class="nav-link-item"
|
||||
:class="{ 'is-active': selectedKeys.includes(item.key) }"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-link-icon" />
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ item.label }}</span>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu-item key="users" @click="router.push('/users')">
|
||||
<template #icon><UserOutlined /></template>
|
||||
<span>مدیریت کاربران</span>
|
||||
</a-menu-item>
|
||||
<!-- Utility Section -->
|
||||
<div v-if="filteredUtilityItems.length > 0" class="nav-group nav-group-secondary">
|
||||
<div v-if="!collapsed" class="nav-group-header">سیستم و ابزارها</div>
|
||||
<div class="nav-group-items">
|
||||
<a-tooltip
|
||||
v-for="item in filteredUtilityItems"
|
||||
:key="item.key"
|
||||
:title="collapsed ? item.label : ''"
|
||||
placement="left"
|
||||
>
|
||||
<div
|
||||
class="nav-link-item"
|
||||
:class="{ 'is-active': selectedKeys.includes(item.key) }"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-link-icon" />
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ item.label }}</span>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu-item key="spins" @click="router.push('/spins')">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
<span>تاریخچه اسپینها</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="winners" @click="router.push('/winners')">
|
||||
<template #icon><TrophyOutlined /></template>
|
||||
<span>مدیریت برندگان</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="sms" @click="router.push('/sms')">
|
||||
<template #icon><MessageOutlined /></template>
|
||||
<span>لاگهای پیامک</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="reports" @click="router.push('/reports')">
|
||||
<template #icon><LineChartOutlined /></template>
|
||||
<span>گزارشها و KPI</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="audit" @click="router.push('/audit')">
|
||||
<template #icon><AuditOutlined /></template>
|
||||
<span>لاگ بازرسی (Audit)</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<!-- Empty search state -->
|
||||
<div
|
||||
v-if="!collapsed && filteredMainItems.length === 0 && filteredUtilityItems.length === 0"
|
||||
class="empty-search-text"
|
||||
>
|
||||
موردی یافت نشد
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="app-header">
|
||||
<div class="header-left">
|
||||
<span class="header-title">سامانه جامع قرعهکشی و گردونه شانس</span>
|
||||
<span class="header-title">سامانه مدیریت گردونه شانس</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space size="middle">
|
||||
<a-tag color="blue">{{ adminRole }}</a-tag>
|
||||
<span class="user-greeting">
|
||||
<UserOutlined /> {{ adminName }}
|
||||
</span>
|
||||
<a-button type="text" danger @click="handleLogout">
|
||||
<template #icon><LogoutOutlined /></template>
|
||||
خروج
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft" overlayClassName="admin-user-dropdown-popover">
|
||||
<div class="user-profile-trigger">
|
||||
<a-avatar size="small" class="user-avatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="user-info-text">
|
||||
<span class="user-name">{{ adminName }}</span>
|
||||
<span class="user-role-label">{{ adminRole === 'SUPER_ADMIN' ? 'مدیر ارشد' : 'اپراتور' }}</span>
|
||||
</div>
|
||||
<DownOutlined class="dropdown-chevron" />
|
||||
</div>
|
||||
|
||||
<template #overlay>
|
||||
<a-menu class="user-dropdown-menu">
|
||||
<!-- User Profile Card in Dropdown -->
|
||||
<div class="dropdown-user-card">
|
||||
<div class="card-avatar-wrap">
|
||||
<a-avatar :size="40" class="card-avatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="card-text">
|
||||
<div class="card-name">{{ adminName }}</div>
|
||||
<div class="card-sub">{{ authStore.admin?.mobile || authStore.admin?.email || 'حساب کاربری ادمین' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-badge-row">
|
||||
<a-tag :color="adminRole === 'SUPER_ADMIN' ? 'purple' : 'blue'" class="role-pill">
|
||||
{{ adminRole === 'SUPER_ADMIN' ? 'مدیر ارشد سامانه' : 'اپراتور سامانه' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu-divider />
|
||||
|
||||
<a-menu-item key="edit-profile" @click="openProfileModal">
|
||||
<div class="dropdown-menu-item">
|
||||
<EditOutlined class="menu-item-icon" />
|
||||
<span>ویرایش کاربر</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-divider />
|
||||
|
||||
<a-menu-item key="logout" @click="confirmLogout">
|
||||
<div class="dropdown-menu-item text-danger">
|
||||
<LogoutOutlined class="menu-item-icon" />
|
||||
<span>خروج از سامانه</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content style="margin: 20px 24px; min-height: 280px">
|
||||
<router-view />
|
||||
<a-layout-content class="app-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<!-- Edit Admin Profile Modal -->
|
||||
<a-modal
|
||||
v-model:open="isProfileModalVisible"
|
||||
title="ویرایش اطلاعات حساب کاربر"
|
||||
:confirmLoading="isUpdatingProfile"
|
||||
okText="ذخیره تغییرات"
|
||||
cancelText="انصراف"
|
||||
width="500px"
|
||||
style="top: 40px"
|
||||
@ok="handleProfileSubmit"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 16px">
|
||||
<a-form-item label="نام و نام خانوادگی" required>
|
||||
<a-input v-model:value="profileForm.name" placeholder="مثال: مدیر ارشد سامانه" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="شماره موبایل" required tooltip="شماره همراه ۱۱ رقمی مدیر">
|
||||
<a-input v-model:value="profileForm.mobile" placeholder="۰۹۱۲۰۰۰۰۰۰۰" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="آدرس ایمیل" required>
|
||||
<a-input v-model:value="profileForm.email" placeholder="admin@luckywheel.local" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="کلمه عبور جدید (اختیاری)" tooltip="در صورتی که نمیخواهید رمز را تغییر دهید، خالی بگذارید">
|
||||
<a-input-password v-model:value="profileForm.password" placeholder="حداقل ۶ کاراکتر" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نقش و سطح دسترسی" style="margin-bottom: 0">
|
||||
<a-tag :color="adminRole === 'SUPER_ADMIN' ? 'purple' : 'blue'" style="font-size: 13px; padding: 4px 12px; border-radius: 6px">
|
||||
{{ adminRole === 'SUPER_ADMIN' ? 'مدیر ارشد سامانه (SUPER_ADMIN)' : 'اپراتور سامانه (OPERATOR)' }}
|
||||
</a-tag>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo-box {
|
||||
height: 64px;
|
||||
.app-sider {
|
||||
background: #ffffff !important;
|
||||
border-inline-end: 1px solid #eaecf0 !important;
|
||||
box-shadow: none !important;
|
||||
z-index: 100;
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
overflow: hidden !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.app-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
/* Logo Box */
|
||||
.logo-box {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logo-emblem {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sider-collapse-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sider-collapse-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.collapsed-toggle {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Search Bar (Repliq style) */
|
||||
.search-container {
|
||||
padding: 12px 14px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: #ffffff;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
.search-bar-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-bar-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 12.5px;
|
||||
width: 100%;
|
||||
color: #1e293b;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-bar-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.clear-search-icon {
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.clear-search-icon:hover {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Navigation Area */
|
||||
.menu-scroll-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 6px 10px 16px;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-group-secondary {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.nav-group-header {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
padding: 6px 12px 6px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-group-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nav-link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-link-icon {
|
||||
font-size: 17px;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-link-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Hover State */
|
||||
.nav-link-item:hover:not(.is-active) {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.nav-link-item:hover:not(.is-active) .nav-link-icon {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* Active / Selected State: PRIMARY COLOR GUARANTEED */
|
||||
.nav-link-item.is-active {
|
||||
background: #4f46e5 !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: 0 3px 10px rgba(79, 70, 229, 0.28) !important;
|
||||
}
|
||||
|
||||
.nav-link-item.is-active .nav-link-icon,
|
||||
.nav-link-item.is-active .nav-link-text {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Collapsed Mode */
|
||||
.app-sider.ant-layout-sider-collapsed .nav-link-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 2px auto;
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider-collapsed .nav-group-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider-collapsed .nav-group-secondary {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-search-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #ffffff;
|
||||
padding: 0 28px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
/* User Profile Trigger Button */
|
||||
.user-profile-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-profile-trigger:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.25);
|
||||
}
|
||||
|
||||
.user-info-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-role-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dropdown-chevron {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
transition: transform 0.2s ease;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.user-profile-trigger:hover .dropdown-chevron {
|
||||
color: #4f46e5;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Dropdown Menu Styling */
|
||||
.user-dropdown-menu {
|
||||
min-width: 240px;
|
||||
padding: 8px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.dropdown-user-card {
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.card-avatar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-badge-row {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dropdown-menu-item:hover .menu-item-icon {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ef4444 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-danger .menu-item-icon {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
margin: 24px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* Route transition animation */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,9 +49,14 @@ const processQueue = (error: any, token: string | null = null) => {
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// If backend wrapped response inside { success: true, data: ... } or paginated { data: [...], meta: ... }
|
||||
// If response is a Blob (e.g. file export), return response as-is
|
||||
if (response.data instanceof Blob) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// If backend wrapped response inside { success: true, data: ..., meta: ... }
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
if (response.data.success !== undefined && response.data.data !== undefined) {
|
||||
if ('success' in response.data && 'data' in response.data) {
|
||||
return { ...response, data: response.data.data, meta: response.data.meta };
|
||||
}
|
||||
if ('data' in response.data && 'meta' in response.data) {
|
||||
|
||||
@@ -55,6 +55,18 @@ export const useAuthStore = defineStore('adminAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(payload: { name?: string; email?: string; mobile?: string; password?: string }) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.patch('/admin/auth/profile', payload);
|
||||
admin.value = response.data;
|
||||
localStorage.setItem('admin_info', JSON.stringify(response.data));
|
||||
return response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
admin,
|
||||
@@ -62,5 +74,6 @@ export const useAuthStore = defineStore('adminAuth', () => {
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,6 +47,14 @@ function showDetails(record: any) {
|
||||
isDetailsModalVisible.value = true;
|
||||
}
|
||||
|
||||
const entityTypeLabels: Record<string, string> = {
|
||||
Campaign: 'کمپین',
|
||||
Prize: 'جایزه',
|
||||
User: 'کاربر',
|
||||
Chance: 'شانس',
|
||||
UserPrize: 'وضعیت برنده',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'مدیر انجامدهنده', dataIndex: ['admin', 'name'], key: 'adminName', width: 150 },
|
||||
{ title: 'ایمیل مدیر', dataIndex: ['admin', 'email'], key: 'adminEmail', width: 180 },
|
||||
@@ -55,7 +63,7 @@ const columns = [
|
||||
{ title: 'شناسه موجودیت', dataIndex: 'entityId', key: 'entityId', ellipsis: true },
|
||||
{ title: 'آیپی', dataIndex: 'ipAddress', key: 'ipAddress', width: 130 },
|
||||
{ title: 'زمان ثبت', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
|
||||
{ title: 'جزئیات تغییرات', key: 'details', width: 120 },
|
||||
{ title: 'عملیات', key: 'details', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
function getActionColor(action: string) {
|
||||
@@ -71,95 +79,152 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-select
|
||||
v-model:value="filters.entityType"
|
||||
placeholder="فیلتر بخش (موجودیت)"
|
||||
allowClear
|
||||
style="width: 180px"
|
||||
@change="() => { filters.page = 1; loadLogs(); }"
|
||||
>
|
||||
<a-select-option value="Campaign">کمپین</a-select-option>
|
||||
<a-select-option value="Prize">جایزه</a-select-option>
|
||||
<a-select-option value="User">کاربر</a-select-option>
|
||||
<a-select-option value="Chance">شانس</a-select-option>
|
||||
<a-select-option value="UserPrize">وضعیت برنده</a-select-option>
|
||||
</a-select>
|
||||
<div class="audit-log-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">لاگ بازرسی (Audit Log)</h1>
|
||||
<p class="subtitle">ثبت امن و تغییرناپذیر تمامی عملیات مدیران در سامانه</p>
|
||||
</div>
|
||||
|
||||
<a-button @click="loadLogs">
|
||||
<a-button @click="loadLogs" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="logs"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadLogs();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'adminName'">
|
||||
{{ record.admin?.name || 'مدیر سیستم' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-tag :color="getActionColor(record.action)">
|
||||
{{ record.action }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'entityId'">
|
||||
{{ toFarsiDigits(record.entityId) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'details'">
|
||||
<a-button size="small" type="link" @click="showDetails(record)">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
مشاهده
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<a-modal
|
||||
v-model:open="isDetailsModalVisible"
|
||||
title="جزئیات لاگ بازرسی (Audit Log)"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
>
|
||||
<div v-if="currentLog">
|
||||
<p><strong>اقدام:</strong> {{ currentLog.action }} بر روی {{ currentLog.entityType }} (شناسه: {{ currentLog.entityId }})</p>
|
||||
<p><strong>توسط:</strong> {{ currentLog.admin?.name }} ({{ currentLog.admin?.email }}) | IP: {{ currentLog.ipAddress }}</p>
|
||||
|
||||
<a-divider>مقادیر قبلی (Old Values)</a-divider>
|
||||
<pre class="json-box">{{ JSON.stringify(currentLog.oldValues, null, 2) || 'ندارد' }}</pre>
|
||||
|
||||
<a-divider>مقادیر جدید (New Values)</a-divider>
|
||||
<pre class="json-box">{{ JSON.stringify(currentLog.newValues, null, 2) || 'ندارد' }}</pre>
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-select
|
||||
v-model:value="filters.entityType"
|
||||
placeholder="فیلتر بخش (موجودیت)"
|
||||
allowClear
|
||||
style="width: 200px"
|
||||
@change="() => { filters.page = 1; loadLogs(); }"
|
||||
>
|
||||
<a-select-option value="Campaign">کمپین</a-select-option>
|
||||
<a-select-option value="Prize">جایزه</a-select-option>
|
||||
<a-select-option value="User">کاربر</a-select-option>
|
||||
<a-select-option value="Chance">شانس</a-select-option>
|
||||
<a-select-option value="UserPrize">وضعیت برنده</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
|
||||
<a-table
|
||||
:dataSource="logs"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} ردیف لاگ`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadLogs();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'adminName'">
|
||||
<span class="admin-name">{{ record.admin?.name || 'مدیر سیستم' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'adminEmail'">
|
||||
<span class="text-muted">{{ record.admin?.email || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'entityType'">
|
||||
<a-tag color="geekblue">{{ entityTypeLabels[record.entityType] || record.entityType }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-tag :color="getActionColor(record.action)">
|
||||
{{ record.action }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'entityId'">
|
||||
<code class="code-badge">{{ record.entityId }}</code>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'ipAddress'">
|
||||
<span class="date-cell">{{ toFarsiDigits(record.ipAddress) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'details'">
|
||||
<a-tooltip title="مشاهده جزئیات تغییرات">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="action-more-btn"
|
||||
@click="showDetails(record)"
|
||||
>
|
||||
<EyeOutlined style="font-size: 16px; color: #4f46e5" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<a-modal
|
||||
v-model:open="isDetailsModalVisible"
|
||||
title="جزئیات لاگ بازرسی (Audit Log)"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
>
|
||||
<div v-if="currentLog">
|
||||
<p><strong>اقدام:</strong> <a-tag :color="getActionColor(currentLog.action)">{{ currentLog.action }}</a-tag> بر روی <strong>{{ entityTypeLabels[currentLog.entityType] || currentLog.entityType }}</strong> (شناسه: <code>{{ currentLog.entityId }}</code>)</p>
|
||||
<p><strong>توسط:</strong> {{ currentLog.admin?.name }} ({{ currentLog.admin?.email }}) | IP: {{ toFarsiDigits(currentLog.ipAddress) }}</p>
|
||||
|
||||
<a-divider>مقادیر قبلی (Old Values)</a-divider>
|
||||
<pre class="json-box">{{ JSON.stringify(currentLog.oldValues, null, 2) || 'ندارد' }}</pre>
|
||||
|
||||
<a-divider>مقادیر جدید (New Values)</a-divider>
|
||||
<pre class="json-box">{{ JSON.stringify(currentLog.newValues, null, 2) || 'ندارد' }}</pre>
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -172,16 +237,56 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #38bdf8;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
border: 1px solid #e9ecef;
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,9 +32,16 @@ async function onFinish() {
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-bg-decorations">
|
||||
<div class="decor-circle circle-1"></div>
|
||||
<div class="decor-circle circle-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="header">
|
||||
<div class="logo">🎡</div>
|
||||
<div class="logo-wrapper">
|
||||
<span class="logo">🎡</span>
|
||||
</div>
|
||||
<h1>ورود به پنل مدیریت</h1>
|
||||
<p>سامانه گردونه شانس و قرعهکشی</p>
|
||||
</div>
|
||||
@@ -46,7 +53,7 @@ async function onFinish() {
|
||||
:rules="[{ required: true, message: 'لطفاً ایمیل را وارد کنید' }, { type: 'email', message: 'فرمت ایمیل نامعتبر است' }]"
|
||||
>
|
||||
<a-input v-model:value="formState.email" size="large" placeholder="admin@example.com">
|
||||
<template #prefix><MailOutlined /></template>
|
||||
<template #prefix><MailOutlined style="color: #6366f1" /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
@@ -56,12 +63,12 @@ async function onFinish() {
|
||||
:rules="[{ required: true, message: 'لطفاً کلمه عبور را وارد کنید' }]"
|
||||
>
|
||||
<a-input-password v-model:value="formState.password" size="large" placeholder="••••••••">
|
||||
<template #prefix><LockOutlined /></template>
|
||||
<template #prefix><LockOutlined style="color: #6366f1" /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item style="margin-top: 24px">
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
|
||||
<a-form-item style="margin-top: 28px">
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading" class="login-submit-btn">
|
||||
ورود به پنل
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
@@ -76,39 +83,102 @@ async function onFinish() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
|
||||
padding: 16px;
|
||||
background: radial-gradient(circle at 50% 20%, #1e1b4b 0%, #0f172a 70%, #020617 100%);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg-decorations {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.decor-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #6366f1;
|
||||
top: 10%;
|
||||
left: 20%;
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: #a855f7;
|
||||
bottom: 10%;
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 0 auto 16px;
|
||||
background: #eef2ff;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #1f1f1f;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
background: #4f46e5;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.35);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-submit-btn:hover {
|
||||
background: #4338ca !important;
|
||||
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.45);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -289,7 +289,7 @@ onMounted(() => {
|
||||
</a-row>
|
||||
|
||||
<div style="margin-top: 24px; display: flex; gap: 12px">
|
||||
<a-button type="primary" html-type="submit" size="large" :loading="submitting" @click="onSubmit">
|
||||
<a-button type="primary" html-type="submit" size="large" :loading="submitting" class="submit-btn">
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد کمپین و ورود به تعریف جوایز' }}
|
||||
</a-button>
|
||||
<a-button size="large" @click="router.push('/campaigns')">انصراف</a-button>
|
||||
@@ -297,3 +297,10 @@ onMounted(() => {
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.submit-btn {
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
GiftOutlined,
|
||||
MoreOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -57,6 +58,16 @@ async function duplicateCampaign(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDuplicate(record: any) {
|
||||
Modal.confirm({
|
||||
title: 'کپیبرداری از کمپین',
|
||||
content: `آیا از کپی کردن کمپین «${record.title}» و تمام جوایز آن اطمینان دارید؟`,
|
||||
okText: 'بله، کپی کن',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => duplicateCampaign(record.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function quickSetStatus(id: string, newStatus: string) {
|
||||
try {
|
||||
await apiClient.patch(`/admin/campaigns/${id}`, { status: newStatus });
|
||||
@@ -68,6 +79,19 @@ async function quickSetStatus(id: string, newStatus: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const wheelDesignLabels: Record<string, string> = {
|
||||
CLASSIC: 'کلاسیک',
|
||||
CLASSIC_CASINO: 'کلاسیک کازینو',
|
||||
NEON: 'نئون دیجیتال',
|
||||
NEON_CYBER: 'نئون سایبر',
|
||||
MINIMAL: 'مینیمال مدرن',
|
||||
MINIMAL_FLAT: 'مینیمال فلت',
|
||||
LUXURY: 'لاکچری طلایی',
|
||||
LUXURY_GOLD: 'لاکچری طلایی',
|
||||
FESTIVAL: 'جشنوارهای شاد',
|
||||
PLAYFUL_CARTOON: 'کارتونی شاد',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'عنوان کمپین', dataIndex: 'title', key: 'title' },
|
||||
{ title: 'اسلاگ (URL)', dataIndex: 'slug', key: 'slug' },
|
||||
@@ -75,23 +99,23 @@ const columns = [
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'شروع', dataIndex: 'startAt', key: 'startAt' },
|
||||
{ title: 'پایان', dataIndex: 'endAt', key: 'endAt' },
|
||||
{ title: 'تعداد جوایز', dataIndex: ['_count', 'prizes'], key: 'prizesCount' },
|
||||
{ title: 'تعداد اسپینها', dataIndex: ['_count', 'spins'], key: 'spinsCount' },
|
||||
{ title: 'عملیات', key: 'actions', width: 250 },
|
||||
{ title: 'تعداد جوایز', key: 'prizesCount', align: 'center' as const },
|
||||
{ title: 'تعداد اسپینها', key: 'spinsCount', align: 'center' as const },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
function getStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return { color: 'green', text: 'فعال' };
|
||||
return { color: 'green', dotColor: '#10b981', text: 'فعال' };
|
||||
case 'PAUSED':
|
||||
return { color: 'orange', text: 'متوقف' };
|
||||
return { color: 'orange', dotColor: '#f59e0b', text: 'متوقف' };
|
||||
case 'FINISHED':
|
||||
return { color: 'red', text: 'پایانیافته' };
|
||||
return { color: 'red', dotColor: '#ef4444', text: 'پایانیافته' };
|
||||
case 'DRAFT':
|
||||
return { color: 'gold', text: 'پیشنویس' };
|
||||
return { color: 'gold', dotColor: '#d97706', text: 'پیشنویس' };
|
||||
default:
|
||||
return { color: 'default', text: status };
|
||||
return { color: 'default', dotColor: '#94a3b8', text: status };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,141 +125,206 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی عنوان یا اسلاگ..."
|
||||
style="width: 240px"
|
||||
@search="loadCampaigns"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 140px"
|
||||
@change="loadCampaigns"
|
||||
>
|
||||
<a-select-option value="DRAFT">پیشنویس</a-select-option>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="PAUSED">متوقف</a-select-option>
|
||||
<a-select-option value="FINISHED">پایانیافته</a-select-option>
|
||||
</a-select>
|
||||
<div class="campaign-list-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">مدیریت کمپینها</h1>
|
||||
<p class="subtitle">تعریف، پیکربندی و مدیریت رویدادهای گردونه شانس</p>
|
||||
</div>
|
||||
|
||||
<a-button type="primary" @click="router.push('/campaigns/create')">
|
||||
<a-button type="primary" size="middle" class="create-btn" @click="router.push('/campaigns/create')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ایجاد کمپین جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="campaigns"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadCampaigns();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusInfo(record.status).color">
|
||||
{{ getStatusInfo(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی عنوان یا اسلاگ..."
|
||||
style="width: 270px"
|
||||
allowClear
|
||||
@search="loadCampaigns"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 150px"
|
||||
@change="loadCampaigns"
|
||||
>
|
||||
<a-select-option value="DRAFT">پیشنویس</a-select-option>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="PAUSED">متوقف</a-select-option>
|
||||
<a-select-option value="FINISHED">پایانیافته</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="column.key === 'defaultChances'">
|
||||
{{ toFarsiDigits(record.defaultChances) }}
|
||||
</template>
|
||||
<a-table
|
||||
:dataSource="campaigns"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} کمپین`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadCampaigns();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<span class="campaign-title" @click="router.push(`/campaigns/${record.id}/edit`)">
|
||||
{{ record.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'startAt'">
|
||||
{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'slug'">
|
||||
<code class="slug-badge">{{ record.slug }}</code>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'endAt'">
|
||||
{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'wheelDesign'">
|
||||
<a-tag color="purple" style="border-radius: 6px">
|
||||
{{ wheelDesignLabels[record.wheelDesign] || record.wheelDesign }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space size="small">
|
||||
<a-tooltip title="مشاهده گردونه کاربر">
|
||||
<a-button
|
||||
size="small"
|
||||
:href="'http://localhost:5174/c/' + record.slug"
|
||||
target="_blank"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusInfo(record.status).color" class="status-tag">
|
||||
<span class="status-dot" :style="{ backgroundColor: getStatusInfo(record.status).dotColor }" />
|
||||
{{ getStatusInfo(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'startAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'endAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizesCount'">
|
||||
<span class="num-cell">{{ formatFarsiNumber(record._count?.prizes ?? (record.prizes?.length || 0)) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'spinsCount'">
|
||||
<span class="num-cell">{{ formatFarsiNumber(record._count?.spins ?? 0) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<a-button type="text" shape="circle" class="action-more-btn" title="عملیات">
|
||||
<MoreOutlined style="font-size: 18px" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip v-if="record.status === 'DRAFT'" title="فعالسازی و انتشار">
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
style="color: #52c41a; border-color: #52c41a"
|
||||
@click="quickSetStatus(record.id, 'ACTIVE')"
|
||||
>
|
||||
<template #icon><CheckCircleOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip v-else-if="record.status === 'ACTIVE'" title="توقف موقت">
|
||||
<a-button
|
||||
danger
|
||||
ghost
|
||||
size="small"
|
||||
@click="quickSetStatus(record.id, 'PAUSED')"
|
||||
>
|
||||
<template #icon><PauseCircleOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="مدیریت جوایز">
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
@click="router.push(`/campaigns/${record.id}/prizes`)"
|
||||
>
|
||||
<template #icon><GiftOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="ویرایش اطلاعات">
|
||||
<a-button size="small" @click="router.push(`/campaigns/${record.id}/edit`)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-popconfirm
|
||||
title="آیا از کپی کردن این کمپین و تمام جوایز آن اطمینان دارید؟"
|
||||
ok-text="بله، کپی کن"
|
||||
cancel-text="انصراف"
|
||||
@confirm="duplicateCampaign(record.id)"
|
||||
>
|
||||
<a-tooltip title="کپیبرداری">
|
||||
<a-button size="small">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
<template #overlay>
|
||||
<a-menu class="action-dropdown-menu">
|
||||
<a-menu-item key="view">
|
||||
<a
|
||||
:href="'http://localhost:5174/c/' + record.slug"
|
||||
target="_blank"
|
||||
class="dropdown-action-item"
|
||||
>
|
||||
<EyeOutlined class="dropdown-item-icon" />
|
||||
<span>مشاهده گردونه</span>
|
||||
</a>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit" @click="router.push(`/campaigns/${record.id}/edit`)">
|
||||
<div class="dropdown-action-item">
|
||||
<EditOutlined class="dropdown-item-icon" />
|
||||
<span>ویرایش اطلاعات</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="prizes" @click="router.push(`/campaigns/${record.id}/prizes`)">
|
||||
<div class="dropdown-action-item">
|
||||
<GiftOutlined class="dropdown-item-icon" />
|
||||
<span>مدیریت جوایز</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="duplicate" @click="confirmDuplicate(record)">
|
||||
<div class="dropdown-action-item">
|
||||
<CopyOutlined class="dropdown-item-icon" />
|
||||
<span>کپیبرداری</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<template v-if="record.status === 'DRAFT' || record.status === 'ACTIVE' || record.status === 'PAUSED'">
|
||||
<a-menu-divider />
|
||||
<a-menu-item
|
||||
v-if="record.status === 'DRAFT' || record.status === 'PAUSED'"
|
||||
key="activate"
|
||||
@click="quickSetStatus(record.id, 'ACTIVE')"
|
||||
>
|
||||
<div class="dropdown-action-item text-success">
|
||||
<CheckCircleOutlined class="dropdown-item-icon" />
|
||||
<span>فعالسازی و انتشار</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-else-if="record.status === 'ACTIVE'"
|
||||
key="pause"
|
||||
@click="quickSetStatus(record.id, 'PAUSED')"
|
||||
>
|
||||
<div class="dropdown-action-item text-warning">
|
||||
<PauseCircleOutlined class="dropdown-item-icon" />
|
||||
<span>توقف موقت</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -249,4 +338,82 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.campaign-title {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.campaign-title:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.slug-badge {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.date-cell, .num-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
color: #4f46e5 !important;
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.dropdown-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-item-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #f59e0b;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,14 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
InboxOutlined,
|
||||
MoreOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { formatFarsiNumber, parseFarsiNumber, toEnglishDigits, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -29,7 +31,6 @@ const isFormModalVisible = ref(false);
|
||||
const isStockModalVisible = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const editingPrizeId = ref<string | null>(null);
|
||||
const isNoPrize = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
@@ -58,36 +59,12 @@ const estimatedWinChance = computed(() => {
|
||||
return toFarsiDigits(pct);
|
||||
});
|
||||
|
||||
function handleNoPrizeChange(checked: boolean) {
|
||||
isNoPrize.value = checked;
|
||||
if (checked) {
|
||||
formState.type = 'NO_PRIZE';
|
||||
formState.stockType = 'UNLIMITED';
|
||||
if (!formState.title || formState.title.trim() === '') {
|
||||
formState.title = 'پوچ (دفعه بعد)';
|
||||
}
|
||||
if (!formState.description) {
|
||||
formState.description = 'بدون جایزه - شانس بعدی';
|
||||
}
|
||||
} else {
|
||||
if (formState.type === 'NO_PRIZE') {
|
||||
formState.type = 'DISCOUNT_CODE';
|
||||
}
|
||||
if (formState.title === 'پوچ (دفعه بعد)' || formState.title === 'پوچ') {
|
||||
formState.title = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypeChange(val: string) {
|
||||
if (val === 'NO_PRIZE') {
|
||||
isNoPrize.value = true;
|
||||
formState.stockType = 'UNLIMITED';
|
||||
if (!formState.title || formState.title.trim() === '') {
|
||||
formState.title = 'پوچ (دفعه بعد)';
|
||||
}
|
||||
} else {
|
||||
isNoPrize.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +139,6 @@ async function loadPrizes() {
|
||||
|
||||
function openCreateModal() {
|
||||
editingPrizeId.value = null;
|
||||
isNoPrize.value = false;
|
||||
formState.title = '';
|
||||
formState.description = '';
|
||||
formState.type = 'DISCOUNT_CODE';
|
||||
@@ -180,12 +156,11 @@ function openCreateModal() {
|
||||
|
||||
function openEditModal(record: any) {
|
||||
editingPrizeId.value = record.id;
|
||||
const isNoPrizeRecord = record.type === 'NO_PRIZE' || record.prizeType === 'NO_PRIZE' || record.title === 'پوچ' || record.title?.includes('پوچ');
|
||||
isNoPrize.value = isNoPrizeRecord;
|
||||
const pType = record.type || record.prizeType || 'DISCOUNT_CODE';
|
||||
formState.title = record.title;
|
||||
formState.description = record.description || record.subtitle || '';
|
||||
formState.type = record.type || record.prizeType || (isNoPrizeRecord ? 'NO_PRIZE' : 'DISCOUNT_CODE');
|
||||
formState.stockType = record.stockType || (isNoPrizeRecord ? 'UNLIMITED' : 'LIMITED');
|
||||
formState.type = pType;
|
||||
formState.stockType = record.stockType || (pType === 'NO_PRIZE' ? 'UNLIMITED' : 'LIMITED');
|
||||
formState.initialStock = record.initialStock ?? 100;
|
||||
formState.remainingStock = record.remainingStock ?? record.initialStock ?? 100;
|
||||
formState.weight = record.weight ?? 10;
|
||||
@@ -214,8 +189,7 @@ async function handleFormSubmit() {
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isNoPrize.value) {
|
||||
formState.type = 'NO_PRIZE';
|
||||
if (formState.type === 'NO_PRIZE') {
|
||||
formState.stockType = 'UNLIMITED';
|
||||
}
|
||||
|
||||
@@ -289,6 +263,17 @@ async function handleDelete(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeletePrize(record: any) {
|
||||
Modal.confirm({
|
||||
title: 'حذف جایزه',
|
||||
content: `آیا از حذف جایزه «${record.title}» اطمینان دارید؟ (در صورت ثبت چرخش روی این جایزه، امکان حذف وجود ندارد)`,
|
||||
okText: 'بله، حذف کن',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => handleDelete(record.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCustomUpload({ file, onSuccess, onError }: any) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -306,17 +291,16 @@ async function handleCustomUpload({ file, onSuccess, onError }: any) {
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ترتیب', dataIndex: 'sortOrder', key: 'sortOrder', width: 70 },
|
||||
{ title: 'رنگ', key: 'color', width: 80 },
|
||||
{ title: 'ترتیب', dataIndex: 'sortOrder', key: 'sortOrder', width: 70, align: 'center' as const },
|
||||
{ title: 'رنگ', key: 'color', width: 70, align: 'center' as const },
|
||||
{ title: 'عنوان جایزه', dataIndex: 'title', key: 'title' },
|
||||
{ title: 'نوع جایزه', key: 'type', width: 140 },
|
||||
{ title: 'وضعیت پوچ', key: 'isNoPrize', width: 130 },
|
||||
{ title: 'نوع موجودی', dataIndex: 'stockType', key: 'stockType' },
|
||||
{ title: 'موجودی اولیه', dataIndex: 'initialStock', key: 'initialStock' },
|
||||
{ title: 'موجودی مانده', key: 'remainingStock' },
|
||||
{ title: 'وزن / شانس', key: 'weight' },
|
||||
{ title: 'وضعیت', key: 'status', width: 90 },
|
||||
{ title: 'عملیات', key: 'actions', width: 220 },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
function getPrizeTypeTag(type: string) {
|
||||
@@ -349,7 +333,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<a-page-header
|
||||
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
|
||||
style="background: #fff; margin-bottom: 20px; border-radius: 12px; border: 1px solid #f1f5f9; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04)"
|
||||
:title="`مدیریت جوایز کمپین: ${campaign?.title || 'در حال بارگذاری...'}`"
|
||||
:sub-title="`طرح گردونه: ${campaign?.wheelDesign || '-'}`"
|
||||
@back="router.push('/campaigns')"
|
||||
@@ -465,15 +449,6 @@ onMounted(() => {
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'isNoPrize'">
|
||||
<a-tag v-if="record.type === 'NO_PRIZE' || record.title?.includes('پوچ')" color="error">
|
||||
🎭 پوچ (باخت)
|
||||
</a-tag>
|
||||
<a-tag v-else color="success">
|
||||
🎁 جایزهدار
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'stockType'">
|
||||
<a-tag :color="record.stockType === 'LIMITED' ? 'cyan' : 'blue'">
|
||||
{{ record.stockType === 'LIMITED' ? 'محدود' : 'نامحدود' }}
|
||||
@@ -513,30 +488,34 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
v-if="record.stockType === 'LIMITED'"
|
||||
size="small"
|
||||
@click="openStockModal(record)"
|
||||
>
|
||||
تنظیم موجودی
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<a-button type="text" shape="circle" class="action-more-btn" title="عملیات">
|
||||
<MoreOutlined style="font-size: 18px" />
|
||||
</a-button>
|
||||
|
||||
<a-button size="small" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm
|
||||
title="آیا از حذف این جایزه اطمینان دارید؟ (در صورت وجود اسپین مجاز نیست)"
|
||||
ok-text="بله، حذف کن"
|
||||
cancel-text="انصراف"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
<template #overlay>
|
||||
<a-menu class="action-dropdown-menu">
|
||||
<a-menu-item key="edit" @click="openEditModal(record)">
|
||||
<div class="dropdown-action-item">
|
||||
<EditOutlined class="dropdown-item-icon" />
|
||||
<span>ویرایش جایزه</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="record.stockType === 'LIMITED'" key="stock" @click="openStockModal(record)">
|
||||
<div class="dropdown-action-item">
|
||||
<InboxOutlined class="dropdown-item-icon" />
|
||||
<span>تنظیم سریع موجودی</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" @click="confirmDeletePrize(record)">
|
||||
<div class="dropdown-action-item text-danger">
|
||||
<DeleteOutlined class="dropdown-item-icon" />
|
||||
<span>حذف جایزه</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -550,41 +529,11 @@ onMounted(() => {
|
||||
okText="ذخیره"
|
||||
cancelText="انصراف"
|
||||
width="680px"
|
||||
style="top: 24px"
|
||||
:body-style="{ maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' }"
|
||||
@ok="handleFormSubmit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<!-- وضعیت پوچ (بدون جایزه) Switch Card -->
|
||||
<div
|
||||
:style="{
|
||||
background: isNoPrize ? '#fff1f0' : '#f8fafc',
|
||||
border: isNoPrize ? '1px solid #ffa39e' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.3s ease',
|
||||
}"
|
||||
>
|
||||
<div style="flex: 1; padding-left: 16px">
|
||||
<div style="font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px">
|
||||
<span>🎭 وضعیت پوچ (اسلایس بدون جایزه / باخت)</span>
|
||||
<a-tag v-if="isNoPrize" color="error">پوچ فعال است (صدای باخت)</a-tag>
|
||||
<a-tag v-else color="default">جایزهدار (برنده)</a-tag>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #64748b; margin-top: 4px; line-height: 1.6">
|
||||
اگر این تیک روشن باشد، کاربر در صورت توقف گردونه روی این اسلایس برنده جایزهای نمیشود و افکت و <strong>صدای معروف باخت (Sad Trombone)</strong> برای هیجان و طنز بیشتر پخش خواهد شد.
|
||||
</div>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="isNoPrize"
|
||||
checked-children="پوچ"
|
||||
un-checked-children="خیر"
|
||||
@change="handleNoPrizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" class="prize-modal-form">
|
||||
<!-- بخش ۱: مشخصات پایه -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
@@ -652,7 +601,7 @@ onMounted(() => {
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="formState.stockType === 'LIMITED' ? 8 : 24">
|
||||
<a-form-item label="نوع موجودی" required style="margin-bottom: 0">
|
||||
<a-select v-model:value="formState.stockType" :disabled="isNoPrize">
|
||||
<a-select v-model:value="formState.stockType" :disabled="formState.type === 'NO_PRIZE'">
|
||||
<a-select-option value="LIMITED">محدود (شمارشدار)</a-select-option>
|
||||
<a-select-option value="UNLIMITED">نامحدود</a-select-option>
|
||||
</a-select>
|
||||
@@ -729,14 +678,15 @@ onMounted(() => {
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت" style="margin-bottom: 0">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px">
|
||||
<span style="font-size: 14px; font-weight: 500; color: #1e293b">وضعیت:</span>
|
||||
<a-switch
|
||||
:checked="formState.status === 'ACTIVE'"
|
||||
checked-children="فعال"
|
||||
un-checked-children="غیرفعال"
|
||||
@change="(checked: any) => formState.status = checked ? 'ACTIVE' : 'INACTIVE'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
@@ -777,3 +727,42 @@ onMounted(() => {
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
color: #4f46e5 !important;
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.dropdown-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-item-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.prize-modal-form :deep(.ant-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -59,13 +59,14 @@ async function exportExcel() {
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `participation-report-${Date.now()}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('گزارش اکسل با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت گزارش اکسل');
|
||||
@@ -81,15 +82,32 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-card :bordered="false" style="margin-bottom: 20px">
|
||||
<div class="report-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">گزارشها و شاخصهای کلیدی (KPI)</h1>
|
||||
<p class="subtitle">تحلیل مشارکت کاربران، شانسهای تخصیصی، نرخ مصرف و خروجی آماری</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadReport" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="exporting" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل گزارش
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false" class="main-card" style="margin-bottom: 20px">
|
||||
<div class="filters-header">
|
||||
<div class="filters">
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="انتخاب کمپین (اختیاری)"
|
||||
allowClear
|
||||
style="width: 240px"
|
||||
style="width: 260px"
|
||||
@change="loadReport"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
@@ -97,110 +115,106 @@ onMounted(() => {
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-space>
|
||||
<a-button @click="loadReport">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="exporting" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل گزارش
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="تعداد کل کاربران ثبتنامی"
|
||||
:value="report?.totalUsers || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#3f8600', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><TeamOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-indigo">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">تعداد کل کاربران ثبتنامی</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.totalUsers || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="تعداد کل چرخشها (Spins)"
|
||||
:value="report?.totalSpins || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#1890ff', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><ReloadOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-sky">
|
||||
<ReloadOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">تعداد کل چرخشها (Spins)</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.totalSpins || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="تعداد کل جوایز برنده شده"
|
||||
:value="report?.winnersCount || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#52c41a', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><TrophyOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-emerald">
|
||||
<TrophyOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">تعداد کل جوایز برنده شده</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.winnersCount || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="کل شانسهای تخصیص یافته"
|
||||
:value="report?.totalChancesAllocated || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#722ed1', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><GiftOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-purple">
|
||||
<GiftOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">کل شانسهای تخصیصیافته</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.totalChancesAllocated || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="کل شانسهای مصرف شده"
|
||||
:value="report?.totalChancesUsed || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#fa8c16', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><BarChartOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-amber">
|
||||
<BarChartOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">کل شانسهای مصرفشده</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.totalChancesUsed || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="کاربران بدون چرخش (Zero Spin)"
|
||||
:value="report?.zeroSpinUsers || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#f5222d', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><TeamOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-rose">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">کاربران بدون چرخش (Zero Spin)</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.zeroSpinUsers || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="کاربران دارای شانس باقیمانده"
|
||||
:value="report?.remainingChanceUsers || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
:value-style="{ color: '#13c2c2', fontWeight: 'bold' }"
|
||||
>
|
||||
<template #prefix><TeamOutlined /></template>
|
||||
</a-statistic>
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<div class="kpi-flex">
|
||||
<div class="kpi-icon-box icon-teal">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<span class="kpi-label">کاربران دارای شانس باقیمانده</span>
|
||||
<span class="kpi-value">{{ formatFarsiNumber(report?.remainingChanceUsers || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -209,6 +223,91 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.kpi-flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kpi-icon-box {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-indigo { background: #eef2ff; color: #4f46e5; }
|
||||
.icon-sky { background: #f0f9ff; color: #0284c7; }
|
||||
.icon-emerald { background: #ecfdf5; color: #059669; }
|
||||
.icon-purple { background: #faf5ff; color: #9333ea; }
|
||||
.icon-amber { background: #fffbeb; color: #d97706; }
|
||||
.icon-rose { background: #fff1f2; color: #e11d48; }
|
||||
.icon-teal { background: #f0fdfa; color: #0d9488; }
|
||||
|
||||
.kpi-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.filters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -54,22 +54,22 @@ const columns = [
|
||||
{ title: 'متن پیامک', dataIndex: 'message', key: 'message' },
|
||||
{ title: 'سرویسدهنده', dataIndex: 'provider', key: 'provider', width: 120 },
|
||||
{ title: 'شناسه پیامک', dataIndex: 'providerMessageId', key: 'providerMessageId', width: 150 },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status', width: 110 },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status', width: 130, align: 'center' as const },
|
||||
{ title: 'خطا', dataIndex: 'errorMessage', key: 'errorMessage', ellipsis: true },
|
||||
{ title: 'زمان ثبت', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
|
||||
{ title: 'عملیات', key: 'actions', width: 120 },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
switch (status) {
|
||||
case 'SENT':
|
||||
return { text: 'ارسال شد', color: 'green' };
|
||||
return { text: 'ارسال شد', color: 'green', dotColor: '#10b981' };
|
||||
case 'QUEUED':
|
||||
return { text: 'در صف ارسال', color: 'orange' };
|
||||
return { text: 'در صف ارسال', color: 'orange', dotColor: '#f59e0b' };
|
||||
case 'FAILED':
|
||||
return { text: 'خطا در ارسال', color: 'red' };
|
||||
return { text: 'خطا در ارسال', color: 'red', dotColor: '#ef4444' };
|
||||
default:
|
||||
return { text: status, color: 'default' };
|
||||
return { text: status, color: 'default', dotColor: '#94a3b8' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,81 +79,136 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.mobile"
|
||||
placeholder="جستجوی شماره همراه..."
|
||||
style="width: 220px"
|
||||
@search="() => { filters.page = 1; loadLogs(); }"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 150px"
|
||||
@change="() => { filters.page = 1; loadLogs(); }"
|
||||
>
|
||||
<a-select-option value="SENT">ارسال شد</a-select-option>
|
||||
<a-select-option value="QUEUED">در صف ارسال</a-select-option>
|
||||
<a-select-option value="FAILED">خطا در ارسال</a-select-option>
|
||||
</a-select>
|
||||
<div class="sms-log-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">لاگهای پیامک</h1>
|
||||
<p class="subtitle">تاریخچه ارسال پیامکهای رمز یکبار مصرف (OTP) و اطلاعرسانی جوایز</p>
|
||||
</div>
|
||||
|
||||
<a-button @click="loadLogs">
|
||||
<a-button @click="loadLogs" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="logs"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadLogs();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
{{ toFarsiDigits(record.mobile) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusTag(record.status).color">
|
||||
{{ getStatusTag(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
v-if="record.status === 'FAILED'"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="resendSms(record.id)"
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.mobile"
|
||||
placeholder="جستجوی شماره همراه..."
|
||||
style="width: 240px"
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadLogs(); }"
|
||||
>
|
||||
<template #icon><RedoOutlined /></template>
|
||||
ارسال مجدد
|
||||
</a-button>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 150px"
|
||||
@change="() => { filters.page = 1; loadLogs(); }"
|
||||
>
|
||||
<a-select-option value="SENT">ارسال شد</a-select-option>
|
||||
<a-select-option value="QUEUED">در صف ارسال</a-select-option>
|
||||
<a-select-option value="FAILED">خطا در ارسال</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="logs"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} پیامک`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadLogs();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
<span class="num-cell">{{ toFarsiDigits(record.mobile) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'providerMessageId'">
|
||||
<code v-if="record.providerMessageId" class="code-badge">{{ record.providerMessageId }}</code>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusTag(record.status).color" class="status-tag">
|
||||
<span class="status-dot" :style="{ backgroundColor: getStatusTag(record.status).dotColor }" />
|
||||
{{ getStatusTag(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'errorMessage'">
|
||||
<span v-if="record.errorMessage" class="text-danger">{{ record.errorMessage }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-tooltip v-if="record.status === 'FAILED'" title="ارسال مجدد پیامک">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="action-more-btn"
|
||||
@click="resendSms(record.id)"
|
||||
>
|
||||
<RedoOutlined style="font-size: 15px; color: #4f46e5" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -165,4 +220,58 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.num-cell, .date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
import { message } from 'ant-design-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const spins = ref<any[]>([]);
|
||||
@@ -25,7 +28,7 @@ const filters = reactive({
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = res.data || [];
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
@@ -60,13 +63,14 @@ async function exportExcel() {
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `spins-${Date.now()}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('فایل اکسل با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت فایل اکسل');
|
||||
@@ -75,13 +79,22 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
const prizeTypeLabels: Record<string, string> = {
|
||||
DIGITAL_CODE: 'کد دیجیتال',
|
||||
PHYSICAL: 'کالای فیزیکی',
|
||||
DISCOUNT: 'کد تخفیف',
|
||||
CASH: 'جایزه نقدی',
|
||||
FREE_CHANCE: 'شانس مجدد',
|
||||
CUSTOM: 'سفارشی',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'userMobile' },
|
||||
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'userName' },
|
||||
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaignTitle' },
|
||||
{ title: 'جایزه / نتیجه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
|
||||
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
|
||||
{ title: 'نتیجه', dataIndex: 'isWin', key: 'isWin' },
|
||||
{ title: 'نتیجه', dataIndex: 'isWin', key: 'isWin', align: 'center' as const },
|
||||
{ title: 'کلید تکرارناپذیری (Idempotency)', dataIndex: 'idempotencyKey', key: 'idempotencyKey', ellipsis: true },
|
||||
{ title: 'آیپی کاربر', dataIndex: 'ipAddress', key: 'ipAddress' },
|
||||
{ title: 'زمان اسپین', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
@@ -94,30 +107,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی شماره همراه یا نام..."
|
||||
style="width: 240px"
|
||||
@search="() => { filters.page = 1; loadSpins(); }"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="فیلتر کمپین"
|
||||
allowClear
|
||||
style="width: 200px"
|
||||
@change="() => { filters.page = 1; loadSpins(); }"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="spin-list-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">تاریخچه چرخشها (Spins)</h1>
|
||||
<p class="subtitle">گزارش لحظهای تمام تلاشها، برندگان، کدها و آیپیهای ثبتشده</p>
|
||||
</div>
|
||||
|
||||
<a-space>
|
||||
<a-button @click="loadSpins">
|
||||
<a-button @click="loadSpins" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
@@ -128,53 +125,127 @@ onMounted(() => {
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="spins"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadSpins();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'userMobile'">
|
||||
{{ toFarsiDigits(record.user?.mobile) }}
|
||||
</template>
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی شماره همراه یا نام..."
|
||||
style="width: 260px"
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadSpins(); }"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="فیلتر کمپین"
|
||||
allowClear
|
||||
style="width: 200px"
|
||||
@change="() => { filters.page = 1; loadSpins(); }"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="column.key === 'userName'">
|
||||
{{ record.user?.name || '—' }}
|
||||
</template>
|
||||
<a-table
|
||||
:dataSource="spins"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} چرخش`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadSpins();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'userMobile'">
|
||||
<span
|
||||
class="mobile-cell user-mobile-link"
|
||||
@click="record.userId ? router.push(`/users/${record.userId}`) : null"
|
||||
>
|
||||
{{ toFarsiDigits(record.user?.mobile) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'isWin'">
|
||||
<a-tag :color="record.isWin ? 'green' : 'default'">
|
||||
{{ record.isWin ? 'برنده' : 'پوچ' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'userName'">
|
||||
<span class="user-name">{{ record.user?.name || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'idempotencyKey'">
|
||||
<code style="font-size: 11px; color: #666">{{ record.idempotencyKey }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'prizeTitle'">
|
||||
<span v-if="record.prize?.title" class="prize-text">{{ record.prize.title }}</span>
|
||||
<span v-else class="text-muted">بدون جایزه (پوچ)</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'ipAddress'">
|
||||
{{ toFarsiDigits(record.ipAddress) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'prizeType'">
|
||||
<a-tag v-if="record.prize?.prizeType">{{ prizeTypeLabels[record.prize.prizeType] || record.prize.prizeType }}</a-tag>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
<template v-else-if="column.key === 'isWin'">
|
||||
<a-tag :color="record.isWin ? 'green' : 'default'" class="status-tag">
|
||||
<span class="status-dot" :style="{ backgroundColor: record.isWin ? '#10b981' : '#94a3b8' }" />
|
||||
{{ record.isWin ? 'برنده' : 'پوچ' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'idempotencyKey'">
|
||||
<code class="code-badge">{{ record.idempotencyKey }}</code>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'ipAddress'">
|
||||
<span class="date-cell">{{ toFarsiDigits(record.ipAddress) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -188,4 +259,58 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-cell {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-mobile-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-mobile-link:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.user-name, .prize-text {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.num-cell, .date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,12 +84,29 @@ async function submitAdjustChance() {
|
||||
}
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
PENDING: { text: 'در انتظار', color: 'processing' },
|
||||
DELIVERED: { text: 'تحویل داده شده', color: 'success' },
|
||||
USED: { text: 'استفاده شده', color: 'cyan' },
|
||||
CANCELLED: { text: 'لغو شده', color: 'error' },
|
||||
EXPIRED: { text: 'منقضی شده', color: 'default' },
|
||||
};
|
||||
|
||||
const prizeTypeLabels: Record<string, string> = {
|
||||
DIGITAL_CODE: 'کد دیجیتال',
|
||||
PHYSICAL: 'کالای فیزیکی',
|
||||
DISCOUNT: 'کد تخفیف',
|
||||
CASH: 'جایزه نقدی',
|
||||
FREE_CHANCE: 'شانس مجدد',
|
||||
CUSTOM: 'سفارشی',
|
||||
};
|
||||
|
||||
const campaignColumns = [
|
||||
{ title: 'عنوان کمپین', dataIndex: ['campaign', 'title'], key: 'title' },
|
||||
{ title: 'شانس باقیمانده', dataIndex: 'remainingChances', key: 'remainingChances' },
|
||||
{ title: 'کل شانسها', dataIndex: 'totalChances', key: 'totalChances' },
|
||||
{ title: 'شانس مصرفشده', dataIndex: 'usedChances', key: 'usedChances' },
|
||||
{ title: 'عملیات', key: 'actions', width: 140 },
|
||||
{ title: 'کل شانسها', dataIndex: 'totalChances', key: 'totalChances', align: 'center' as const },
|
||||
{ title: 'شانس مصرفشده', dataIndex: 'usedChances', key: 'usedChances', align: 'center' as const },
|
||||
{ title: 'عملیات', key: 'actions', width: 110, align: 'center' as const },
|
||||
];
|
||||
|
||||
const prizeColumns = [
|
||||
@@ -115,16 +132,16 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="user-detail-page">
|
||||
<a-page-header
|
||||
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
|
||||
:title="`پروفایل کاربر: ${user?.mobile || 'در حال بارگذاری...'}`"
|
||||
class="detail-header"
|
||||
:title="`پروفایل کاربر: ${user?.mobile ? toFarsiDigits(user.mobile) : 'در حال بارگذاری...'}`"
|
||||
:sub-title="user?.name || 'بدون نام'"
|
||||
@back="router.push('/users')"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="loadUser">
|
||||
<a-button @click="loadUser" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
@@ -138,46 +155,57 @@ onMounted(() => {
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-card title="اطلاعات هویتی و حساب" :bordered="false" style="margin-bottom: 20px">
|
||||
<a-card title="اطلاعات هویتی و حساب" :bordered="false" class="section-card" style="margin-bottom: 20px">
|
||||
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2, md: 4 }">
|
||||
<a-descriptions-item label="شماره موبایل">{{ toFarsiDigits(user?.mobile) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="شماره موبایل">
|
||||
<span class="num-cell">{{ toFarsiDigits(user?.mobile) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="نام و نام خانوادگی">{{ user?.name || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت مسدودی">
|
||||
<a-tag :color="user?.isBlocked ? 'red' : 'green'">
|
||||
{{ user?.isBlocked ? 'مسدود شده' : 'فعال' }}
|
||||
<a-descriptions-item label="وضعیت حساب">
|
||||
<a-tag :color="user?.status === 'BLOCKED' || user?.isBlocked ? 'red' : 'green'">
|
||||
{{ user?.status === 'BLOCKED' || user?.isBlocked ? 'مسدود شده' : 'فعال' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ ثبتنام">
|
||||
{{ user?.createdAt ? toFarsiDigits(new Date(user.createdAt).toLocaleDateString('fa-IR')) : '—' }}
|
||||
<span class="date-cell">{{ user?.createdAt ? toFarsiDigits(new Date(user.createdAt).toLocaleDateString('fa-IR')) : '—' }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card title="موجودی شانس در کمپینها" :bordered="false" style="margin-bottom: 20px">
|
||||
<a-card title="موجودی شانس در کمپینها" :bordered="false" class="section-card" style="margin-bottom: 20px">
|
||||
<a-table
|
||||
:dataSource="user?.userCampaigns || []"
|
||||
:columns="campaignColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'remainingChances'">
|
||||
<a-badge :count="toFarsiDigits(record.remainingChances)" :overflowCount="999" :number-style="{ backgroundColor: '#52c41a' }" />
|
||||
<a-tag color="success" style="font-size: 13px; font-weight: 600">
|
||||
{{ formatFarsiNumber(record.remainingChances) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'totalChances'">
|
||||
{{ toFarsiDigits(record.totalChances) }}
|
||||
<span class="num-cell">{{ formatFarsiNumber(record.totalChances) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'usedChances'">
|
||||
{{ toFarsiDigits(record.usedChances) }}
|
||||
<span class="num-cell">{{ formatFarsiNumber(record.usedChances) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button size="small" type="primary" ghost @click="openChanceModal(record.campaignId)">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="action-text-btn"
|
||||
@click="openChanceModal(record.campaignId)"
|
||||
>
|
||||
<template #icon><PlusCircleOutlined style="color: #4f46e5" /></template>
|
||||
تغییر شانس
|
||||
</a-button>
|
||||
</template>
|
||||
@@ -185,27 +213,33 @@ onMounted(() => {
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-tabs defaultActiveKey="prizes" type="card" style="background: #fff; padding: 16px; border-radius: 8px">
|
||||
<a-tabs defaultActiveKey="prizes" type="card" class="detail-tabs">
|
||||
<a-tab-pane key="prizes" tab="جوایز دریافتشده">
|
||||
<a-table
|
||||
:dataSource="user?.prizes || []"
|
||||
:columns="prizeColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'code'">
|
||||
<code v-if="record.code" style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px">
|
||||
{{ toFarsiDigits(record.code) }}
|
||||
<template v-if="column.key === 'prizeType'">
|
||||
<a-tag>{{ prizeTypeLabels[record.prize?.prizeType] || record.prize?.prizeType || '—' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'code'">
|
||||
<code v-if="record.code" class="code-badge">
|
||||
{{ record.code }}
|
||||
</code>
|
||||
<span v-else>—</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag color="blue">{{ record.status }}</a-tag>
|
||||
<a-tag :color="statusMap[record.status]?.color || 'blue'">
|
||||
{{ statusMap[record.status]?.text || record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -217,6 +251,7 @@ onMounted(() => {
|
||||
:columns="spinColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -226,7 +261,7 @@ onMounted(() => {
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -270,3 +305,58 @@ onMounted(() => {
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header {
|
||||
background: #fff;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.num-cell, .date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-text-btn {
|
||||
color: #4f46e5 !important;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-text-btn:hover {
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
CloseCircleOutlined,
|
||||
EyeOutlined,
|
||||
LockOutlined,
|
||||
MoreOutlined,
|
||||
PlusCircleOutlined,
|
||||
SearchOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -82,6 +83,20 @@ async function toggleBlock(record: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmToggleBlock(record: any) {
|
||||
const isBlocked = record.status === 'BLOCKED';
|
||||
Modal.confirm({
|
||||
title: isBlocked ? 'رفع مسدودی کاربر' : 'مسدودسازی کاربر',
|
||||
content: isBlocked
|
||||
? `آیا از رفع مسدودی کاربر ${toFarsiDigits(record.mobile)} اطمینان دارید؟`
|
||||
: `آیا از مسدود کردن کاربر ${toFarsiDigits(record.mobile)} اطمینان دارید؟ دسترسی کاربر به چرخش گردونه قطع خواهد شد.`,
|
||||
okText: isBlocked ? 'تأیید رفع مسدودی' : 'مسدود کن',
|
||||
okType: isBlocked ? 'primary' : 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: () => toggleBlock(record),
|
||||
});
|
||||
}
|
||||
|
||||
function openAdjustChance(record: any) {
|
||||
chanceForm.userId = record.id;
|
||||
chanceForm.userMobile = record.mobile;
|
||||
@@ -126,10 +141,10 @@ const columns = [
|
||||
{ title: 'نام کاربر', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'تاریخ ثبتنام', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: 'آخرین ورود', dataIndex: 'lastLoginAt', key: 'lastLoginAt' },
|
||||
{ title: 'تعداد اسپین', dataIndex: ['_count', 'spins'], key: 'spinsCount' },
|
||||
{ title: 'تعداد جوایز برنده', key: 'prizesCount' },
|
||||
{ title: 'وضعیت حساب', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'عملیات', key: 'actions', width: 220 },
|
||||
{ title: 'تعداد اسپین', dataIndex: ['_count', 'spins'], key: 'spinsCount', align: 'center' as const },
|
||||
{ title: 'تعداد جوایز برنده', key: 'prizesCount', align: 'center' as const },
|
||||
{ title: 'وضعیت حساب', dataIndex: 'status', key: 'status', align: 'center' as const },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
@@ -139,155 +154,198 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی موبایل یا نام..."
|
||||
style="width: 240px"
|
||||
@search="() => { filters.page = 1; loadUsers(); }"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="وضعیت حساب"
|
||||
allowClear
|
||||
style="width: 140px"
|
||||
@change="() => { filters.page = 1; loadUsers(); }"
|
||||
>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="BLOCKED">مسدود</a-select-option>
|
||||
</a-select>
|
||||
<div class="user-list-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">مدیریت کاربران</h1>
|
||||
<p class="subtitle">مشاهده لیست کاربران، وضعیت حساب و تنظیم دستی شانسها</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="users"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadUsers();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
{{ toFarsiDigits(record.mobile) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'name'">
|
||||
{{ record.name || '—' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastLoginAt'">
|
||||
{{ record.lastLoginAt ? toFarsiDigits(new Date(record.lastLoginAt).toLocaleDateString('fa-IR')) : '—' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizesCount'">
|
||||
{{ toFarsiDigits(record._count?.userPrizes ?? record._count?.prizes ?? 0) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'BLOCKED' ? 'red' : 'green'">
|
||||
{{ record.status === 'BLOCKED' ? 'مسدود' : 'فعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space size="small">
|
||||
<a-tooltip title="مشاهده پروفایل و جزییات">
|
||||
<a-button
|
||||
size="small"
|
||||
@click="router.push(`/users/${record.id}`)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="تنظیم شانس">
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="openAdjustChance(record)"
|
||||
>
|
||||
<template #icon><PlusCircleOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-popconfirm
|
||||
:title="record.status === 'BLOCKED' ? 'رفع مسدودی کاربر؟' : 'آیا از مسدود کردن این کاربر اطمینان دارید؟'"
|
||||
ok-text="تأیید"
|
||||
cancel-text="انصراف"
|
||||
@confirm="toggleBlock(record)"
|
||||
>
|
||||
<a-tooltip :title="record.status === 'BLOCKED' ? 'رفع مسدودی' : 'مسدودسازی'">
|
||||
<a-button size="small" :danger="record.status !== 'BLOCKED'">
|
||||
<template #icon>
|
||||
<UnlockOutlined v-if="record.status === 'BLOCKED'" />
|
||||
<LockOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Adjust Chance Modal -->
|
||||
<a-modal
|
||||
v-model:open="isChanceModalVisible"
|
||||
title="افزایش / کاهش شانس کاربر"
|
||||
:confirmLoading="isSubmittingChance"
|
||||
okText="ثبت تغییر شانس"
|
||||
cancelText="انصراف"
|
||||
@ok="submitAdjustChance"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-alert
|
||||
:message="`کاربر: ${chanceForm.userMobile}`"
|
||||
description="تغییر شانس مستقیماً در دفترکل تراکنشهای شانس (Ledger) و اسنپشات کاربر با لاگ بازرسی ثبت میگردد."
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-form-item label="کمپین مورد نظر" required>
|
||||
<a-select v-model:value="chanceForm.campaignId" placeholder="انتخاب کمپین">
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی موبایل یا نام..."
|
||||
style="width: 270px"
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadUsers(); }"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="وضعیت حساب"
|
||||
allowClear
|
||||
style="width: 150px"
|
||||
@change="() => { filters.page = 1; loadUsers(); }"
|
||||
>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="BLOCKED">مسدود</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form-item
|
||||
label="مقدار تغییر (+ برای افزایش، - برای کسر)"
|
||||
required
|
||||
tooltip="مثال: ۱ برای اهدای یک شانس، ۲- برای کسر دو شانس"
|
||||
>
|
||||
<a-input-number v-model:value="chanceForm.amount" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-table
|
||||
:dataSource="users"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} کاربر`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadUsers();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
<span class="user-mobile-link" @click="router.push(`/users/${record.id}`)">
|
||||
{{ toFarsiDigits(record.mobile) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-form-item label="دلیل / بابت" required>
|
||||
<a-input v-model:value="chanceForm.reason" placeholder="مثال: خرید از فروشگاه / هدیه پشتیبانی" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
<span class="user-name">{{ record.name || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastLoginAt'">
|
||||
<span class="date-cell">{{ record.lastLoginAt ? toFarsiDigits(new Date(record.lastLoginAt).toLocaleDateString('fa-IR')) : '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'spinsCount'">
|
||||
<span class="num-cell">{{ formatFarsiNumber(record._count?.spins ?? 0) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizesCount'">
|
||||
<span class="num-cell">{{ formatFarsiNumber(record._count?.userPrizes ?? record._count?.prizes ?? 0) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'BLOCKED' ? 'red' : 'green'" class="status-tag">
|
||||
<span
|
||||
class="status-dot"
|
||||
:style="{ backgroundColor: record.status === 'BLOCKED' ? '#ef4444' : '#10b981' }"
|
||||
/>
|
||||
{{ record.status === 'BLOCKED' ? 'مسدود' : 'فعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<a-button type="text" shape="circle" class="action-more-btn" title="عملیات">
|
||||
<MoreOutlined style="font-size: 18px" />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu class="action-dropdown-menu">
|
||||
<a-menu-item key="profile" @click="router.push(`/users/${record.id}`)">
|
||||
<div class="dropdown-action-item">
|
||||
<EyeOutlined class="dropdown-item-icon" />
|
||||
<span>مشاهده پروفایل و جزییات</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="chance" @click="openAdjustChance(record)">
|
||||
<div class="dropdown-action-item">
|
||||
<PlusCircleOutlined class="dropdown-item-icon" />
|
||||
<span>تنظیم دستی شانس</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="block" @click="confirmToggleBlock(record)">
|
||||
<div v-if="record.status === 'BLOCKED'" class="dropdown-action-item text-success">
|
||||
<UnlockOutlined class="dropdown-item-icon" />
|
||||
<span>رفع مسدودی حساب</span>
|
||||
</div>
|
||||
<div v-else class="dropdown-action-item text-danger">
|
||||
<LockOutlined class="dropdown-item-icon" />
|
||||
<span>مسدودسازی کاربر</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Adjust Chance Modal -->
|
||||
<a-modal
|
||||
v-model:open="isChanceModalVisible"
|
||||
title="افزایش / کاهش شانس کاربر"
|
||||
:confirmLoading="isSubmittingChance"
|
||||
okText="ثبت تغییر شانس"
|
||||
cancelText="انصراف"
|
||||
@ok="submitAdjustChance"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-alert
|
||||
:message="`کاربر: ${toFarsiDigits(chanceForm.userMobile)}`"
|
||||
description="تغییر شانس مستقیماً در دفترکل تراکنشهای شانس (Ledger) و اسنپشات کاربر با لاگ بازرسی ثبت میگردد."
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px; border-radius: 8px"
|
||||
/>
|
||||
|
||||
<a-form-item label="کمپین مورد نظر" required>
|
||||
<a-select v-model:value="chanceForm.campaignId" placeholder="انتخاب کمپین">
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="مقدار تغییر (+ برای افزایش، - برای کسر)"
|
||||
required
|
||||
tooltip="مثال: ۱ برای اهدای یک شانس، ۲- برای کسر دو شانس"
|
||||
>
|
||||
<a-input-number v-model:value="chanceForm.amount" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="دلیل / بابت" required>
|
||||
<a-input v-model:value="chanceForm.reason" placeholder="مثال: خرید از فروشگاه / هدیه پشتیبانی" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -299,4 +357,77 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-mobile-link {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-mobile-link:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.num-cell, .date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
color: #4f46e5 !important;
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.dropdown-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-item-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const winners = ref<any[]>([]);
|
||||
@@ -38,7 +44,7 @@ const statusForm = reactive({
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = res.data || [];
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
@@ -66,10 +72,10 @@ async function loadWinners() {
|
||||
|
||||
function openStatusModal(record: any) {
|
||||
statusForm.id = record.id;
|
||||
statusForm.winnerMobile = record.user?.mobile;
|
||||
statusForm.prizeTitle = record.prize?.title;
|
||||
statusForm.status = record.status;
|
||||
statusForm.note = '';
|
||||
statusForm.winnerMobile = record.user?.mobile || '';
|
||||
statusForm.prizeTitle = record.prize?.title || '';
|
||||
statusForm.status = record.status || 'CONTACTED';
|
||||
statusForm.note = record.note || '';
|
||||
isStatusModalVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -101,13 +107,14 @@ async function exportExcel() {
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `winners-${Date.now()}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('فایل اکسل برندگان با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت فایل اکسل برندگان');
|
||||
@@ -116,6 +123,20 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success(`${label} با موفقیت کپی شد.`);
|
||||
}
|
||||
|
||||
const prizeTypeLabels: Record<string, string> = {
|
||||
DIGITAL_CODE: 'کد دیجیتال',
|
||||
PHYSICAL: 'کالای فیزیکی',
|
||||
DISCOUNT: 'کد تخفیف',
|
||||
CASH: 'جایزه نقدی',
|
||||
FREE_CHANCE: 'شانس مجدد',
|
||||
CUSTOM: 'سفارشی',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'mobile' },
|
||||
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'name' },
|
||||
@@ -125,25 +146,25 @@ const columns = [
|
||||
{ title: 'کد دیجیتال / پیگیری', dataIndex: 'code', key: 'code' },
|
||||
{ title: 'وضعیت پیگیری', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'تاریخ برنده شدن', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: 'عملیات', key: 'actions', width: 100 },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
switch (status) {
|
||||
case 'NEW':
|
||||
return { text: 'جدید', color: 'blue' };
|
||||
return { text: 'جدید', color: 'blue', dotColor: '#3b82f6' };
|
||||
case 'CONTACTED':
|
||||
return { text: 'تماس گرفته شده', color: 'orange' };
|
||||
return { text: 'تماس گرفته شده', color: 'orange', dotColor: '#f59e0b' };
|
||||
case 'DELIVERED':
|
||||
return { text: 'تحویل داده شده', color: 'green' };
|
||||
return { text: 'تحویل داده شده', color: 'green', dotColor: '#10b981' };
|
||||
case 'ACTIVE_CODE':
|
||||
return { text: 'کد فعال', color: 'cyan' };
|
||||
return { text: 'کد فعال', color: 'cyan', dotColor: '#06b6d4' };
|
||||
case 'USED':
|
||||
return { text: 'مصرف شده', color: 'purple' };
|
||||
return { text: 'مصرف شده', color: 'purple', dotColor: '#8b5cf6' };
|
||||
case 'EXPIRED':
|
||||
return { text: 'منقضی شده', color: 'red' };
|
||||
return { text: 'منقضی شده', color: 'red', dotColor: '#ef4444' };
|
||||
default:
|
||||
return { text: status, color: 'default' };
|
||||
return { text: status, color: 'default', dotColor: '#94a3b8' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,44 +175,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی موبایل یا نام..."
|
||||
style="width: 220px"
|
||||
@search="() => { filters.page = 1; loadWinners(); }"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="فیلتر کمپین"
|
||||
allowClear
|
||||
style="width: 180px"
|
||||
@change="() => { filters.page = 1; loadWinners(); }"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 160px"
|
||||
@change="() => { filters.page = 1; loadWinners(); }"
|
||||
>
|
||||
<a-select-option value="NEW">جدید</a-select-option>
|
||||
<a-select-option value="CONTACTED">تماس گرفته شده</a-select-option>
|
||||
<a-select-option value="DELIVERED">تحویل شده</a-select-option>
|
||||
<a-select-option value="ACTIVE_CODE">کد فعال</a-select-option>
|
||||
<a-select-option value="USED">مصرف شده</a-select-option>
|
||||
<a-select-option value="EXPIRED">منقضی شده</a-select-option>
|
||||
</a-select>
|
||||
<div class="winner-list-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">مدیریت برندگان</h1>
|
||||
<p class="subtitle">پیگیری، تأیید و تغییر وضعیت اهدای جوایز به برندگان</p>
|
||||
</div>
|
||||
|
||||
<a-space>
|
||||
<a-button @click="loadWinners">
|
||||
<a-button @click="loadWinners" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
@@ -202,55 +193,151 @@ onMounted(() => {
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="winners"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadWinners();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
{{ toFarsiDigits(record.user?.mobile) }}
|
||||
</template>
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی موبایل یا نام..."
|
||||
style="width: 240px"
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadWinners(); }"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="فیلتر کمپین"
|
||||
allowClear
|
||||
style="width: 180px"
|
||||
@change="() => { filters.page = 1; loadWinners(); }"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="فیلتر وضعیت"
|
||||
allowClear
|
||||
style="width: 160px"
|
||||
@change="() => { filters.page = 1; loadWinners(); }"
|
||||
>
|
||||
<a-select-option value="NEW">جدید</a-select-option>
|
||||
<a-select-option value="CONTACTED">تماس گرفته شده</a-select-option>
|
||||
<a-select-option value="DELIVERED">تحویل شده</a-select-option>
|
||||
<a-select-option value="ACTIVE_CODE">کد فعال</a-select-option>
|
||||
<a-select-option value="USED">مصرف شده</a-select-option>
|
||||
<a-select-option value="EXPIRED">منقضی شده</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="column.key === 'name'">
|
||||
{{ record.user?.name || '—' }}
|
||||
</template>
|
||||
<a-table
|
||||
:dataSource="winners"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} برنده`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadWinners();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
<span
|
||||
class="mobile-cell user-link"
|
||||
@click="record.userId ? router.push(`/users/${record.userId}`) : null"
|
||||
>
|
||||
{{ toFarsiDigits(record.user?.mobile) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'code'">
|
||||
<code v-if="record.code" style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-weight: 600">
|
||||
{{ toFarsiDigits(record.code) }}
|
||||
</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
<span class="user-name">{{ record.user?.name || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusTag(record.status).color">
|
||||
{{ getStatusTag(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'campaign'">
|
||||
<span class="campaign-cell">{{ record.campaign?.title || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'prizeTitle'">
|
||||
<span class="prize-text">{{ record.prize?.title }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button size="small" type="link" @click="openStatusModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
تغییر وضعیت
|
||||
</a-button>
|
||||
<template v-else-if="column.key === 'prizeType'">
|
||||
<a-tag>{{ prizeTypeLabels[record.prize?.prizeType] || record.prize?.prizeType || '—' }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'code'">
|
||||
<code
|
||||
v-if="record.code"
|
||||
class="code-badge clickable-code"
|
||||
title="برای کپی کلیک کنید"
|
||||
@click="copyToClipboard(record.code, 'کد جایزه')"
|
||||
>
|
||||
{{ record.code }}
|
||||
</code>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusTag(record.status).color" class="status-tag">
|
||||
<span class="status-dot" :style="{ backgroundColor: getStatusTag(record.status).dotColor }" />
|
||||
{{ getStatusTag(record.status).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<a-button type="text" shape="circle" class="action-more-btn" title="عملیات">
|
||||
<MoreOutlined style="font-size: 18px" />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu class="action-dropdown-menu">
|
||||
<a-menu-item key="status" @click="openStatusModal(record)">
|
||||
<div class="dropdown-action-item">
|
||||
<EditOutlined class="dropdown-item-icon" />
|
||||
<span>تغییر وضعیت پیگیری</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.userId || record.user?.id"
|
||||
key="user"
|
||||
@click="router.push(`/users/${record.userId || record.user?.id}`)"
|
||||
>
|
||||
<div class="dropdown-action-item">
|
||||
<UserOutlined class="dropdown-item-icon" />
|
||||
<span>پروفایل کاربر</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.code"
|
||||
key="copy"
|
||||
@click="copyToClipboard(record.code, 'کد جایزه')"
|
||||
>
|
||||
<div class="dropdown-action-item">
|
||||
<CopyOutlined class="dropdown-item-icon" />
|
||||
<span>کپی کد دیجیتال</span>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-table>
|
||||
|
||||
<!-- Update Status Modal -->
|
||||
<a-modal
|
||||
@@ -286,9 +373,39 @@ onMounted(() => {
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header-title .title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.page-header-title .subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -302,4 +419,106 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-cell {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.user-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.campaign-cell {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.prize-text {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.clickable-code {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.clickable-code:hover {
|
||||
background: #eef2ff;
|
||||
border-color: #c7d2fe;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-more-btn:hover {
|
||||
color: #4f46e5 !important;
|
||||
background-color: #eef2ff !important;
|
||||
}
|
||||
|
||||
.dropdown-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-item-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @lucky-wheel/api build
|
||||
RUN pnpm deploy --filter @lucky-wheel/api --prod /prod/api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /prod/api .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/src/main"]
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||
import { AdminRefreshDto } from './dto/admin-refresh.dto';
|
||||
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
|
||||
|
||||
@ApiTags('Admin Auth (مدیریت)')
|
||||
@Controller('admin/auth')
|
||||
@@ -30,6 +31,15 @@ export class AdminAuthController {
|
||||
return this.adminAuthService.refreshToken(dto);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
@ApiOperation({ summary: 'ویرایش اطلاعات حساب کاربری مدیر جاری' })
|
||||
async updateProfile(@Req() req: any, @Body() dto: UpdateAdminProfileDto) {
|
||||
return this.adminAuthService.updateProfile(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
@@ -11,6 +12,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||
import { AdminRefreshDto } from './dto/admin-refresh.dto';
|
||||
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthService implements OnModuleInit {
|
||||
@@ -152,4 +154,38 @@ export class AdminAuthService implements OnModuleInit {
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async updateProfile(adminId: string, dto: UpdateAdminProfileDto) {
|
||||
const dataToUpdate: any = {};
|
||||
if (dto.name) dataToUpdate.name = dto.name.trim();
|
||||
if (dto.mobile) dataToUpdate.mobile = dto.mobile.trim();
|
||||
if (dto.email) {
|
||||
const emailLower = dto.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.admin.findFirst({
|
||||
where: { email: emailLower, id: { not: adminId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('این ایمیل قبلاً توسط مدیر دیگری ثبت شده است.');
|
||||
}
|
||||
dataToUpdate.email = emailLower;
|
||||
}
|
||||
if (dto.password) {
|
||||
dataToUpdate.passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.admin.update({
|
||||
where: { id: adminId },
|
||||
data: dataToUpdate,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
role: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateAdminProfileDto {
|
||||
@ApiPropertyOptional({ description: 'نام و نام خانوادگی مدیر' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 50, { message: 'نام باید بین ۲ تا ۵۰ کاراکتر باشد.' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'شماره موبایل مدیر' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقم و با ۰۹ شروع شود.' })
|
||||
mobile?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ایمیل مدیر' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'کلمه عبور جدید (اختیاری)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 50, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' })
|
||||
password?: string;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export class ReportService {
|
||||
totalDistributedPrizes,
|
||||
consumedChancesAggr,
|
||||
activePrizesCount,
|
||||
activeCampaignsCount,
|
||||
activeCampaignsList,
|
||||
recentWinners,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
@@ -37,13 +39,35 @@ export class ReportService {
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.prize.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.campaign.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.campaign.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
take: 4,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
wheelDesign: true,
|
||||
_count: {
|
||||
select: {
|
||||
spins: true,
|
||||
userPrizes: true,
|
||||
prizes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.userPrize.findMany({
|
||||
take: 10,
|
||||
orderBy: { wonAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { mobile: true, name: true } },
|
||||
user: { select: { id: true, mobile: true, name: true } },
|
||||
prize: { select: { title: true, type: true, image: true } },
|
||||
campaign: { select: { title: true } },
|
||||
campaign: { select: { id: true, title: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -102,6 +126,8 @@ export class ReportService {
|
||||
percentage: totalWon > 0 ? Number(((p._count.id / totalWon) * 100).toFixed(1)) : 0,
|
||||
}));
|
||||
|
||||
const winRate = totalSpins > 0 ? Number(((totalWinners / totalSpins) * 100).toFixed(1)) : 0;
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalUsers,
|
||||
@@ -112,7 +138,10 @@ export class ReportService {
|
||||
totalDistributedPrizes,
|
||||
consumedChances: Math.abs(consumedChancesAggr._sum.amount || 0),
|
||||
activePrizesCount,
|
||||
activeCampaignsCount,
|
||||
winRate,
|
||||
},
|
||||
activeCampaigns: activeCampaignsList,
|
||||
spinsByDay,
|
||||
prizeDistribution,
|
||||
recentWinners,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @lucky-wheel/client build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/apps/client/dist /usr/share/nginx/html
|
||||
|
||||
# Configure Nginx for Single Page Application
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name localhost; \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html index.htm; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -30,3 +30,48 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: lucky_wheel_api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgrespassword@postgres:5432/lucky_wheel?schema=public
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/admin/Dockerfile
|
||||
container_name: lucky_wheel_admin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/client/Dockerfile
|
||||
container_name: lucky_wheel_client
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
redis_data:
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
*/
|
||||
const FARSI_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||
|
||||
export function toFarsiDigits(val: number | string | null | undefined): string {
|
||||
export function toFarsiDigits(val: number | string | null | undefined | any): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'object' && val !== null && 'value' in val) {
|
||||
val = val.value;
|
||||
if (val === null || val === undefined) return '';
|
||||
}
|
||||
if (typeof val === 'object') return '';
|
||||
return String(val)
|
||||
.replace(/[0-9]/g, (d) => FARSI_DIGITS[+d])
|
||||
.replace(/[٠-٩]/g, (d) => FARSI_DIGITS[d.charCodeAt(0) - 1632]);
|
||||
@@ -13,8 +18,13 @@ export function toFarsiDigits(val: number | string | null | undefined): string {
|
||||
/**
|
||||
* قالببندی اعداد با جداکننده هزارگان و ارقام فارسی
|
||||
*/
|
||||
export function formatFarsiNumber(val: number | string | null | undefined): string {
|
||||
export function formatFarsiNumber(val: number | string | null | undefined | any): string {
|
||||
if (val === null || val === undefined || val === '') return '';
|
||||
if (typeof val === 'object' && val !== null && 'value' in val) {
|
||||
val = val.value;
|
||||
if (val === null || val === undefined || val === '') return '';
|
||||
}
|
||||
if (typeof val === 'object') return '';
|
||||
const num = typeof val === 'string' ? parseFloat(toEnglishDigits(val)) : val;
|
||||
if (isNaN(num)) return toFarsiDigits(val);
|
||||
const formatted = new Intl.NumberFormat('fa-IR').format(num);
|
||||
|
||||
@@ -185,6 +185,8 @@ export interface DashboardStatsDto {
|
||||
totalDistributedPrizes: number;
|
||||
consumedChances: number;
|
||||
activePrizesCount: number;
|
||||
activeCampaignsCount?: number;
|
||||
winRate?: number;
|
||||
}
|
||||
|
||||
export interface SpinsByDayDataPoint {
|
||||
|
||||
Reference in New Issue
Block a user