first commit

This commit is contained in:
s.esmaeili
2026-09-10 01:58:29 +03:30
commit 077da7eacd
201 changed files with 25072 additions and 0 deletions
+31
View File
@@ -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.
+90
View File
@@ -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;
}
+166
View File
@@ -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>
+16
View File
@@ -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');
+105
View File
@@ -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();
}
});
+124
View File
@@ -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);
},
);
+66
View File
@@ -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,
};
});
+187
View File
@@ -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>
+114
View File
@@ -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>
+224
View File
@@ -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>
+168
View File
@@ -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>
+191
View File
@@ -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>
+302
View File
@@ -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>