chore: dockerize monorepo with multi-stage builds and update frontend

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