first commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import faIR from 'ant-design-vue/es/locale/fa_IR';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/fa';
|
||||
|
||||
dayjs.locale('fa');
|
||||
|
||||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
borderRadius: 6,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider direction="rtl" :locale="faIR" :theme="theme">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
direction: rtl;
|
||||
font-family: 'IRANSans', Tahoma, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
/* IRANSans FaNum (Farsi Numerals) */
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_UltraLight.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Light.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Light.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum).woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Medium.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Black.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Black.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Alias for IRANSansWeb(FaNum) */
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb(FaNum)';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum).woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb(FaNum)';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
|
||||
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Universal Persian Font and Numbers Enforcement in Admin */
|
||||
*, *::before, *::after,
|
||||
body, button, input, select, textarea,
|
||||
.ant-typography, .ant-btn, .ant-input, .ant-table, .ant-statistic,
|
||||
.ant-statistic-content-value, .ant-statistic-content-value-int, .ant-statistic-content-value-decimal,
|
||||
.ant-pagination, .ant-pagination-item, .ant-pagination-total-text,
|
||||
.ant-badge, .ant-tag, .ant-card, .ant-modal, .ant-drawer,
|
||||
.ant-form-item-label, .ant-select, .ant-picker, .ant-dropdown {
|
||||
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
|
||||
font-variant-numeric: normal !important;
|
||||
}
|
||||
|
||||
.ant-table-cell, .ant-statistic-content, .ant-statistic-title {
|
||||
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
|
||||
font-variant-numeric: normal !important;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AuditOutlined,
|
||||
DashboardOutlined,
|
||||
GiftOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
PieChartOutlined,
|
||||
ReloadOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const collapsed = ref(false);
|
||||
const selectedKeys = ref<string[]>([route.name as string || 'dashboard']);
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
(val) => {
|
||||
if (val) selectedKeys.value = [val as string];
|
||||
},
|
||||
);
|
||||
|
||||
const adminName = computed(() => authStore.admin?.name || 'مدیر سیستم');
|
||||
const adminRole = computed(() => authStore.admin?.role || 'OPERATOR');
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
collapsible
|
||||
width="240"
|
||||
theme="dark"
|
||||
style="box-shadow: 2px 0 8px rgba(0,0,0,0.15); z-index: 10"
|
||||
>
|
||||
<div class="logo-box">
|
||||
<div class="logo-icon">🎡</div>
|
||||
<h2 v-if="!collapsed" class="logo-text">گردونه شانس</h2>
|
||||
</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>
|
||||
|
||||
<a-menu-item key="campaigns" @click="router.push('/campaigns')">
|
||||
<template #icon><PieChartOutlined /></template>
|
||||
<span>مدیریت کمپینها</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="users" @click="router.push('/users')">
|
||||
<template #icon><UserOutlined /></template>
|
||||
<span>مدیریت کاربران</span>
|
||||
</a-menu-item>
|
||||
|
||||
<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>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="app-header">
|
||||
<div class="header-left">
|
||||
<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>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content style="margin: 20px 24px; min-height: 280px">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo-box {
|
||||
height: 64px;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Antd from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import './assets/fonts/fonts.css';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(Antd);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import AdminLayout from '../layouts/AdminLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/auth/LoginView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/dashboard/DashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns',
|
||||
name: 'campaigns',
|
||||
component: () => import('../views/campaigns/CampaignListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/create',
|
||||
name: 'campaign-create',
|
||||
component: () => import('../views/campaigns/CampaignFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/:id/edit',
|
||||
name: 'campaign-edit',
|
||||
component: () => import('../views/campaigns/CampaignFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/:campaignId/prizes',
|
||||
name: 'campaign-prizes',
|
||||
component: () => import('../views/prizes/PrizeListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: () => import('../views/users/UserListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users/:id',
|
||||
name: 'user-detail',
|
||||
component: () => import('../views/users/UserDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'spins',
|
||||
name: 'spins',
|
||||
component: () => import('../views/spins/SpinListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'winners',
|
||||
name: 'winners',
|
||||
component: () => import('../views/winners/WinnerListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'sms',
|
||||
component: () => import('../views/sms/SmsLogListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../views/reports/ReportView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'audit',
|
||||
component: () => import('../views/audit/AuditLogView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const token = localStorage.getItem('admin_access_token');
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login');
|
||||
} else if (to.meta.guestOnly && token) {
|
||||
next('/dashboard');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { message } from 'ant-design-vue';
|
||||
import axios from 'axios';
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosResponse<T = any> {
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('admin_access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// If backend wrapped response inside { success: true, data: ... } or paginated { data: [...], meta: ... }
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
if (response.data.success !== undefined && response.data.data !== undefined) {
|
||||
return { ...response, data: response.data.data, meta: response.data.meta };
|
||||
}
|
||||
if ('data' in response.data && 'meta' in response.data) {
|
||||
return { ...response, data: response.data.data, meta: response.data.meta };
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (originalRequest.url?.includes('/admin/auth/login') || originalRequest.url?.includes('/admin/auth/refresh')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return apiClient(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token');
|
||||
if (!refreshToken) {
|
||||
localStorage.removeItem('admin_access_token');
|
||||
localStorage.removeItem('admin_info');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post('/api/admin/auth/refresh', { refreshToken });
|
||||
const newToken = data.data?.accessToken || data.accessToken;
|
||||
localStorage.setItem('admin_access_token', newToken);
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${newToken}`;
|
||||
processQueue(null, newToken);
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshErr) {
|
||||
processQueue(refreshErr, null);
|
||||
localStorage.removeItem('admin_access_token');
|
||||
localStorage.removeItem('admin_refresh_token');
|
||||
localStorage.removeItem('admin_info');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshErr);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMsg =
|
||||
error.response?.data?.message ||
|
||||
(Array.isArray(error.response?.data?.message)
|
||||
? error.response?.data?.message.join(' - ')
|
||||
: 'خطایی در ارتباط با سرور رخ داده است.');
|
||||
|
||||
if (error.response?.status !== 401) {
|
||||
message.error(errorMsg);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../services/api/client';
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('adminAuth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('admin_access_token'));
|
||||
const admin = ref<AdminUser | null>(
|
||||
localStorage.getItem('admin_info')
|
||||
? JSON.parse(localStorage.getItem('admin_info')!)
|
||||
: null,
|
||||
);
|
||||
const loading = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
|
||||
async function login(credentials: { email: string; password: string }) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.post('/admin/auth/login', credentials);
|
||||
const data = response.data;
|
||||
|
||||
token.value = data.accessToken;
|
||||
admin.value = data.admin;
|
||||
|
||||
localStorage.setItem('admin_access_token', data.accessToken);
|
||||
localStorage.setItem('admin_refresh_token', data.refreshToken);
|
||||
localStorage.setItem('admin_info', JSON.stringify(data.admin));
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiClient.post('/admin/auth/logout');
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
token.value = null;
|
||||
admin.value = null;
|
||||
localStorage.removeItem('admin_access_token');
|
||||
localStorage.removeItem('admin_refresh_token');
|
||||
localStorage.removeItem('admin_info');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
admin,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AuditOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
action: undefined as string | undefined,
|
||||
entityType: undefined as string | undefined,
|
||||
});
|
||||
|
||||
// JSON Details Modal
|
||||
const isDetailsModalVisible = ref(false);
|
||||
const currentLog = ref<any>(null);
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/audit-logs', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
action: filters.action || undefined,
|
||||
entityType: filters.entityType || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
logs.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showDetails(record: any) {
|
||||
currentLog.value = record;
|
||||
isDetailsModalVisible.value = true;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'مدیر انجامدهنده', dataIndex: ['admin', 'name'], key: 'adminName', width: 150 },
|
||||
{ title: 'ایمیل مدیر', dataIndex: ['admin', 'email'], key: 'adminEmail', width: 180 },
|
||||
{ title: 'نوع موجودیت', dataIndex: 'entityType', key: 'entityType', width: 140 },
|
||||
{ title: 'عملیات', dataIndex: 'action', key: 'action', width: 160 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function getActionColor(action: string) {
|
||||
if (action.includes('CREATE') || action.includes('INSERT')) return 'green';
|
||||
if (action.includes('UPDATE') || action.includes('EDIT') || action.includes('ADJUST')) return 'blue';
|
||||
if (action.includes('DELETE') || action.includes('BLOCK')) return 'red';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs();
|
||||
});
|
||||
</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>
|
||||
|
||||
<a-button @click="loadLogs">
|
||||
<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>
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const formState = reactive({
|
||||
email: 'admin@luckywheel.local',
|
||||
password: 'Admin@SecurePass2026',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function onFinish() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login({
|
||||
email: formState.email,
|
||||
password: formState.password,
|
||||
});
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
// Handled by axios interceptor
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="header">
|
||||
<div class="logo">🎡</div>
|
||||
<h1>ورود به پنل مدیریت</h1>
|
||||
<p>سامانه گردونه شانس و قرعهکشی</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" layout="vertical" @finish="onFinish">
|
||||
<a-form-item
|
||||
label="ایمیل مدیریت"
|
||||
name="email"
|
||||
:rules="[{ required: true, message: 'لطفاً ایمیل را وارد کنید' }, { type: 'email', message: 'فرمت ایمیل نامعتبر است' }]"
|
||||
>
|
||||
<a-input v-model:value="formState.email" size="large" placeholder="admin@example.com">
|
||||
<template #prefix><MailOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="کلمه عبور"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'لطفاً کلمه عبور را وارد کنید' }]"
|
||||
>
|
||||
<a-input-password v-model:value="formState.password" size="large" placeholder="••••••••">
|
||||
<template #prefix><LockOutlined /></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-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRightOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { WheelDesign } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
import WheelDesignPicker from './WheelDesignPicker.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isEdit = ref(false);
|
||||
const campaignId = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
banner: '',
|
||||
logo: '',
|
||||
dateRange: [] as Dayjs[],
|
||||
status: 'DRAFT',
|
||||
defaultChances: 3,
|
||||
rules: '',
|
||||
ctaText: 'بچرخون و برنده شو!',
|
||||
wheelDesign: WheelDesign.CLASSIC_CASINO,
|
||||
primaryColor: '#FFD700',
|
||||
secondaryColor: '#B22222',
|
||||
});
|
||||
|
||||
async function loadCampaign() {
|
||||
if (!campaignId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
|
||||
const c = res.data;
|
||||
formState.title = c.title;
|
||||
formState.slug = c.slug;
|
||||
formState.description = c.description || '';
|
||||
formState.banner = c.banner || '';
|
||||
formState.logo = c.logo || '';
|
||||
formState.status = c.status;
|
||||
formState.defaultChances = c.defaultChances;
|
||||
formState.rules = c.rules || '';
|
||||
formState.ctaText = c.ctaText || 'بچرخون و برنده شو!';
|
||||
formState.wheelDesign = c.wheelDesign;
|
||||
formState.dateRange = [dayjs(c.startAt), dayjs(c.endAt)];
|
||||
if (c.settings) {
|
||||
formState.primaryColor = c.settings.primaryColor || '#FFD700';
|
||||
formState.secondaryColor = c.settings.secondaryColor || '#B22222';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.title || !formState.title.trim()) {
|
||||
message.error('لطفاً عنوان کمپین را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-clean slug: lowercase, replace spaces with hyphens
|
||||
let slug = (formState.slug || '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||
if (!slug) {
|
||||
slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
formState.slug = slug;
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
message.error('شناسه URL (اسلاگ) باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره (-) باشد.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formState.dateRange || formState.dateRange.length < 2) {
|
||||
message.error('لطفاً بازه زمانی شروع و پایان کمپین را انتخاب کنید.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
title: formState.title.trim(),
|
||||
slug: formState.slug.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
banner: formState.banner ? formState.banner.trim() : null,
|
||||
logo: formState.logo ? formState.logo.trim() : null,
|
||||
startAt: formState.dateRange[0].toISOString(),
|
||||
endAt: formState.dateRange[1].toISOString(),
|
||||
status: formState.status,
|
||||
defaultChances: Number(formState.defaultChances) || 0,
|
||||
rules: formState.rules?.trim() || undefined,
|
||||
ctaText: formState.ctaText || 'بچرخون و برنده شو!',
|
||||
wheelDesign: formState.wheelDesign,
|
||||
settings: {
|
||||
primaryColor: formState.primaryColor,
|
||||
secondaryColor: formState.secondaryColor,
|
||||
},
|
||||
};
|
||||
|
||||
if (isEdit.value && campaignId.value) {
|
||||
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, payload);
|
||||
message.success('کمپین با موفقیت بهروزرسانی شد.');
|
||||
router.push('/campaigns');
|
||||
} else {
|
||||
const res = await apiClient.post('/admin/campaigns', payload);
|
||||
message.success('کمپین با موفقیت ایجاد گردید.');
|
||||
const createdId = res.data?.id || (res as any).id;
|
||||
if (createdId) {
|
||||
router.push(`/campaigns/${createdId}/prizes`);
|
||||
} else {
|
||||
router.push('/campaigns');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || 'خطایی در ثبت اطلاعات کمپین رخ داد.';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File, field: 'banner' | 'logo') {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await apiClient.post('/admin/uploads', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
formState[field] = res.data.url;
|
||||
message.success('تصویر با موفقیت آپلود شد.');
|
||||
} catch {
|
||||
// Handled
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
isEdit.value = true;
|
||||
campaignId.value = route.params.id as string;
|
||||
loadCampaign();
|
||||
} else {
|
||||
// Default dates: now to 1 month later
|
||||
formState.dateRange = [dayjs(), dayjs().add(1, 'month')];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false" :loading="loading">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button type="text" @click="router.push('/campaigns')">
|
||||
<template #icon><ArrowRightOutlined /></template>
|
||||
</a-button>
|
||||
<span>{{ isEdit ? 'ویرایش اطلاعات کمپین' : 'تعریف کمپین جدید گردونه شانس' }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-form :model="formState" layout="vertical" @submit.prevent="onSubmit">
|
||||
<a-row :gutter="24">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="عنوان کمپین"
|
||||
name="title"
|
||||
required
|
||||
:rules="[{ required: true, message: 'عنوان کمپین الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="formState.title" placeholder="مثال: جشنواره عیدانه گردونه شانس" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="شناسه URL (اسلاگ)"
|
||||
name="slug"
|
||||
required
|
||||
extra="فقط حروف کوچک انگلیسی، اعداد و خط تیره (-) مانند: norooz-1405"
|
||||
:rules="[{ required: true, message: 'اسلاگ الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="formState.slug" placeholder="eid-1405" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="بازه زمانی اعتبار (شروع و پایان)" name="dateRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="formState.dateRange"
|
||||
show-time
|
||||
format="YYYY/MM/DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="وضعیت کمپین">
|
||||
<a-select v-model:value="formState.status">
|
||||
<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>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="تعداد شانس پیشفرض کاربران">
|
||||
<a-input-number v-model:value="formState.defaultChances" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="متن دکمه چرخاندن (CTA)">
|
||||
<a-input v-model:value="formState.ctaText" placeholder="بچرخون و برنده شو!" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="لوگوی کمپین">
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div
|
||||
v-if="formState.logo"
|
||||
style="position: relative; width: 90px; height: 90px; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #f8fafc; padding: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.05);"
|
||||
>
|
||||
<img :src="formState.logo" alt="لوگو" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
<button
|
||||
type="button"
|
||||
style="position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: #fff; border: none; font-size: 11px; cursor: pointer; display: flex; items-center; justify-content: center; line-height: 1;"
|
||||
title="حذف لوگو"
|
||||
@click="formState.logo = ''"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<a-upload :before-upload="(f: any) => handleFileUpload(f, 'logo')" :show-upload-list="false">
|
||||
<a-button><template #icon><UploadOutlined /></template> {{ formState.logo ? 'تغییر لوگو' : 'انتخاب فایل لوگو' }}</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="بنر کمپین">
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div
|
||||
v-if="formState.banner"
|
||||
style="position: relative; width: 160px; height: 90px; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #f8fafc; box-shadow: 0 2px 6px rgba(0,0,0,0.05);"
|
||||
>
|
||||
<img :src="formState.banner" alt="بنر" style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
<button
|
||||
type="button"
|
||||
style="position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: #fff; border: none; font-size: 11px; cursor: pointer; display: flex; items-center; justify-content: center; line-height: 1;"
|
||||
title="حذف بنر"
|
||||
@click="formState.banner = ''"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<a-upload :before-upload="(f: any) => handleFileUpload(f, 'banner')" :show-upload-list="false">
|
||||
<a-button><template #icon><UploadOutlined /></template> {{ formState.banner ? 'تغییر بنر' : 'انتخاب فایل بنر' }}</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24">
|
||||
<a-divider>طرح بصری گردونه شانس (انتخاب از بین ۵ طرح استاندار)</a-divider>
|
||||
<a-form-item>
|
||||
<WheelDesignPicker v-model="formState.wheelDesign" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="توضیحات کوتاه کمپین">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="قوانین و مقررات کمپین">
|
||||
<a-textarea v-model:value="formState.rules" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div style="margin-top: 24px; display: flex; gap: 12px">
|
||||
<a-button type="primary" html-type="submit" size="large" :loading="submitting" @click="onSubmit">
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد کمپین و ورود به تعریف جوایز' }}
|
||||
</a-button>
|
||||
<a-button size="large" @click="router.push('/campaigns')">انصراف</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
GiftOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} 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 campaigns = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
status: filters.status,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
campaigns.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateCampaign(id: string) {
|
||||
try {
|
||||
await apiClient.post(`/admin/campaigns/${id}/duplicate`);
|
||||
message.success('کمپین و جوایز آن با موفقیت کپی شدند.');
|
||||
loadCampaigns();
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function quickSetStatus(id: string, newStatus: string) {
|
||||
try {
|
||||
await apiClient.patch(`/admin/campaigns/${id}`, { status: newStatus });
|
||||
message.success(newStatus === 'ACTIVE' ? 'کمپین با موفقیت فعال و منتشر شد.' : 'وضعیت کمپین تغییر یافت.');
|
||||
loadCampaigns();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || 'خطا در تغییر وضعیت کمپین.';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'عنوان کمپین', dataIndex: 'title', key: 'title' },
|
||||
{ title: 'اسلاگ (URL)', dataIndex: 'slug', key: 'slug' },
|
||||
{ title: 'طرح گردونه', dataIndex: 'wheelDesign', key: 'wheelDesign' },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function getStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return { color: 'green', text: 'فعال' };
|
||||
case 'PAUSED':
|
||||
return { color: 'orange', text: 'متوقف' };
|
||||
case 'FINISHED':
|
||||
return { color: 'red', text: 'پایانیافته' };
|
||||
case 'DRAFT':
|
||||
return { color: 'gold', text: 'پیشنویس' };
|
||||
default:
|
||||
return { color: 'default', text: status };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
});
|
||||
</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>
|
||||
|
||||
<a-button type="primary" @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>
|
||||
|
||||
<template v-else-if="column.key === 'defaultChances'">
|
||||
{{ toFarsiDigits(record.defaultChances) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'startAt'">
|
||||
{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'endAt'">
|
||||
{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { WheelDesign } from '@lucky-wheel/shared-types';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: WheelDesign;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: WheelDesign): void;
|
||||
}>();
|
||||
|
||||
// Modal preview state
|
||||
const previewModalVisible = ref(false);
|
||||
const activePreviewDesign = ref<any>(null);
|
||||
|
||||
// Rotating state for cards
|
||||
const spinningCardId = ref<string | null>(null);
|
||||
const cardRotation = ref<Record<string, number>>({});
|
||||
|
||||
const designs = [
|
||||
{
|
||||
id: WheelDesign.CLASSIC_CASINO,
|
||||
title: 'کلاسیک کازینویی (Classic Casino)',
|
||||
badge: 'جشنوارهها و قرعهکشی',
|
||||
badgeColor: 'gold',
|
||||
desc: 'طرح سنتی و مجلل با حبابهای نور طلایی چشمکزن، اسلایسهای قرمز و مشکی و ستاره برنجی',
|
||||
bg: 'radial-gradient(circle, #2d0a0a 0%, #150505 100%)',
|
||||
accent: '#fbbf24',
|
||||
border: '#d97706',
|
||||
slices: ['#b91c1c', '#1e293b', '#b91c1c', '#1e293b', '#b91c1c', '#1e293b'],
|
||||
pointerColor: '#f59e0b',
|
||||
centerColor: '#fbbf24',
|
||||
centerText: '★',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.NEON_CYBER,
|
||||
title: 'نئون سایبری (Neon Cyber)',
|
||||
badge: 'مدرن و گیمینگ',
|
||||
badgeColor: 'cyan',
|
||||
desc: 'تم سایبرپانک تیره با درخشش نور فیروزهای و سرخابی، نشانگر لیزری و پالس نوری آیندهنگرانه',
|
||||
bg: 'radial-gradient(circle, #100e26 0%, #060512 100%)',
|
||||
accent: '#00f0ff',
|
||||
border: '#ec4899',
|
||||
slices: ['#111827', '#1e1b4b', '#111827', '#1e1b4b', '#111827', '#1e1b4b'],
|
||||
pointerColor: '#00f0ff',
|
||||
centerColor: '#00f0ff',
|
||||
centerText: '⚡',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.MINIMAL_FLAT,
|
||||
title: 'مینیمال تخت (Minimal Flat)',
|
||||
badge: 'فینتک و B2B',
|
||||
badgeColor: 'blue',
|
||||
desc: 'طراحی مدرن، بدون حاشیه سنگین، فوقالعاده خوانا، شارپ و متناسب با برندهای رسمی',
|
||||
bg: 'radial-gradient(circle, #1e293b 0%, #0f172a 100%)',
|
||||
accent: '#38bdf8',
|
||||
border: '#3b82f6',
|
||||
slices: ['#3b82f6', '#6366f1', '#3b82f6', '#6366f1', '#3b82f6', '#6366f1'],
|
||||
pointerColor: '#38bdf8',
|
||||
centerColor: '#ffffff',
|
||||
centerText: '●',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.LUXURY_GOLD,
|
||||
title: 'لوکس سلطنتی (Luxury Gold)',
|
||||
badge: 'VIP و پرمیوم',
|
||||
badgeColor: 'orange',
|
||||
desc: 'پسزمینه مشکی ابسیدین با شیبهای طلای متالیک، حاشیه اشرافی و مدالیون کریستالی',
|
||||
bg: 'radial-gradient(circle, #241c0e 0%, #0d0a06 100%)',
|
||||
accent: '#facc15',
|
||||
border: '#ca8a04',
|
||||
slices: ['#1c1917', '#0c0a09', '#1c1917', '#0c0a09', '#1c1917', '#0c0a09'],
|
||||
pointerColor: '#eab308',
|
||||
centerColor: '#facc15',
|
||||
centerText: 'VIP',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.PLAYFUL_CARTOON,
|
||||
title: 'کارتونی فانتزی (Playful Cartoon)',
|
||||
badge: 'خانواده و بازی',
|
||||
badgeColor: 'magenta',
|
||||
desc: 'رنگهای شاداب آبنباتی، بردر ضخیم و نرم، نشانگر جهنده و حس پویای بازیهای موبایلی',
|
||||
bg: 'radial-gradient(circle, #fef3c7 0%, #fde68a 100%)',
|
||||
accent: '#f43f5e',
|
||||
border: '#f59e0b',
|
||||
slices: ['#f43f5e', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ec4899'],
|
||||
pointerColor: '#ef4444',
|
||||
centerColor: '#facc15',
|
||||
centerText: '✨',
|
||||
},
|
||||
];
|
||||
|
||||
function selectDesign(id: WheelDesign) {
|
||||
emit('update:modelValue', id);
|
||||
}
|
||||
|
||||
function triggerTestSpin(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
const current = cardRotation.value[id] || 0;
|
||||
cardRotation.value[id] = current + 1440 + Math.floor(Math.random() * 360);
|
||||
spinningCardId.value = id;
|
||||
setTimeout(() => {
|
||||
if (spinningCardId.value === id) {
|
||||
spinningCardId.value = null;
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function openModalPreview(item: any, event: Event) {
|
||||
event.stopPropagation();
|
||||
activePreviewDesign.value = item;
|
||||
previewModalVisible.value = true;
|
||||
}
|
||||
|
||||
// Generate SVG Slice path for 6 slices
|
||||
function getSectorPath(index: number, count = 6, radius = 46, cx = 50, cy = 50): string {
|
||||
const sliceAngle = 360 / count;
|
||||
const startAngle = (index * sliceAngle - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + radius * Math.cos(startAngle);
|
||||
const y1 = cy + radius * Math.sin(startAngle);
|
||||
const x2 = cx + radius * Math.cos(endAngle);
|
||||
const y2 = cy + radius * Math.sin(endAngle);
|
||||
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 0 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
function getSectorPathLarge(index: number, count = 6, radius = 110, cx = 130, cy = 130): string {
|
||||
const sliceAngle = 360 / count;
|
||||
const startAngle = (index * sliceAngle - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sliceAngle - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = cx + radius * Math.cos(startAngle);
|
||||
const y1 = cy + radius * Math.sin(startAngle);
|
||||
const x2 = cx + radius * Math.cos(endAngle);
|
||||
const y2 = cy + radius * Math.sin(endAngle);
|
||||
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 0 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wheel-picker-grid">
|
||||
<div
|
||||
v-for="item in designs"
|
||||
:key="item.id"
|
||||
class="design-card"
|
||||
:class="{ 'is-selected': modelValue === item.id }"
|
||||
@click="selectDesign(item.id)"
|
||||
>
|
||||
<!-- Visual Wheel Preview Header -->
|
||||
<div class="preview-stage" :style="{ background: item.bg }">
|
||||
<!-- Badge -->
|
||||
<a-tag :color="item.badgeColor" class="theme-tag">
|
||||
{{ item.badge }}
|
||||
</a-tag>
|
||||
|
||||
<!-- Live SVG Wheel Representation -->
|
||||
<div class="wheel-wrapper">
|
||||
<!-- Pointer -->
|
||||
<div class="pointer-wrapper">
|
||||
<svg width="18" height="22" viewBox="0 0 18 22" fill="none">
|
||||
<path
|
||||
d="M9 22L2 6C0.5 3 2.5 0 6 0H12C15.5 0 17.5 3 16 6L9 22Z"
|
||||
:fill="item.pointerColor"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Rotating Wheel SVG -->
|
||||
<svg
|
||||
class="mini-wheel-svg"
|
||||
viewBox="0 0 100 100"
|
||||
:style="{
|
||||
transform: `rotate(${cardRotation[item.id] || 0}deg)`,
|
||||
transition: spinningCardId === item.id ? 'transform 2.5s cubic-bezier(0.15, 0.9, 0.2, 1)' : 'none',
|
||||
}"
|
||||
>
|
||||
<!-- Slices -->
|
||||
<g v-for="(col, sIdx) in item.slices" :key="sIdx">
|
||||
<path
|
||||
:d="getSectorPath(sIdx, item.slices.length)"
|
||||
:fill="col"
|
||||
:stroke="item.accent"
|
||||
stroke-width="0.8"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Outer Ring Bulbs for Casino -->
|
||||
<template v-if="item.id === WheelDesign.CLASSIC_CASINO">
|
||||
<circle
|
||||
v-for="b in 12"
|
||||
:key="b"
|
||||
:cx="50 + 46 * Math.cos((b * 30 - 90) * (Math.PI / 180))"
|
||||
:cy="50 + 46 * Math.sin((b * 30 - 90) * (Math.PI / 180))"
|
||||
r="1.8"
|
||||
fill="#fef08a"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Center Disc -->
|
||||
<circle cx="50" cy="50" r="13" :fill="item.centerColor" :stroke="item.border" stroke-width="1" />
|
||||
<circle cx="50" cy="50" r="8" fill="#1e293b" />
|
||||
<text
|
||||
x="50"
|
||||
y="53"
|
||||
text-anchor="middle"
|
||||
:fill="item.accent"
|
||||
font-size="6"
|
||||
font-weight="bold"
|
||||
>
|
||||
{{ item.centerText }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Action Overlay Tools -->
|
||||
<div class="stage-tools">
|
||||
<a-tooltip title="تست چرخش آزمایشی">
|
||||
<a-button
|
||||
size="small"
|
||||
shape="circle"
|
||||
class="tool-btn"
|
||||
@click="triggerTestSpin(item.id, $event)"
|
||||
>
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="مشاهده پیشنمایش بزرگ">
|
||||
<a-button
|
||||
size="small"
|
||||
shape="circle"
|
||||
class="tool-btn"
|
||||
@click="openModalPreview(item, $event)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Information -->
|
||||
<div class="card-body">
|
||||
<div class="card-title-row">
|
||||
<a-radio :checked="modelValue === item.id">
|
||||
<span class="title-text">{{ item.title }}</span>
|
||||
</a-radio>
|
||||
<CheckCircleFilled v-if="modelValue === item.id" class="selected-icon" />
|
||||
</div>
|
||||
<p class="card-desc">{{ item.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full-Size Preview Modal -->
|
||||
<a-modal
|
||||
v-model:open="previewModalVisible"
|
||||
:title="`پیشنمایش طرح: ${activePreviewDesign?.title || ''}`"
|
||||
:footer="null"
|
||||
width="420px"
|
||||
centered
|
||||
>
|
||||
<div v-if="activePreviewDesign" class="modal-preview-container" :style="{ background: activePreviewDesign.bg }">
|
||||
<div class="modal-wheel-box">
|
||||
<!-- Large Pointer -->
|
||||
<div class="large-pointer">
|
||||
<svg width="28" height="34" viewBox="0 0 28 34" fill="none">
|
||||
<path
|
||||
d="M14 34L4 10C2 5 6 1 11 1H17C22 1 26 5 24 10L14 34Z"
|
||||
:fill="activePreviewDesign.pointerColor"
|
||||
stroke="#fff"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Large Wheel SVG -->
|
||||
<svg class="large-wheel-svg" viewBox="0 0 260 260">
|
||||
<g v-for="(col, sIdx) in activePreviewDesign.slices" :key="sIdx">
|
||||
<path
|
||||
:d="getSectorPathLarge(sIdx, activePreviewDesign.slices.length)"
|
||||
:fill="col"
|
||||
:stroke="activePreviewDesign.accent"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="130" r="30" :fill="activePreviewDesign.centerColor" stroke="#fff" stroke-width="2" />
|
||||
<circle cx="130" cy="130" r="18" fill="#0f172a" />
|
||||
<text
|
||||
x="130"
|
||||
y="136"
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
>
|
||||
{{ activePreviewDesign.centerText }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="selectDesign(activePreviewDesign.id); previewModalVisible = false;"
|
||||
>
|
||||
انتخاب این طرح برای کمپین
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wheel-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.design-card {
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.design-card:hover {
|
||||
border-color: #1677ff;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.design-card.is-selected {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.25);
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
height: 140px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.theme-tag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-tools {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.wheel-wrapper {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pointer-wrapper {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.mini-wheel-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-weight: 700;
|
||||
font-size: 13.5px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
color: #1677ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-preview-container {
|
||||
padding: 30px 20px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-wheel-box {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.large-pointer {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.large-wheel-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
GiftOutlined,
|
||||
ReloadOutlined,
|
||||
TrophyOutlined,
|
||||
UserAddOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import * as echarts from 'echarts';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(true);
|
||||
const dashboardData = ref<any>(null);
|
||||
const chartRef = ref<HTMLElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/reports/dashboard');
|
||||
dashboardData.value = res.data;
|
||||
initChart();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartRef.value || !dashboardData.value?.spinsByDay) return;
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
}
|
||||
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
|
||||
const dates = dashboardData.value.spinsByDay.map((d: any) => toFarsiDigits(d.date));
|
||||
const spins = dashboardData.value.spinsByDay.map((d: any) => d.spins);
|
||||
const winners = dashboardData.value.spinsByDay.map((d: any) => d.winners);
|
||||
|
||||
chartInstance.setOption({
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: 'IRANSans' },
|
||||
valueFormatter: (val: any) => formatFarsiNumber(val),
|
||||
},
|
||||
legend: {
|
||||
data: ['تعداد کل چرخشها', 'تعداد برندگان'],
|
||||
textStyle: { fontFamily: 'IRANSans' },
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
fontFamily: 'IRANSans',
|
||||
formatter: (val: string) => toFarsiDigits(val),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontFamily: 'IRANSans',
|
||||
formatter: (val: number) => formatFarsiNumber(val),
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'تعداد کل چرخشها',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: spins,
|
||||
itemStyle: { color: '#1890ff' },
|
||||
areaStyle: { color: 'rgba(24, 144, 255, 0.1)' },
|
||||
},
|
||||
{
|
||||
name: 'تعداد برندگان',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: winners,
|
||||
itemStyle: { color: '#52c41a' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره موبایل', dataIndex: ['user', 'mobile'], key: 'mobile' },
|
||||
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
|
||||
{ title: 'جایزه برنده شده', dataIndex: ['prize', 'title'], key: 'prize' },
|
||||
{ title: 'کد جایزه', dataIndex: 'code', key: 'code' },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'تاریخ برد', dataIndex: 'wonAt', key: 'wonAt' },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboard();
|
||||
window.addEventListener('resize', () => chartInstance?.resize());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<a-spin :spinning="loading">
|
||||
<!-- KPI Cards -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<a-statistic
|
||||
title="کل کاربران ثبتنامی"
|
||||
:value="dashboardData?.stats?.totalUsers || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
>
|
||||
<template #prefix><UserOutlined style="color: #1890ff" /></template>
|
||||
<template #suffix>
|
||||
<span class="sub-stat">
|
||||
(+{{ formatFarsiNumber(dashboardData?.stats?.todayUsers || 0) }} امروز)
|
||||
</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<a-statistic
|
||||
title="کل چرخشها (Spins)"
|
||||
:value="dashboardData?.stats?.totalSpins || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
>
|
||||
<template #prefix><ReloadOutlined style="color: #faad14" /></template>
|
||||
<template #suffix>
|
||||
<span class="sub-stat">
|
||||
(+{{ formatFarsiNumber(dashboardData?.stats?.todaySpins || 0) }} امروز)
|
||||
</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<a-statistic
|
||||
title="کل جوایز برنده شده"
|
||||
:value="dashboardData?.stats?.totalWinners || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
>
|
||||
<template #prefix><TrophyOutlined style="color: #52c41a" /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false" class="kpi-card">
|
||||
<a-statistic
|
||||
title="جوایز تحویل/مصرف شده"
|
||||
:value="dashboardData?.stats?.totalDistributedPrizes || 0"
|
||||
:formatter="(val: any) => formatFarsiNumber(val)"
|
||||
>
|
||||
<template #prefix><CheckCircleOutlined style="color: #722ed1" /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Chart and Prize Distribution -->
|
||||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||||
<a-col :xs="24" :lg="16">
|
||||
<a-card title="روند چرخشها و برندگان در ۱۴ روز گذشته" :bordered="false">
|
||||
<div ref="chartRef" style="height: 320px; width: 100%"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="توزیع جوایز برنده شده" :bordered="false">
|
||||
<div v-if="dashboardData?.prizeDistribution?.length > 0" class="dist-list">
|
||||
<div
|
||||
v-for="item in dashboardData.prizeDistribution"
|
||||
:key="item.prizeTitle"
|
||||
class="dist-item"
|
||||
>
|
||||
<div class="dist-header">
|
||||
<span>{{ item.prizeTitle }}</span>
|
||||
<span>{{ formatFarsiNumber(item.count) }} عدد ({{ toFarsiDigits(item.percentage) }}٪)</span>
|
||||
</div>
|
||||
<a-progress :percent="item.percentage" size="small" status="active" />
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="هنوز جایزهای توزیع نشده است" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Recent Winners Table -->
|
||||
<a-card title="آخرین برندگان گردونه" :bordered="false" style="margin-top: 16px">
|
||||
<a-table
|
||||
:dataSource="dashboardData?.recentWinners || []"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mobile'">
|
||||
{{ toFarsiDigits(record.user?.mobile) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'wonAt'">
|
||||
{{ toFarsiDigits(new Date(record.wonAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'DELIVERED' || record.status === 'USED' ? 'green' : 'blue'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sub-stat {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.dist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,779 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { 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';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const campaignId = ref<string>((route.params.campaignId as string) || '');
|
||||
const campaign = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
const prizes = ref<any[]>([]);
|
||||
|
||||
// Modal states
|
||||
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: '',
|
||||
description: '',
|
||||
type: 'DISCOUNT_CODE',
|
||||
stockType: 'LIMITED',
|
||||
initialStock: 100,
|
||||
remainingStock: 100,
|
||||
weight: 10,
|
||||
segmentColor: '#FF6B6B',
|
||||
image: '',
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
smsTemplate: '',
|
||||
});
|
||||
|
||||
const estimatedWinChance = computed(() => {
|
||||
const currentWeight = parseFarsiNumber(formState.weight, 1);
|
||||
const otherWeights = prizes.value.reduce((acc, p) => {
|
||||
if (editingPrizeId.value && p.id === editingPrizeId.value) return acc;
|
||||
return acc + (p.status === 'ACTIVE' || p.isActive ? (p.weight || 0) : 0);
|
||||
}, 0);
|
||||
const newTotal = otherWeights + currentWeight;
|
||||
if (newTotal <= 0) return '۰';
|
||||
const pct = ((currentWeight / newTotal) * 100).toFixed(1);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const stockState = reactive({
|
||||
prizeId: '',
|
||||
prizeTitle: '',
|
||||
currentStock: 0,
|
||||
change: 0,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const totalWeight = computed(() => {
|
||||
return prizes.value.reduce((acc, p) => acc + (p.status === 'ACTIVE' || p.isActive ? p.weight : 0), 0);
|
||||
});
|
||||
|
||||
async function loadCampaignInfo() {
|
||||
if (!campaignId.value) return;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
|
||||
campaign.value = res.data;
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
const isActivating = ref(false);
|
||||
|
||||
async function activateCampaign() {
|
||||
if (!campaignId.value) return;
|
||||
if (prizes.value.length < 1) {
|
||||
message.warning('برای فعالسازی کمپین، حداقل یک جایزه فعال تعریف کنید.');
|
||||
return;
|
||||
}
|
||||
isActivating.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, { status: 'ACTIVE' });
|
||||
message.success('کمپین با موفقیت فعال و منتشر شد! هماکنون برای کاربران در دسترس است.');
|
||||
await loadCampaignInfo();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || 'خطا در فعالسازی کمپین.';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
isActivating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseCampaign() {
|
||||
if (!campaignId.value) return;
|
||||
isActivating.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, { status: 'PAUSED' });
|
||||
message.info('کمپین متوقف شد.');
|
||||
await loadCampaignInfo();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || 'خطا در توقف کمپین.';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
isActivating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrizes() {
|
||||
if (!campaignId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}/prizes`);
|
||||
prizes.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingPrizeId.value = null;
|
||||
isNoPrize.value = false;
|
||||
formState.title = '';
|
||||
formState.description = '';
|
||||
formState.type = 'DISCOUNT_CODE';
|
||||
formState.stockType = 'LIMITED';
|
||||
formState.initialStock = 100;
|
||||
formState.remainingStock = 100;
|
||||
formState.weight = 10;
|
||||
formState.segmentColor = '#3880ff';
|
||||
formState.image = '';
|
||||
formState.sortOrder = prizes.value.length + 1;
|
||||
formState.status = 'ACTIVE';
|
||||
formState.smsTemplate = '';
|
||||
isFormModalVisible.value = true;
|
||||
}
|
||||
|
||||
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;
|
||||
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.initialStock = record.initialStock ?? 100;
|
||||
formState.remainingStock = record.remainingStock ?? record.initialStock ?? 100;
|
||||
formState.weight = record.weight ?? 10;
|
||||
formState.segmentColor = record.segmentColor || record.color || '#3880ff';
|
||||
formState.image = record.image || record.iconUrl || '';
|
||||
formState.sortOrder = record.sortOrder ?? 1;
|
||||
formState.status = record.status || (record.isActive === false ? 'INACTIVE' : 'ACTIVE');
|
||||
formState.smsTemplate = record.smsTemplate || '';
|
||||
isFormModalVisible.value = true;
|
||||
}
|
||||
|
||||
function openStockModal(record: any) {
|
||||
stockState.prizeId = record.id;
|
||||
stockState.prizeTitle = record.title;
|
||||
stockState.currentStock = record.remainingStock;
|
||||
stockState.change = 0;
|
||||
stockState.reason = '';
|
||||
isStockModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleFormSubmit() {
|
||||
if (!formState.title || !formState.title.trim()) {
|
||||
message.error('عنوان جایزه الزامی است.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isNoPrize.value) {
|
||||
formState.type = 'NO_PRIZE';
|
||||
formState.stockType = 'UNLIMITED';
|
||||
}
|
||||
|
||||
const parsedWeight = parseFarsiNumber(formState.weight, 1);
|
||||
const parsedInitialStock = parseFarsiNumber(formState.initialStock, 1);
|
||||
const parsedRemainingStock = parseFarsiNumber(formState.remainingStock, parsedInitialStock);
|
||||
const parsedSortOrder = parseFarsiNumber(formState.sortOrder, 1);
|
||||
|
||||
const payload: any = {
|
||||
title: formState.title.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
type: formState.type,
|
||||
stockType: formState.stockType,
|
||||
weight: parsedWeight > 0 ? parsedWeight : 1,
|
||||
segmentColor: formState.segmentColor || '#3880ff',
|
||||
image: formState.image || undefined,
|
||||
sortOrder: parsedSortOrder > 0 ? parsedSortOrder : 1,
|
||||
status: formState.status,
|
||||
smsTemplate: formState.smsTemplate?.trim() || undefined,
|
||||
};
|
||||
|
||||
if (formState.stockType === 'LIMITED') {
|
||||
payload.initialStock = parsedInitialStock;
|
||||
payload.remainingStock = parsedRemainingStock;
|
||||
}
|
||||
|
||||
if (editingPrizeId.value) {
|
||||
await apiClient.patch(`/admin/prizes/${editingPrizeId.value}`, payload);
|
||||
message.success('جایزه با موفقیت بهروزرسانی شد.');
|
||||
} else {
|
||||
await apiClient.post(`/admin/campaigns/${campaignId.value}/prizes`, payload);
|
||||
message.success('جایزه با موفقیت ایجاد شد.');
|
||||
}
|
||||
isFormModalVisible.value = false;
|
||||
loadPrizes();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || err.message || 'خطا در ثبت جایزه.';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStockSubmit() {
|
||||
const changeVal = parseFarsiNumber(stockState.change, 0);
|
||||
if (changeVal === 0) {
|
||||
message.warning('میزان تغییر باید غیر صفر باشد.');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/prizes/${stockState.prizeId}/stock`, {
|
||||
change: changeVal,
|
||||
reason: stockState.reason || undefined,
|
||||
});
|
||||
message.success('موجودی جایزه با موفقیت تغییر یافت.');
|
||||
isStockModalVisible.value = false;
|
||||
loadPrizes();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await apiClient.delete(`/admin/prizes/${id}`);
|
||||
message.success('جایزه با موفقیت حذف شد.');
|
||||
loadPrizes();
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomUpload({ file, onSuccess, onError }: any) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await apiClient.post('/admin/uploads', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
formState.image = res.data.url;
|
||||
onSuccess(res.data);
|
||||
message.success('تصویر با موفقیت بارگذاری شد.');
|
||||
} catch (err: any) {
|
||||
onError(err);
|
||||
message.error('خطا در بارگذاری تصویر');
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ترتیب', dataIndex: 'sortOrder', key: 'sortOrder', width: 70 },
|
||||
{ title: 'رنگ', key: 'color', width: 80 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function getPrizeTypeTag(type: string) {
|
||||
switch (type) {
|
||||
case 'PHYSICAL':
|
||||
return { text: 'کالای فیزیکی', color: 'blue' };
|
||||
case 'DISCOUNT_CODE':
|
||||
return { text: 'کد تخفیف', color: 'green' };
|
||||
case 'CREDIT':
|
||||
case 'WALLET_CREDIT':
|
||||
return { text: 'شارژ کیف پول', color: 'purple' };
|
||||
case 'CASH':
|
||||
return { text: 'جایزه نقدی', color: 'gold' };
|
||||
case 'NO_PRIZE':
|
||||
return { text: 'پوچ / بدون جایزه', color: 'error' };
|
||||
case 'OTHER':
|
||||
case 'CHANCE':
|
||||
return { text: 'شانس مجدد / سایر', color: 'orange' };
|
||||
default:
|
||||
return { text: type || '—', color: 'default' };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaignInfo();
|
||||
loadPrizes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-page-header
|
||||
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
|
||||
:title="`مدیریت جوایز کمپین: ${campaign?.title || 'در حال بارگذاری...'}`"
|
||||
:sub-title="`طرح گردونه: ${campaign?.wheelDesign || '-'}`"
|
||||
@back="router.push('/campaigns')"
|
||||
>
|
||||
<template #tags>
|
||||
<a-tag v-if="campaign?.status === 'DRAFT'" color="orange">پیشنویس (DRAFT)</a-tag>
|
||||
<a-tag v-else-if="campaign?.status === 'ACTIVE'" color="green">فعال و در دسترس (ACTIVE)</a-tag>
|
||||
<a-tag v-else-if="campaign?.status === 'PAUSED'" color="red">متوقف (PAUSED)</a-tag>
|
||||
<a-tag v-else>{{ campaign?.status }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="campaign?.slug"
|
||||
:href="'http://localhost:5174/c/' + campaign.slug"
|
||||
target="_blank"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
مشاهده گردونه کاربر ↗
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
v-if="campaign?.status === 'DRAFT'"
|
||||
type="primary"
|
||||
style="background: #52c41a; border-color: #52c41a"
|
||||
:loading="isActivating"
|
||||
@click="activateCampaign"
|
||||
>
|
||||
<template #icon><CheckCircleOutlined /></template>
|
||||
🚀 فعالسازی و انتشار کمپین
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
v-else-if="campaign?.status === 'ACTIVE'"
|
||||
danger
|
||||
:loading="isActivating"
|
||||
@click="pauseCampaign"
|
||||
>
|
||||
<template #icon><PauseCircleOutlined /></template>
|
||||
توقف موقت کمپین
|
||||
</a-button>
|
||||
|
||||
<a-button @click="loadPrizes">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن جایزه جدید
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-alert
|
||||
v-if="campaign?.status === 'DRAFT'"
|
||||
message="کمپین در وضعیت پیشنویس (DRAFT) قرار دارد"
|
||||
description="جوایز خود را در جدول زیر تعریف یا ویرایش کنید. پس از پایان تنظیمات جوایز، برای فعالسازی و در دسترس قرار گرفتن گردونه برای کاربران، بر روی دکمه سبز رنگ «🚀 فعالسازی و انتشار کمپین» در بالا کلیک نمایید."
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
<a-alert
|
||||
v-else-if="campaign?.status === 'ACTIVE'"
|
||||
message="این کمپین فعال و منتشر شده است"
|
||||
description="کاربران میتوانند با مراجعه به صفحه گردونه، شانس خود را امتحان کنند."
|
||||
type="success"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<div v-if="prizes.length === 0 && !loading" style="text-align: center; padding: 40px">
|
||||
<a-empty description="هنوز جایزهای برای این کمپین تعریف نشده است.">
|
||||
<a-button type="primary" @click="openCreateModal">ایجاد اولین جایزه</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:dataSource="prizes"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'color'">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: record.segmentColor || record.color || '#ddd',
|
||||
color: '#fff',
|
||||
width: '40px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
|
||||
}"
|
||||
>
|
||||
{{ toFarsiDigits(record.sortOrder) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getPrizeTypeTag(record.type || record.prizeType).color">
|
||||
{{ getPrizeTypeTag(record.type || record.prizeType).text }}
|
||||
</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' ? 'محدود' : 'نامحدود' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'initialStock'">
|
||||
<span>{{ toFarsiDigits(record.initialStock) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'remainingStock'">
|
||||
<span v-if="record.stockType === 'UNLIMITED'">∞</span>
|
||||
<span
|
||||
v-else
|
||||
:style="{
|
||||
color: record.remainingStock === 0 ? 'red' : record.remainingStock < 10 ? 'orange' : 'inherit',
|
||||
fontWeight: record.remainingStock < 10 ? 700 : 'normal',
|
||||
}"
|
||||
>
|
||||
{{ toFarsiDigits(record.remainingStock) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'weight'">
|
||||
<span>
|
||||
{{ toFarsiDigits(record.weight) }}
|
||||
<span v-if="totalWeight > 0" style="color: #888; font-size: 12px">
|
||||
({{ toFarsiDigits(((record.weight / totalWeight) * 100).toFixed(1)) }}٪)
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'ACTIVE' || record.isActive ? 'green' : 'default'">
|
||||
{{ record.status === 'ACTIVE' || record.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</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-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>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<a-modal
|
||||
v-model:open="isFormModalVisible"
|
||||
:title="editingPrizeId ? 'ویرایش جایزه' : 'افزودن جایزه جدید'"
|
||||
:confirmLoading="isSubmitting"
|
||||
okText="ذخیره"
|
||||
cancelText="انصراف"
|
||||
width="680px"
|
||||
@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-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="عنوان جایزه" required>
|
||||
<a-input v-model:value="formState.title" placeholder="مثال: آیفون ۱۵ یا کد تخفیف ۵۰٪" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="ترتیب در گردونه (۱ به بعد)" required tooltip="شماره موقعیت این اسلایس روی گردونه">
|
||||
<a-input-number
|
||||
v-model:value="formState.sortOrder"
|
||||
:min="1"
|
||||
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="توضیحات جایزه">
|
||||
<a-input v-model:value="formState.description" placeholder="مثال: خریدهای بالای ۵۰۰ هزار تومان یا تحویل حضوری" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- بخش ۲: نوع جایزه و وزن شانس (کنار هم) -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="نوع جایزه" required>
|
||||
<a-select v-model:value="formState.type" @change="handleTypeChange">
|
||||
<a-select-option value="DISCOUNT_CODE">کد تخفیف</a-select-option>
|
||||
<a-select-option value="PHYSICAL">کالای فیزیکی</a-select-option>
|
||||
<a-select-option value="CREDIT">شارژ کیف پول (اعتبار)</a-select-option>
|
||||
<a-select-option value="CASH">جایزه نقدی</a-select-option>
|
||||
<a-select-option value="NO_PRIZE">پوچ (بدون جایزه)</a-select-option>
|
||||
<a-select-option value="OTHER">سایر / شانس مجدد</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="وزن شانس (ضریب احتمالات)"
|
||||
required
|
||||
tooltip="هرچه وزن نسبت به سایر جوایز بالاتر باشد احتمال اصابت بیشتر خواهد بود"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="formState.weight"
|
||||
:min="1"
|
||||
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 12px; display: flex; align-items: center; justify-content: space-between">
|
||||
<span style="color: #64748b">تخمین احتمال اصابت:</span>
|
||||
<a-tag color="processing" style="font-weight: 600; margin: 0">
|
||||
{{ estimatedWinChance }}٪ از کل شانسها
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- بخش ۳: مدیریت انبار و موجودی -->
|
||||
<div style="background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 12px 14px; margin-bottom: 16px">
|
||||
<div style="font-weight: 600; font-size: 13px; color: #334155; margin-bottom: 10px">
|
||||
📦 تنظیمات موجودی و انبار
|
||||
</div>
|
||||
<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-option value="LIMITED">محدود (شمارشدار)</a-select-option>
|
||||
<a-select-option value="UNLIMITED">نامحدود</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<template v-if="formState.stockType === 'LIMITED'">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="موجودی اولیه" style="margin-bottom: 0">
|
||||
<a-input-number
|
||||
v-model:value="formState.initialStock"
|
||||
:min="1"
|
||||
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<a-form-item
|
||||
label="موجودی باقیمانده"
|
||||
tooltip="موجودی قابل استفاده فعلی در انبار"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="formState.remainingStock"
|
||||
:min="0"
|
||||
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- بخش ۴: ظاهر و قالب پیامک -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="رنگ پسزمینه اسلایس (Hex)" required>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<input
|
||||
v-model="formState.segmentColor"
|
||||
type="color"
|
||||
style="width: 40px; height: 32px; border: none; cursor: pointer; border-radius: 4px"
|
||||
/>
|
||||
<a-input v-model:value="formState.segmentColor" placeholder="#3880ff" style="flex: 1" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="قالب پیامک برنده (اختیاری)" tooltip="متغیرها: {prize} و {code}">
|
||||
<a-input v-model:value="formState.smsTemplate" placeholder="تبریک! برنده {prize} شدید. کد: {code}" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="آیکون یا تصویر جایزه">
|
||||
<div style="display: flex; gap: 12px; align-items: center">
|
||||
<a-upload
|
||||
:customRequest="handleCustomUpload"
|
||||
:showUploadList="false"
|
||||
accept="image/*"
|
||||
>
|
||||
<a-button>
|
||||
<template #icon><UploadOutlined /></template>
|
||||
بارگذاری تصویر
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-input v-model:value="formState.image" placeholder="آدرس تصویر (URL)" style="flex: 1" />
|
||||
</div>
|
||||
<div v-if="formState.image" style="margin-top: 8px">
|
||||
<img :src="formState.image" alt="icon preview" style="max-height: 48px; border-radius: 4px" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت" style="margin-bottom: 0">
|
||||
<a-switch
|
||||
:checked="formState.status === 'ACTIVE'"
|
||||
checked-children="فعال"
|
||||
un-checked-children="غیرفعال"
|
||||
@change="(checked: any) => formState.status = checked ? 'ACTIVE' : 'INACTIVE'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Adjust Stock Modal -->
|
||||
<a-modal
|
||||
v-model:open="isStockModalVisible"
|
||||
title="تنظیم سریع موجودی جایزه"
|
||||
:confirmLoading="isSubmitting"
|
||||
okText="اعمال تغییرات"
|
||||
cancelText="انصراف"
|
||||
@ok="handleStockSubmit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-alert
|
||||
:message="`جایزه: ${stockState.prizeTitle}`"
|
||||
:description="`موجودی فعلی باقیمانده: ${stockState.currentStock} عدد`"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-form-item
|
||||
label="تغییر موجودی (+ برای افزایش، - برای کاهش)"
|
||||
required
|
||||
tooltip="مثال: برای اضافه کردن ۵۰ عدد مقدار 50+ و برای کاهش مقدار 20- وارد کنید"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="stockState.change"
|
||||
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9.-]/g, '')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="دلیل تغییر (جهت ثبت در لاگ بازرسی)">
|
||||
<a-input v-model:value="stockState.reason" placeholder="مثال: ورود محموله جدید به انبار" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarChartOutlined,
|
||||
DownloadOutlined,
|
||||
GiftOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
} 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 { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const report = ref<any>(null);
|
||||
const campaigns = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
campaignId: undefined as string | undefined,
|
||||
from: undefined as string | undefined,
|
||||
to: undefined as string | undefined,
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = res.data || [];
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReport() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/reports/participation', {
|
||||
params: {
|
||||
campaignId: filters.campaignId || undefined,
|
||||
from: filters.from || undefined,
|
||||
to: filters.to || undefined,
|
||||
},
|
||||
});
|
||||
report.value = res.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/reports/export', {
|
||||
params: {
|
||||
campaignId: filters.campaignId || undefined,
|
||||
from: filters.from || undefined,
|
||||
to: filters.to || undefined,
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
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);
|
||||
message.success('گزارش اکسل با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت گزارش اکسل');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadReport();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-card :bordered="false" style="margin-bottom: 20px">
|
||||
<div class="filters-header">
|
||||
<div class="filters">
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
placeholder="انتخاب کمپین (اختیاری)"
|
||||
allowClear
|
||||
style="width: 240px"
|
||||
@change="loadReport"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
RedoOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} 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 { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
mobile: '',
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/sms-logs', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
mobile: filters.mobile || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
logs.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resendSms(id: string) {
|
||||
try {
|
||||
await apiClient.post(`/admin/sms-logs/${id}/resend`);
|
||||
message.success('پیامک مجدداً در صف ارسال قرار گرفت.');
|
||||
loadLogs();
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: 'mobile', key: 'mobile', width: 140 },
|
||||
{ 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: 'errorMessage', key: 'errorMessage', ellipsis: true },
|
||||
{ title: 'زمان ثبت', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
|
||||
{ title: 'عملیات', key: 'actions', width: 120 },
|
||||
];
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
switch (status) {
|
||||
case 'SENT':
|
||||
return { text: 'ارسال شد', color: 'green' };
|
||||
case 'QUEUED':
|
||||
return { text: 'در صف ارسال', color: 'orange' };
|
||||
case 'FAILED':
|
||||
return { text: 'خطا در ارسال', color: 'red' };
|
||||
default:
|
||||
return { text: status, color: 'default' };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs();
|
||||
});
|
||||
</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>
|
||||
|
||||
<a-button @click="loadLogs">
|
||||
<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)"
|
||||
>
|
||||
<template #icon><RedoOutlined /></template>
|
||||
ارسال مجدد
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} 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 { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const spins = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const campaigns = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
campaignId: undefined as string | undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = res.data || [];
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSpins() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/spins', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
campaignId: filters.campaignId || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
spins.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/spins/export', {
|
||||
params: {
|
||||
campaignId: filters.campaignId || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
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);
|
||||
message.success('فایل اکسل با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت فایل اکسل');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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: 'کلید تکرارناپذیری (Idempotency)', dataIndex: 'idempotencyKey', key: 'idempotencyKey', ellipsis: true },
|
||||
{ title: 'آیپی کاربر', dataIndex: 'ipAddress', key: 'ipAddress' },
|
||||
{ title: 'زمان اسپین', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadSpins();
|
||||
});
|
||||
</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>
|
||||
|
||||
<a-space>
|
||||
<a-button @click="loadSpins">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="exporting" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</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>
|
||||
|
||||
<template v-else-if="column.key === 'userName'">
|
||||
{{ record.user?.name || '—' }}
|
||||
</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 === 'idempotencyKey'">
|
||||
<code style="font-size: 11px; color: #666">{{ record.idempotencyKey }}</code>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'ipAddress'">
|
||||
{{ toFarsiDigits(record.ipAddress) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
GiftOutlined,
|
||||
HistoryOutlined,
|
||||
PlusCircleOutlined,
|
||||
ReloadOutlined,
|
||||
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 { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userId = ref<string>((route.params.id as string) || '');
|
||||
|
||||
const loading = ref(false);
|
||||
const user = ref<any>(null);
|
||||
|
||||
// Adjust chance modal
|
||||
const isChanceModalVisible = ref(false);
|
||||
const isSubmittingChance = ref(false);
|
||||
const selectedCampaignId = ref('');
|
||||
const chanceAmount = ref(1);
|
||||
const chanceReason = ref('');
|
||||
const allCampaigns = ref<any[]>([]);
|
||||
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
allCampaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
if (!userId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/users/${userId.value}`);
|
||||
user.value = res.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openChanceModal(campaignId?: string) {
|
||||
selectedCampaignId.value =
|
||||
campaignId ||
|
||||
user.value?.userCampaigns?.[0]?.campaignId ||
|
||||
allCampaigns.value?.[0]?.id ||
|
||||
'';
|
||||
chanceAmount.value = 1;
|
||||
chanceReason.value = 'اهدای شانس تشویقی توسط مدیر';
|
||||
isChanceModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitAdjustChance() {
|
||||
if (!selectedCampaignId.value) {
|
||||
message.error('انتخاب کمپین الزامی است.');
|
||||
return;
|
||||
}
|
||||
if (!chanceAmount.value) {
|
||||
message.error('مقدار شانس نامعتبر است.');
|
||||
return;
|
||||
}
|
||||
isSubmittingChance.value = true;
|
||||
try {
|
||||
await apiClient.post('/admin/chances/adjust', {
|
||||
userId: userId.value,
|
||||
campaignId: selectedCampaignId.value,
|
||||
amount: chanceAmount.value,
|
||||
reason: chanceReason.value,
|
||||
});
|
||||
message.success('شانس کاربر با موفقیت تغییر کرد.');
|
||||
isChanceModalVisible.value = false;
|
||||
loadUser();
|
||||
} finally {
|
||||
isSubmittingChance.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
const prizeColumns = [
|
||||
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'title' },
|
||||
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
|
||||
{ title: 'کد دیجیتال', dataIndex: 'code', key: 'code' },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'تاریخ دریافت', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
];
|
||||
|
||||
const spinColumns = [
|
||||
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
|
||||
{ title: 'نتیجه / جایزه', dataIndex: ['prize', 'title'], key: 'prize' },
|
||||
{ title: 'برد؟', dataIndex: 'isWin', key: 'isWin' },
|
||||
{ title: 'آیپی', dataIndex: 'ipAddress', key: 'ipAddress' },
|
||||
{ title: 'تاریخ اسپین', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadUser();
|
||||
loadCampaigns();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-page-header
|
||||
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
|
||||
:title="`پروفایل کاربر: ${user?.mobile || 'در حال بارگذاری...'}`"
|
||||
:sub-title="user?.name || 'بدون نام'"
|
||||
@back="router.push('/users')"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="loadUser">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" @click="() => openChanceModal()">
|
||||
<template #icon><PlusCircleOutlined /></template>
|
||||
افزودن شانس
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-card title="اطلاعات هویتی و حساب" :bordered="false" 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="نام و نام خانوادگی">{{ user?.name || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت مسدودی">
|
||||
<a-tag :color="user?.isBlocked ? 'red' : 'green'">
|
||||
{{ user?.isBlocked ? 'مسدود شده' : 'فعال' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ ثبتنام">
|
||||
{{ user?.createdAt ? toFarsiDigits(new Date(user.createdAt).toLocaleDateString('fa-IR')) : '—' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card title="موجودی شانس در کمپینها" :bordered="false" style="margin-bottom: 20px">
|
||||
<a-table
|
||||
:dataSource="user?.userCampaigns || []"
|
||||
:columns="campaignColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'remainingChances'">
|
||||
<a-badge :count="toFarsiDigits(record.remainingChances)" :overflowCount="999" :number-style="{ backgroundColor: '#52c41a' }" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'totalChances'">
|
||||
{{ toFarsiDigits(record.totalChances) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'usedChances'">
|
||||
{{ toFarsiDigits(record.usedChances) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button size="small" type="primary" ghost @click="openChanceModal(record.campaignId)">
|
||||
تغییر شانس
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-tabs defaultActiveKey="prizes" type="card" style="background: #fff; padding: 16px; border-radius: 8px">
|
||||
<a-tab-pane key="prizes" tab="جوایز دریافتشده">
|
||||
<a-table
|
||||
:dataSource="user?.prizes || []"
|
||||
:columns="prizeColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
: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) }}
|
||||
</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag color="blue">{{ record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="spins" tab="تاریخچه اسپینها">
|
||||
<a-table
|
||||
:dataSource="user?.spins || []"
|
||||
:columns="spinColumns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'isWin'">
|
||||
<a-tag :color="record.isWin ? 'green' : 'default'">
|
||||
{{ record.isWin ? 'برنده' : 'پوچ' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- Adjust Chance Modal -->
|
||||
<a-modal
|
||||
v-model:open="isChanceModalVisible"
|
||||
title="تنظیم شانس کاربر در کمپین"
|
||||
:confirmLoading="isSubmittingChance"
|
||||
okText="ثبت تغییرات"
|
||||
cancelText="انصراف"
|
||||
@ok="submitAdjustChance"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="کمپین مورد نظر" required>
|
||||
<a-select
|
||||
v-model:value="selectedCampaignId"
|
||||
placeholder="انتخاب کمپین"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="c in allCampaigns"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="تغییر شانس (+ برای افزایش، - برای کسر)" required>
|
||||
<a-input-number v-model:value="chanceAmount" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="دلیل / بابت" required>
|
||||
<a-input v-model:value="chanceReason" placeholder="مثال: خرید مجدد مشتری" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
EyeOutlined,
|
||||
LockOutlined,
|
||||
PlusCircleOutlined,
|
||||
SearchOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
// Adjust Chance Modal state
|
||||
const isChanceModalVisible = ref(false);
|
||||
const isSubmittingChance = ref(false);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const chanceForm = reactive({
|
||||
userId: '',
|
||||
userMobile: '',
|
||||
campaignId: '',
|
||||
amount: 1,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/users', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
users.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBlock(record: any) {
|
||||
const isCurrentlyBlocked = record.status === 'BLOCKED';
|
||||
const newBlockedState = !isCurrentlyBlocked;
|
||||
try {
|
||||
await apiClient.patch(`/admin/users/${record.id}/block`, {
|
||||
blocked: newBlockedState,
|
||||
reason: newBlockedState ? 'مسدودسازی توسط مدیر' : 'رفع مسدودی توسط مدیر',
|
||||
});
|
||||
message.success(newBlockedState ? 'کاربر با موفقیت مسدود شد.' : 'مسدودی کاربر رفع شد.');
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
function openAdjustChance(record: any) {
|
||||
chanceForm.userId = record.id;
|
||||
chanceForm.userMobile = record.mobile;
|
||||
chanceForm.campaignId = campaigns.value[0]?.id || '';
|
||||
chanceForm.amount = 1;
|
||||
chanceForm.reason = 'اهدای شانس تشویقی توسط مدیر';
|
||||
isChanceModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitAdjustChance() {
|
||||
if (!chanceForm.campaignId) {
|
||||
message.error('لطفاً کمپین مورد نظر را انتخاب کنید.');
|
||||
return;
|
||||
}
|
||||
if (!chanceForm.amount || chanceForm.amount === 0) {
|
||||
message.error('مقدار شانس باید غیر صفر باشد.');
|
||||
return;
|
||||
}
|
||||
if (!chanceForm.reason) {
|
||||
message.error('دلیل تغییر شانس الزامی است.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingChance.value = true;
|
||||
try {
|
||||
await apiClient.post('/admin/chances/adjust', {
|
||||
userId: chanceForm.userId,
|
||||
campaignId: chanceForm.campaignId,
|
||||
amount: chanceForm.amount,
|
||||
reason: chanceForm.reason,
|
||||
});
|
||||
message.success('شانس کاربر با موفقیت تغییر یافت.');
|
||||
isChanceModalVisible.value = false;
|
||||
loadUsers();
|
||||
} finally {
|
||||
isSubmittingChance.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره موبایل', dataIndex: 'mobile', key: 'mobile' },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadUsers();
|
||||
});
|
||||
</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>
|
||||
</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-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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} 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 { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const winners = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const campaigns = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
campaignId: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
// Update Status Modal
|
||||
const isStatusModalVisible = ref(false);
|
||||
const isSubmittingStatus = ref(false);
|
||||
const statusForm = reactive({
|
||||
id: '',
|
||||
winnerMobile: '',
|
||||
prizeTitle: '',
|
||||
status: 'CONTACTED',
|
||||
note: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = res.data || [];
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWinners() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/user-prizes', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
campaignId: filters.campaignId || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
winners.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openStatusModal(record: any) {
|
||||
statusForm.id = record.id;
|
||||
statusForm.winnerMobile = record.user?.mobile;
|
||||
statusForm.prizeTitle = record.prize?.title;
|
||||
statusForm.status = record.status;
|
||||
statusForm.note = '';
|
||||
isStatusModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitStatusUpdate() {
|
||||
isSubmittingStatus.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/user-prizes/${statusForm.id}/status`, {
|
||||
status: statusForm.status,
|
||||
note: statusForm.note || undefined,
|
||||
});
|
||||
message.success('وضعیت جایزه برنده با موفقیت بهروزرسانی شد.');
|
||||
isStatusModalVisible.value = false;
|
||||
loadWinners();
|
||||
} catch (err) {
|
||||
// Handled
|
||||
} finally {
|
||||
isSubmittingStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/user-prizes/export', {
|
||||
params: {
|
||||
campaignId: filters.campaignId || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response as any]));
|
||||
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);
|
||||
message.success('فایل اکسل برندگان با موفقیت دانلود شد.');
|
||||
} catch (err) {
|
||||
message.error('خطا در دریافت فایل اکسل برندگان');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'mobile' },
|
||||
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'name' },
|
||||
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
|
||||
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
|
||||
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
|
||||
{ title: 'کد دیجیتال / پیگیری', dataIndex: 'code', key: 'code' },
|
||||
{ title: 'وضعیت پیگیری', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'تاریخ برنده شدن', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: 'عملیات', key: 'actions', width: 100 },
|
||||
];
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
switch (status) {
|
||||
case 'NEW':
|
||||
return { text: 'جدید', color: 'blue' };
|
||||
case 'CONTACTED':
|
||||
return { text: 'تماس گرفته شده', color: 'orange' };
|
||||
case 'DELIVERED':
|
||||
return { text: 'تحویل داده شده', color: 'green' };
|
||||
case 'ACTIVE_CODE':
|
||||
return { text: 'کد فعال', color: 'cyan' };
|
||||
case 'USED':
|
||||
return { text: 'مصرف شده', color: 'purple' };
|
||||
case 'EXPIRED':
|
||||
return { text: 'منقضی شده', color: 'red' };
|
||||
default:
|
||||
return { text: status, color: 'default' };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadWinners();
|
||||
});
|
||||
</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>
|
||||
|
||||
<a-space>
|
||||
<a-button @click="loadWinners">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بهروزرسانی
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="exporting" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل برندگان
|
||||
</a-button>
|
||||
</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>
|
||||
|
||||
<template v-else-if="column.key === 'name'">
|
||||
{{ record.user?.name || '—' }}
|
||||
</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 === '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 size="small" type="link" @click="openStatusModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
تغییر وضعیت
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Update Status Modal -->
|
||||
<a-modal
|
||||
v-model:open="isStatusModalVisible"
|
||||
title="تغییر وضعیت پیگیری جایزه برنده"
|
||||
:confirmLoading="isSubmittingStatus"
|
||||
okText="ثبت تغییر وضعیت"
|
||||
cancelText="انصراف"
|
||||
@ok="submitStatusUpdate"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-alert
|
||||
:message="`برنده: ${statusForm.winnerMobile} | جایزه: ${statusForm.prizeTitle}`"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-form-item label="وضعیت جدید" required>
|
||||
<a-select v-model:value="statusForm.status">
|
||||
<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>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="یادداشت و توضیحات پیگیری">
|
||||
<a-textarea v-model:value="statusForm.note" rows="3" placeholder="مثال: کد رهگیری پست: 123456789 یا هماهنگی با کاربر" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user