fix(core): resolve local client url redirects and stabilize dev environment
- enforce localhost:5174 for wheel campaign client URL in local development - lock vite dev server ports using strictPort on admin (5173) and client (5174) - sanitize .env.example and remove hardcoded staging domain - configure root dev orchestration and docker multi-stage build context
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AuditOutlined,
|
||||
DashboardOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoreOutlined,
|
||||
PieChartOutlined,
|
||||
ReloadOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const moreOpen = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'm-dashboard', label: 'داشبورد', icon: DashboardOutlined, path: '/m/dashboard' },
|
||||
{ key: 'm-campaigns', label: 'کمپینها', icon: PieChartOutlined, path: '/m/campaigns' },
|
||||
{ key: 'm-users', label: 'کاربران', icon: UserOutlined, path: '/m/users' },
|
||||
{ key: 'm-winners', label: 'برندگان', icon: TrophyOutlined, path: '/m/winners' },
|
||||
];
|
||||
|
||||
const moreItems = [
|
||||
{ key: 'm-spins', label: 'تاریخچه اسپین', icon: ReloadOutlined, path: '/m/spins' },
|
||||
{ key: 'm-sms', label: 'لاگ پیامک', icon: MessageOutlined, path: '/m/sms' },
|
||||
{ key: 'm-reports', label: 'گزارشها', icon: LineChartOutlined, path: '/m/reports' },
|
||||
{ key: 'm-audit', label: 'لاگ بازرسی', icon: AuditOutlined, path: '/m/audit' },
|
||||
];
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const name = String(route.name || '');
|
||||
if (tabs.some((t) => t.key === name || name.startsWith(t.key))) {
|
||||
if (name.startsWith('m-campaign')) return 'm-campaigns';
|
||||
if (name.startsWith('m-user')) return 'm-users';
|
||||
return name;
|
||||
}
|
||||
if (moreItems.some((t) => t.key === name)) return 'more';
|
||||
return name;
|
||||
});
|
||||
|
||||
const moreActive = computed(() => moreItems.some((i) => i.key === String(route.name)));
|
||||
|
||||
function go(path: string) {
|
||||
moreOpen.value = false;
|
||||
if (route.path !== path) router.push(path);
|
||||
}
|
||||
|
||||
function openMore() {
|
||||
moreOpen.value = true;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
moreOpen.value = false;
|
||||
await authStore.logout();
|
||||
router.push('/m/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="m-bottom-nav" aria-label="ناوبری اصلی">
|
||||
<div class="m-bottom-nav__inner">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="m-bottom-nav__item"
|
||||
:class="{ 'is-active': activeKey === tab.key }"
|
||||
@click="go(tab.path)"
|
||||
>
|
||||
<span class="m-bottom-nav__icon-wrap">
|
||||
<component :is="tab.icon" class="m-bottom-nav__icon" />
|
||||
</span>
|
||||
<span class="m-bottom-nav__label">{{ tab.label }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="m-bottom-nav__item"
|
||||
:class="{ 'is-active': moreActive || moreOpen }"
|
||||
@click="openMore"
|
||||
>
|
||||
<span class="m-bottom-nav__icon-wrap">
|
||||
<MoreOutlined class="m-bottom-nav__icon" />
|
||||
</span>
|
||||
<span class="m-bottom-nav__label">بیشتر</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-bottom-nav__safe" />
|
||||
</nav>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="moreOpen"
|
||||
placement="bottom"
|
||||
:height="'auto'"
|
||||
:closable="false"
|
||||
class="m-more-drawer"
|
||||
:body-style="{ padding: '12px 16px 24px' }"
|
||||
>
|
||||
<div class="m-more-handle" />
|
||||
<div class="m-more-title">منوی بیشتر</div>
|
||||
|
||||
<div class="m-more-grid">
|
||||
<button
|
||||
v-for="item in moreItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="m-more-item"
|
||||
:class="{ 'is-active': String(route.name) === item.key }"
|
||||
@click="go(item.path)"
|
||||
>
|
||||
<span class="m-more-item__icon">
|
||||
<component :is="item.icon" />
|
||||
</span>
|
||||
<span class="m-more-item__label">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="m-more-profile" v-if="authStore.admin">
|
||||
<div class="m-more-profile__info">
|
||||
<div class="m-more-profile__avatar">
|
||||
{{ (authStore.admin.name || 'ا').charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="m-more-profile__name">{{ authStore.admin.name }}</div>
|
||||
<div class="m-more-profile__email">{{ authStore.admin.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="m-more-logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 110;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(16px);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.m-bottom-nav__inner {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.m-bottom-nav__safe {
|
||||
height: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.m-bottom-nav__item {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
padding: 6px 2px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.m-bottom-nav__icon-wrap {
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.m-bottom-nav__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.m-bottom-nav__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.m-bottom-nav__item.is-active {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.m-bottom-nav__item.is-active .m-bottom-nav__icon-wrap {
|
||||
background: #eef2ff;
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
}
|
||||
|
||||
.m-bottom-nav__item:active .m-bottom-nav__icon-wrap {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.m-more-handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: #cbd5e1;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.m-more-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m-more-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.m-more-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 14px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m-more-item.is-active {
|
||||
border-color: #c7d2fe;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.m-more-item__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #4f46e5;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.m-more-item__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.m-more-profile {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m-more-profile__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-more-profile__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-more-profile__name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.m-more-profile__email {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.m-more-logout {
|
||||
border: none;
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
showBack?: boolean;
|
||||
transparent?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
showBack: false,
|
||||
transparent: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const canGoBack = computed(() => props.showBack);
|
||||
|
||||
function handleBack() {
|
||||
emit('back');
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/m/dashboard');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="m-topbar" :class="{ 'm-topbar--transparent': transparent }">
|
||||
<div class="m-topbar__safe" />
|
||||
<div class="m-topbar__inner">
|
||||
<div class="m-topbar__side m-topbar__side--start">
|
||||
<button
|
||||
v-if="canGoBack"
|
||||
type="button"
|
||||
class="m-topbar__btn"
|
||||
aria-label="بازگشت"
|
||||
@click="handleBack"
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
</button>
|
||||
<slot name="start" />
|
||||
</div>
|
||||
|
||||
<h1 class="m-topbar__title">{{ title }}</h1>
|
||||
|
||||
<div class="m-topbar__side m-topbar__side--end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 55%, #7c3aed 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
|
||||
.m-topbar--transparent {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m-topbar__safe {
|
||||
height: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
|
||||
.m-topbar__inner {
|
||||
height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 72px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.m-topbar__title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.m-topbar__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.m-topbar__side--start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m-topbar__side--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m-topbar__btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-topbar__btn:active {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
:deep(.m-topbar-action) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.m-topbar-action:active) {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
const MOBILE_UA =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet|tablet/i;
|
||||
|
||||
/** Include tablets (iPad / 768–1024) in mobile app experience */
|
||||
const MOBILE_BREAKPOINT = 1024;
|
||||
|
||||
function detectMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const byUa = MOBILE_UA.test(navigator.userAgent);
|
||||
const byWidth = window.innerWidth <= MOBILE_BREAKPOINT;
|
||||
// Prefer width so Chrome device toolbar always switches even if UA is desktop
|
||||
return byWidth || byUa;
|
||||
}
|
||||
|
||||
const isMobile = ref(detectMobile());
|
||||
|
||||
let listenersAttached = false;
|
||||
let boundRouter: Router | null = null;
|
||||
|
||||
const desktopToMobileRoute: Record<string, string> = {
|
||||
login: 'm-login',
|
||||
dashboard: 'm-dashboard',
|
||||
campaigns: 'm-campaigns',
|
||||
'campaign-create': 'm-campaign-create',
|
||||
'campaign-edit': 'm-campaign-edit',
|
||||
'campaign-prizes': 'm-campaign-prizes',
|
||||
users: 'm-users',
|
||||
'user-detail': 'm-user-detail',
|
||||
spins: 'm-spins',
|
||||
winners: 'm-winners',
|
||||
sms: 'm-sms',
|
||||
reports: 'm-reports',
|
||||
audit: 'm-audit',
|
||||
};
|
||||
|
||||
const mobileToDesktopRoute: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(desktopToMobileRoute).map(([d, m]) => [m, d]),
|
||||
);
|
||||
|
||||
export { desktopToMobileRoute, mobileToDesktopRoute };
|
||||
|
||||
function syncRouteToDevice() {
|
||||
if (!boundRouter) return;
|
||||
|
||||
const mobile = detectMobile();
|
||||
isMobile.value = mobile;
|
||||
|
||||
const route = boundRouter.currentRoute.value;
|
||||
const isMobileRoute = route.path.startsWith('/m') || !!route.meta.mobile;
|
||||
const name = String(route.name || '');
|
||||
|
||||
if (mobile && !isMobileRoute) {
|
||||
if (name === 'login') {
|
||||
boundRouter.replace({ name: 'm-login', query: route.query });
|
||||
return;
|
||||
}
|
||||
const mapped = desktopToMobileRoute[name];
|
||||
if (mapped) {
|
||||
boundRouter.replace({ name: mapped, params: route.params, query: route.query });
|
||||
} else {
|
||||
boundRouter.replace('/m/dashboard');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mobile && isMobileRoute) {
|
||||
if (name === 'm-login') {
|
||||
boundRouter.replace({ name: 'login', query: route.query });
|
||||
return;
|
||||
}
|
||||
const mapped = mobileToDesktopRoute[name];
|
||||
if (mapped) {
|
||||
boundRouter.replace({ name: mapped, params: route.params, query: route.query });
|
||||
} else {
|
||||
boundRouter.replace('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const next = detectMobile();
|
||||
if (next === isMobile.value) return;
|
||||
isMobile.value = next;
|
||||
syncRouteToDevice();
|
||||
}
|
||||
|
||||
function ensureListeners() {
|
||||
if (listenersAttached || typeof window === 'undefined') return;
|
||||
window.addEventListener('resize', onResize);
|
||||
// Chrome device-mode sometimes fires orientationchange without a reliable resize
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
listenersAttached = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once from main.ts so resize/device-toolbar switches layouts live.
|
||||
*/
|
||||
export function bindDeviceRouter(router: Router) {
|
||||
boundRouter = router;
|
||||
isMobile.value = detectMobile();
|
||||
ensureListeners();
|
||||
// Initial sync after first navigation settles
|
||||
router.isReady().then(() => syncRouteToDevice());
|
||||
}
|
||||
|
||||
export function useDevice() {
|
||||
onMounted(() => {
|
||||
isMobile.value = detectMobile();
|
||||
ensureListeners();
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
ensureListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkIsMobile(): boolean {
|
||||
return detectMobile();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import MobileBottomNav from '../components/mobile/MobileBottomNav.vue';
|
||||
import MobileTopBar from '../components/mobile/MobileTopBar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
'm-dashboard': 'داشبورد',
|
||||
'm-campaigns': 'کمپینها',
|
||||
'm-campaign-create': 'ایجاد کمپین',
|
||||
'm-campaign-edit': 'ویرایش کمپین',
|
||||
'm-campaign-prizes': 'مدیریت جوایز',
|
||||
'm-users': 'کاربران',
|
||||
'm-user-detail': 'جزئیات کاربر',
|
||||
'm-spins': 'اسپینها',
|
||||
'm-winners': 'برندگان',
|
||||
'm-sms': 'لاگ پیامک',
|
||||
'm-reports': 'گزارشها',
|
||||
'm-audit': 'لاگ بازرسی',
|
||||
};
|
||||
|
||||
const hideBottomNavRoutes = new Set(['m-campaign-create', 'm-campaign-edit', 'm-user-detail']);
|
||||
const showBackRoutes = new Set([
|
||||
'm-campaign-create',
|
||||
'm-campaign-edit',
|
||||
'm-campaign-prizes',
|
||||
'm-user-detail',
|
||||
]);
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const custom = route.meta.title as string | undefined;
|
||||
if (custom) return custom;
|
||||
return titles[String(route.name)] || 'پنل مدیریت';
|
||||
});
|
||||
|
||||
const showBottomNav = computed(() => !hideBottomNavRoutes.has(String(route.name)));
|
||||
const showBack = computed(() => showBackRoutes.has(String(route.name)) || !!route.meta.showBack);
|
||||
const hideTopBar = computed(() => !!route.meta.hideTopBar);
|
||||
|
||||
const transitionName = ref('m-slide-left');
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(to, from) => {
|
||||
const toDepth = to.split('/').filter(Boolean).length;
|
||||
const fromDepth = (from || '').split('/').filter(Boolean).length;
|
||||
transitionName.value = toDepth >= fromDepth ? 'm-slide-left' : 'm-slide-right';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-shell" :class="{ 'mobile-shell--no-nav': !showBottomNav }">
|
||||
<MobileTopBar
|
||||
v-if="!hideTopBar"
|
||||
:title="pageTitle"
|
||||
:show-back="showBack"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="top-actions" />
|
||||
</template>
|
||||
</MobileTopBar>
|
||||
|
||||
<main class="mobile-shell__content">
|
||||
<router-view v-slot="{ Component, route: r }">
|
||||
<transition :name="transitionName" mode="out-in">
|
||||
<div :key="r.fullPath" class="mobile-shell__page">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<MobileBottomNav v-if="showBottomNav" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #f4f6fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
|
||||
}
|
||||
|
||||
.mobile-shell--no-nav .mobile-shell__content {
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.mobile-shell__page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Slide animations (RTL-aware: left = forward into stack) */
|
||||
.m-slide-left-enter-active,
|
||||
.m-slide-left-leave-active,
|
||||
.m-slide-right-enter-active,
|
||||
.m-slide-right-leave-active {
|
||||
transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.m-slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
|
||||
.m-slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.m-slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.m-slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import './assets/fonts/fonts.css';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { bindDeviceRouter } from './composables/useDevice';
|
||||
import { router } from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
@@ -13,4 +14,6 @@ app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(Antd);
|
||||
|
||||
bindDeviceRouter(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
+139
-13
@@ -1,6 +1,11 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import AdminLayout from '../layouts/AdminLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import MobileLayout from '../layouts/MobileLayout.vue';
|
||||
import {
|
||||
checkIsMobile,
|
||||
desktopToMobileRoute,
|
||||
mobileToDesktopRoute,
|
||||
} from '../composables/useDevice';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -9,15 +14,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/auth/LoginView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/m/login',
|
||||
name: 'm-login',
|
||||
component: () => import('../views/mobile/MLoginView.vue'),
|
||||
meta: { guestOnly: true, mobile: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
@@ -80,9 +88,77 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/m',
|
||||
component: MobileLayout,
|
||||
meta: { requiresAuth: true, mobile: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/m/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'm-dashboard',
|
||||
component: () => import('../views/mobile/MDashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns',
|
||||
name: 'm-campaigns',
|
||||
component: () => import('../views/mobile/MCampaignListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/create',
|
||||
name: 'm-campaign-create',
|
||||
component: () => import('../views/mobile/MCampaignFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/:id/edit',
|
||||
name: 'm-campaign-edit',
|
||||
component: () => import('../views/mobile/MCampaignFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'campaigns/:campaignId/prizes',
|
||||
name: 'm-campaign-prizes',
|
||||
component: () => import('../views/mobile/MPrizeListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'm-users',
|
||||
component: () => import('../views/mobile/MUserListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users/:id',
|
||||
name: 'm-user-detail',
|
||||
component: () => import('../views/mobile/MUserDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'spins',
|
||||
name: 'm-spins',
|
||||
component: () => import('../views/mobile/MSpinListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'winners',
|
||||
name: 'm-winners',
|
||||
component: () => import('../views/mobile/MWinnerListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'm-sms',
|
||||
component: () => import('../views/mobile/MSmsLogView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'm-reports',
|
||||
component: () => import('../views/mobile/MReportView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'm-audit',
|
||||
component: () => import('../views/mobile/MAuditLogView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/dashboard',
|
||||
redirect: () => (checkIsMobile() ? '/m/dashboard' : '/dashboard'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -91,15 +167,65 @@ export const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
function toMobilePath(to: { name?: string | symbol | null; params: Record<string, any>; query: Record<string, any> }) {
|
||||
const name = String(to.name || '');
|
||||
const mobileName = desktopToMobileRoute[name];
|
||||
if (!mobileName) return null;
|
||||
return { name: mobileName, params: to.params, query: to.query };
|
||||
}
|
||||
|
||||
function toDesktopPath(to: { name?: string | symbol | null; params: Record<string, any>; query: Record<string, any> }) {
|
||||
const name = String(to.name || '');
|
||||
const desktopName = mobileToDesktopRoute[name];
|
||||
if (!desktopName) return null;
|
||||
return { name: desktopName, params: to.params, query: to.query };
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const token = localStorage.getItem('admin_access_token');
|
||||
const mobile = checkIsMobile();
|
||||
const isMobileRoute = to.path.startsWith('/m') || !!to.meta.mobile;
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login');
|
||||
} else if (to.meta.guestOnly && token) {
|
||||
next('/dashboard');
|
||||
} else {
|
||||
next();
|
||||
next(mobile ? '/m/login' : '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && token) {
|
||||
next(mobile ? '/m/dashboard' : '/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Device-aware redirects between desktop and mobile route trees
|
||||
if (token || to.meta.guestOnly) {
|
||||
if (mobile && !isMobileRoute) {
|
||||
if (to.name === 'login') {
|
||||
next({ name: 'm-login', query: to.query });
|
||||
return;
|
||||
}
|
||||
const mapped = toMobilePath(to as any);
|
||||
if (mapped) {
|
||||
next(mapped);
|
||||
return;
|
||||
}
|
||||
next('/m/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mobile && isMobileRoute) {
|
||||
if (to.name === 'm-login') {
|
||||
next({ name: 'login', query: to.query });
|
||||
return;
|
||||
}
|
||||
const mapped = toDesktopPath(to as any);
|
||||
if (mapped) {
|
||||
next(mapped);
|
||||
return;
|
||||
}
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
|
||||
/** Jalali date+time display for admin tables */
|
||||
export function formatFarsiDateTime(val?: string | Date | null): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
const dateStr = d.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return toFarsiDigits(`${dateStr} - ${timeStr}`);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/** Jalali date only */
|
||||
export function formatFarsiDate(val?: string | Date | null): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
return toFarsiDigits(
|
||||
d.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export const prizeTypeMap: Record<string, { text: string; color: string }> = {
|
||||
PHYSICAL: { text: 'فیزیکی', color: 'blue' },
|
||||
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
|
||||
CREDIT: { text: 'اعتبار', color: 'purple' },
|
||||
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
|
||||
CASH: { text: 'نقدی', color: 'gold' },
|
||||
NO_PRIZE: { text: 'پوچ', color: 'default' },
|
||||
OTHER: { text: 'سایر', color: 'cyan' },
|
||||
};
|
||||
|
||||
export function getPrizeTypeTag(type: string) {
|
||||
return prizeTypeMap[type] || { text: type, color: 'blue' };
|
||||
}
|
||||
|
||||
export const winnerStatusMap: Record<string, { text: string; color: string }> = {
|
||||
NEW: { text: 'جدید', color: 'blue' },
|
||||
PENDING: { text: 'در انتظار', color: 'warning' },
|
||||
CONTACTED: { text: 'تماس گرفتهشده', color: 'cyan' },
|
||||
DELIVERED: { text: 'تحویل داده شده', color: 'success' },
|
||||
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
|
||||
USED: { text: 'استفاده شده', color: 'green' },
|
||||
CANCELLED: { text: 'لغو شده', color: 'error' },
|
||||
EXPIRED: { text: 'منقضی شده', color: 'default' },
|
||||
};
|
||||
|
||||
export function getCampaignStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return { color: 'green', dotColor: '#10b981', text: 'فعال' };
|
||||
case 'PAUSED':
|
||||
return { color: 'orange', dotColor: '#f59e0b', text: 'متوقف' };
|
||||
case 'FINISHED':
|
||||
return { color: 'red', dotColor: '#ef4444', text: 'پایانیافته' };
|
||||
case 'DRAFT':
|
||||
return { color: 'gold', dotColor: '#d97706', text: 'پیشنویس' };
|
||||
default:
|
||||
return { color: 'default', dotColor: '#94a3b8', text: status };
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize list API responses that may be array or { data, meta } */
|
||||
export function unwrapListResponse<T = any>(res: any): { items: T[]; total: number } {
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
const total = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
return { items, total };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Public URL of the client app (wheel page).
|
||||
* Local browsing must never open production domains like wheel.montest.ir.
|
||||
*/
|
||||
|
||||
const LOCAL_CLIENT_ORIGIN = 'http://localhost:5174';
|
||||
|
||||
function isLocalBrowsingContext(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const href = String(window.location.href || '');
|
||||
const host = String(window.location.hostname || '');
|
||||
const port = String(window.location.port || '');
|
||||
|
||||
if (href.includes('localhost') || href.includes('127.0.0.1') || href.includes('[::1]')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common local admin ports (Vite / Docker compose mappings)
|
||||
if (port === '5173' || port === '5174' || port === '8080') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(import.meta.env.DEV);
|
||||
}
|
||||
|
||||
function normalizeSlug(slug: string): string {
|
||||
const trimmed = String(slug || '').trim().replace(/^\/+/, '');
|
||||
if (!trimmed) return '';
|
||||
|
||||
// If a full URL was stored as slug, keep only the last path segment
|
||||
if (/^https?:\/\//i.test(trimmed) || trimmed.includes('wheel.montest.ir')) {
|
||||
try {
|
||||
const asUrl = /^https?:\/\//i.test(trimmed)
|
||||
? new URL(trimmed)
|
||||
: new URL(`https://${trimmed}`);
|
||||
const parts = asUrl.pathname.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
} catch {
|
||||
const parts = trimmed.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function getClientCampaignUrl(slug: string): string {
|
||||
const cleanSlug = normalizeSlug(slug);
|
||||
|
||||
// Absolute local rule: never return production domains while browsing locally
|
||||
if (isLocalBrowsingContext()) {
|
||||
return cleanSlug ? `${LOCAL_CLIENT_ORIGIN}/${cleanSlug}` : `${LOCAL_CLIENT_ORIGIN}/`;
|
||||
}
|
||||
|
||||
const configured = (import.meta.env.VITE_CLIENT_PUBLIC_URL as string | undefined)?.replace(/\/$/, '');
|
||||
if (configured) {
|
||||
// Safety net: if a local build somehow got a production URL, still refuse montest on local hosts
|
||||
if (configured.includes('wheel.montest.ir') && isLocalBrowsingContext()) {
|
||||
return cleanSlug ? `${LOCAL_CLIENT_ORIGIN}/${cleanSlug}` : `${LOCAL_CLIENT_ORIGIN}/`;
|
||||
}
|
||||
return cleanSlug ? `${configured}/${cleanSlug}` : `${configured}/`;
|
||||
}
|
||||
|
||||
return cleanSlug ? `/${cleanSlug}` : '/';
|
||||
}
|
||||
|
||||
export function openClientCampaign(slug: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const targetUrl = getClientCampaignUrl(slug);
|
||||
console.log('CLICKED WHEEL LINK:', targetUrl);
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { checkIsMobile } from '../../composables/useDevice';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -21,7 +22,7 @@ async function onFinish() {
|
||||
email: formState.email,
|
||||
password: formState.password,
|
||||
});
|
||||
router.push('/dashboard');
|
||||
router.push(checkIsMobile() ? '/m/dashboard' : '/dashboard');
|
||||
} catch (err) {
|
||||
// Handled by axios interceptor
|
||||
} finally {
|
||||
|
||||
@@ -15,9 +15,22 @@ 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';
|
||||
import {
|
||||
formatFarsiDateTime,
|
||||
getCampaignStatusInfo,
|
||||
getPrizeTypeTag,
|
||||
unwrapListResponse,
|
||||
} from '../../utils/adminListUtils';
|
||||
import { getClientCampaignUrl, openClientCampaign } from '../../utils/clientUrl';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function onViewWheelClick(slug: string) {
|
||||
const targetUrl = getClientCampaignUrl(slug);
|
||||
console.log('CLICKED WHEEL LINK:', targetUrl);
|
||||
openClientCampaign(slug);
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -40,9 +53,9 @@ async function loadCampaigns() {
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
const { items, total: t } = unwrapListResponse(res);
|
||||
campaigns.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
total.value = t;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -90,34 +103,38 @@ const wheelDesignLabels: Record<string, string> = {
|
||||
LUXURY_GOLD: 'لاکچری طلایی',
|
||||
FESTIVAL: 'جشنوارهای شاد',
|
||||
PLAYFUL_CARTOON: 'کارتونی شاد',
|
||||
RUSTIC_WOOD: 'چوبی',
|
||||
GALAXY_SPACE: 'کهکشانی',
|
||||
CANDY_LAND: 'آبنباتی',
|
||||
FIRE_FLAME: 'آتشین',
|
||||
CRYSTAL_ICE: 'یخی',
|
||||
};
|
||||
|
||||
function getUniquePrizeTypes(record: any): string[] {
|
||||
if (Array.isArray(record.prizeTypes) && record.prizeTypes.length) {
|
||||
return record.prizeTypes;
|
||||
}
|
||||
if (Array.isArray(record.prizes)) {
|
||||
return Array.from(new Set(record.prizes.map((p: any) => p.type).filter(Boolean)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'عنوان کمپین', dataIndex: 'title', key: 'title' },
|
||||
{ title: 'اسلاگ (URL)', dataIndex: 'slug', key: 'slug' },
|
||||
{ title: 'طرح گردونه', dataIndex: 'wheelDesign', key: 'wheelDesign' },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'شروع', dataIndex: 'startAt', key: 'startAt' },
|
||||
{ title: 'پایان', dataIndex: 'endAt', key: 'endAt' },
|
||||
{ title: 'تاریخ و ساعت شروع', dataIndex: 'startAt', key: 'startAt' },
|
||||
{ title: 'تاریخ و ساعت پایان', dataIndex: 'endAt', key: 'endAt' },
|
||||
{ title: 'نوع جوایز', key: 'prizeTypes' },
|
||||
{ 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', dotColor: '#10b981', text: 'فعال' };
|
||||
case 'PAUSED':
|
||||
return { color: 'orange', dotColor: '#f59e0b', text: 'متوقف' };
|
||||
case 'FINISHED':
|
||||
return { color: 'red', dotColor: '#ef4444', text: 'پایانیافته' };
|
||||
case 'DRAFT':
|
||||
return { color: 'gold', dotColor: '#d97706', text: 'پیشنویس' };
|
||||
default:
|
||||
return { color: 'default', dotColor: '#94a3b8', text: status };
|
||||
}
|
||||
}
|
||||
const getStatusInfo = getCampaignStatusInfo;
|
||||
const formatJalaliDateTime = formatFarsiDateTime;
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
@@ -206,11 +223,25 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'startAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
<span class="date-cell">{{ record.startAtFormatted ? toFarsiDigits(record.startAtFormatted) : formatJalaliDateTime(record.startAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'endAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}</span>
|
||||
<span class="date-cell">{{ record.endAtFormatted ? toFarsiDigits(record.endAtFormatted) : formatJalaliDateTime(record.endAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizeTypes'">
|
||||
<div class="prize-tags-container" v-if="getUniquePrizeTypes(record).length">
|
||||
<a-tag
|
||||
v-for="pType in getUniquePrizeTypes(record)"
|
||||
:key="pType"
|
||||
:color="getPrizeTypeTag(pType).color"
|
||||
style="margin: 2px; border-radius: 4px; font-size: 11px"
|
||||
>
|
||||
{{ getPrizeTypeTag(pType).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<span v-else class="text-muted" style="color: #94a3b8; font-size: 12px">تعریفنشده</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizesCount'">
|
||||
@@ -228,15 +259,11 @@ onMounted(() => {
|
||||
</a-button>
|
||||
<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"
|
||||
>
|
||||
<a-menu-item key="view" @click="onViewWheelClick(record.slug)">
|
||||
<div class="dropdown-action-item">
|
||||
<EyeOutlined class="dropdown-item-icon" />
|
||||
<span>مشاهده گردونه</span>
|
||||
</a>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit" @click="router.push(`/campaigns/${record.id}/edit`)">
|
||||
<div class="dropdown-action-item">
|
||||
|
||||
@@ -94,6 +94,76 @@ const designs = [
|
||||
centerColor: '#facc15',
|
||||
centerText: '✨',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.RUSTIC_WOOD,
|
||||
title: 'چوبی روستایی (Rustic Wood)',
|
||||
badge: 'طبیعی و گرم',
|
||||
badgeColor: 'orange',
|
||||
desc: 'بافت چوب طبیعی، پیچهای فلزی، رنگهای قهوهای گرم و حس دستساز سنتی',
|
||||
bg: 'radial-gradient(circle, #5c3a1e 0%, #2a160c 100%)',
|
||||
accent: '#c9a36a',
|
||||
border: '#8d6e63',
|
||||
slices: ['#8B5A2B', '#A0522D', '#6B4226', '#C4A484', '#8B5A2B', '#5D4037'],
|
||||
pointerColor: '#8d6e63',
|
||||
centerColor: '#a1887f',
|
||||
centerText: '🪵',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.GALAXY_SPACE,
|
||||
title: 'کهکشانی (Galaxy Space)',
|
||||
badge: 'فضایی و مدرن',
|
||||
badgeColor: 'purple',
|
||||
desc: 'پسزمینه فضایی با ستارهها، گرادیان بنفش-آبی و حلقه نورانی کهکشانی',
|
||||
bg: 'radial-gradient(circle, #1e1b4b 0%, #050816 100%)',
|
||||
accent: '#a78bfa',
|
||||
border: '#6366f1',
|
||||
slices: ['#1e1b4b', '#312e81', '#4c1d95', '#0f172a', '#1e3a8a', '#581c87'],
|
||||
pointerColor: '#a78bfa',
|
||||
centerColor: '#818cf8',
|
||||
centerText: '🌌',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.CANDY_LAND,
|
||||
title: 'آبنباتی (Candy Land)',
|
||||
badge: 'شاد و کودکانه',
|
||||
badgeColor: 'pink',
|
||||
desc: 'رنگهای پاستل صورتی و زرد، حاشیه ابری و حس شیرین و بازیگوش',
|
||||
bg: 'radial-gradient(circle, #ffe4e6 0%, #fbcfe8 100%)',
|
||||
accent: '#fb7185',
|
||||
border: '#f9a8d4',
|
||||
slices: ['#fb7185', '#fbbf24', '#34d399', '#60a5fa', '#c084fc', '#f472b6'],
|
||||
pointerColor: '#fb7185',
|
||||
centerColor: '#fef08a',
|
||||
centerText: '🍬',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.FIRE_FLAME,
|
||||
title: 'آتشین (Fire Flame)',
|
||||
badge: 'انرژی و هیجان',
|
||||
badgeColor: 'volcano',
|
||||
desc: 'قرمز و نارنجی درخشان، شعلههای SVG روی حاشیه و مرکز اخگر',
|
||||
bg: 'radial-gradient(circle, #7f1d1d 0%, #1a0505 100%)',
|
||||
accent: '#f97316',
|
||||
border: '#dc2626',
|
||||
slices: ['#7f1d1d', '#b91c1c', '#ea580c', '#c2410c', '#9a3412', '#450a0a'],
|
||||
pointerColor: '#f97316',
|
||||
centerColor: '#fdba74',
|
||||
centerText: '🔥',
|
||||
},
|
||||
{
|
||||
id: WheelDesign.CRYSTAL_ICE,
|
||||
title: 'یخی کریستالی (Crystal Ice)',
|
||||
badge: 'خنک و لوکس',
|
||||
badgeColor: 'blue',
|
||||
desc: 'آبی یخی شفاف، کریستالهای لبه و حس زمستانی و کریستالی',
|
||||
bg: 'radial-gradient(circle, #0ea5e9 0%, #082f49 100%)',
|
||||
accent: '#7dd3fc',
|
||||
border: '#38bdf8',
|
||||
slices: ['#0ea5e9', '#38bdf8', '#7dd3fc', '#0284c7', '#e0f2fe', '#0369a1'],
|
||||
pointerColor: '#38bdf8',
|
||||
centerColor: '#e0f2fe',
|
||||
centerText: '❄️',
|
||||
},
|
||||
];
|
||||
|
||||
function selectDesign(id: WheelDesign) {
|
||||
|
||||
@@ -262,6 +262,11 @@ const wheelDesignMap: Record<string, string> = {
|
||||
MINIMAL_FLAT: 'مینیمال فلت',
|
||||
LUXURY_GOLD: 'طلایی لوکس',
|
||||
PLAYFUL_CARTOON: 'کارتونی فانتزی',
|
||||
RUSTIC_WOOD: 'چوبی',
|
||||
GALAXY_SPACE: 'کهکشانی',
|
||||
CANDY_LAND: 'آبنباتی',
|
||||
FIRE_FLAME: 'آتشین',
|
||||
CRYSTAL_ICE: 'یخی',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const detailOpen = ref(false);
|
||||
const selected = ref<any>(null);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
entityType: undefined as string | undefined,
|
||||
});
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/audit-logs', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
entityType: filters.entityType || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
logs.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(log: any) {
|
||||
selected.value = log;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function actionColor(action: string) {
|
||||
const a = (action || '').toUpperCase();
|
||||
if (a.includes('CREATE') || a.includes('ADD')) return 'green';
|
||||
if (a.includes('UPDATE') || a.includes('EDIT') || a.includes('PATCH')) return 'blue';
|
||||
if (a.includes('DELETE') || a.includes('REMOVE')) return 'red';
|
||||
if (a.includes('LOGIN') || a.includes('AUTH')) return 'purple';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(
|
||||
new Date(val).toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-filters">
|
||||
<a-select
|
||||
v-model:value="filters.entityType"
|
||||
allow-clear
|
||||
placeholder="نوع موجودیت"
|
||||
style="flex: 1"
|
||||
@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>
|
||||
<button type="button" class="m-icon-btn" @click="loadLogs">
|
||||
<ReloadOutlined :spin="loading" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!logs.length && !loading" class="m-empty">لاگی یافت نشد</div>
|
||||
<div v-for="log in logs" :key="log.id" class="m-card">
|
||||
<div class="m-card__top">
|
||||
<div>
|
||||
<div class="m-card__admin">{{ log.admin?.name || log.admin?.email || 'سیستم' }}</div>
|
||||
<div class="m-card__entity">{{ log.entityType }} · {{ log.entityId || '—' }}</div>
|
||||
</div>
|
||||
<a-tag :color="actionColor(log.action)">{{ log.action }}</a-tag>
|
||||
</div>
|
||||
<div class="m-card__meta">
|
||||
<span dir="ltr">{{ log.ipAddress || '—' }}</span>
|
||||
<span>{{ formatDate(log.createdAt) }}</span>
|
||||
</div>
|
||||
<button type="button" class="m-detail" @click="openDetail(log)">
|
||||
<EyeOutlined /> جزئیات JSON
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination
|
||||
v-model:current="filters.page"
|
||||
:total="total"
|
||||
:page-size="filters.pageSize"
|
||||
size="small"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer v-model:open="detailOpen" placement="bottom" :height="'70%'" title="جزئیات لاگ">
|
||||
<div v-if="selected" class="detail">
|
||||
<div class="detail__row"><span>ادمین</span><strong>{{ selected.admin?.name || '—' }}</strong></div>
|
||||
<div class="detail__row"><span>اکشن</span><strong>{{ selected.action }}</strong></div>
|
||||
<div class="detail__row"><span>موجودیت</span><strong>{{ selected.entityType }}</strong></div>
|
||||
<h4>قبل</h4>
|
||||
<pre>{{ JSON.stringify(selected.oldValues || {}, null, 2) }}</pre>
|
||||
<h4>بعد</h4>
|
||||
<pre>{{ JSON.stringify(selected.newValues || {}, null, 2) }}</pre>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-filters { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
|
||||
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
|
||||
.m-card__top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
.m-card__admin { font-weight: 800; font-size: 13px; color: #0f172a; }
|
||||
.m-card__entity { font-size: 11px; color: #94a3b8; margin-top: 2px; direction: ltr; text-align: right; }
|
||||
.m-card__meta { display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
|
||||
.m-detail {
|
||||
margin-top: 10px; width: 100%; border: 1px solid #e2e8f0; background: #f8fafc;
|
||||
border-radius: 10px; padding: 10px; font-weight: 700; font-size: 12px; color: #334155; min-height: 42px;
|
||||
}
|
||||
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
|
||||
.detail__row {
|
||||
display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; color: #64748b;
|
||||
}
|
||||
.detail__row strong { color: #0f172a; }
|
||||
.detail h4 { margin: 16px 0 8px; font-size: 13px; }
|
||||
.detail pre {
|
||||
background: #0f172a; color: #e2e8f0; border-radius: 12px; padding: 12px;
|
||||
font-size: 11px; overflow: auto; direction: ltr; text-align: left; max-height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { WheelDesign } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isEdit = ref(false);
|
||||
const campaignId = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const step = ref(0);
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
banner: '',
|
||||
logo: '',
|
||||
dateRange: [] as Dayjs[],
|
||||
status: 'DRAFT',
|
||||
defaultChances: 3,
|
||||
rules: '',
|
||||
ctaText: 'بچرخون و برنده شو!',
|
||||
wheelDesign: WheelDesign.CLASSIC_CASINO as string,
|
||||
primaryColor: '#FFD700',
|
||||
secondaryColor: '#B22222',
|
||||
});
|
||||
|
||||
const wheelOptions = [
|
||||
{ value: WheelDesign.CLASSIC_CASINO, label: 'کازینویی کلاسیک' },
|
||||
{ value: WheelDesign.NEON_CYBER, label: 'نئونی سایبر' },
|
||||
{ value: WheelDesign.MINIMAL_FLAT, label: 'مینیمال فلت' },
|
||||
{ value: WheelDesign.LUXURY_GOLD, label: 'طلایی لوکس' },
|
||||
{ value: WheelDesign.PLAYFUL_CARTOON, label: 'کارتونی' },
|
||||
{ value: WheelDesign.RUSTIC_WOOD, label: 'چوبی' },
|
||||
{ value: WheelDesign.GALAXY_SPACE, label: 'کهکشانی' },
|
||||
{ value: WheelDesign.CANDY_LAND, label: 'آبنباتی' },
|
||||
{ value: WheelDesign.FIRE_FLAME, label: 'آتشین' },
|
||||
{ value: WheelDesign.CRYSTAL_ICE, label: 'یخی' },
|
||||
];
|
||||
|
||||
async function loadCampaign() {
|
||||
if (!campaignId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
|
||||
const c = res.data;
|
||||
formState.title = c.title;
|
||||
formState.slug = c.slug;
|
||||
formState.description = c.description || '';
|
||||
formState.banner = c.banner || '';
|
||||
formState.logo = c.logo || '';
|
||||
formState.status = c.status;
|
||||
formState.defaultChances = c.defaultChances;
|
||||
formState.rules = c.rules || '';
|
||||
formState.ctaText = c.ctaText || 'بچرخون و برنده شو!';
|
||||
formState.wheelDesign = c.wheelDesign;
|
||||
formState.dateRange = [dayjs(c.startAt), dayjs(c.endAt)];
|
||||
if (c.settings) {
|
||||
formState.primaryColor = c.settings.primaryColor || '#FFD700';
|
||||
formState.secondaryColor = c.settings.secondaryColor || '#B22222';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step.value === 0) {
|
||||
if (!formState.title?.trim()) {
|
||||
message.error('عنوان کمپین را وارد کنید');
|
||||
return;
|
||||
}
|
||||
if (!formState.slug?.trim()) {
|
||||
formState.slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
}
|
||||
if (step.value === 1) {
|
||||
if (!formState.dateRange || formState.dateRange.length < 2) {
|
||||
message.error('بازه زمانی را انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
}
|
||||
step.value += 1;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
let slug = (formState.slug || '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||
if (!slug) slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
|
||||
formState.slug = slug;
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
message.error('اسلاگ فقط حروف انگلیسی، عدد و -');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
title: formState.title.trim(),
|
||||
slug: formState.slug.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
banner: formState.banner ? formState.banner.trim() : null,
|
||||
logo: formState.logo ? formState.logo.trim() : null,
|
||||
startAt: formState.dateRange[0].toISOString(),
|
||||
endAt: formState.dateRange[1].toISOString(),
|
||||
status: formState.status,
|
||||
defaultChances: Number(formState.defaultChances) || 0,
|
||||
rules: formState.rules?.trim() || undefined,
|
||||
ctaText: formState.ctaText || 'بچرخون و برنده شو!',
|
||||
wheelDesign: formState.wheelDesign,
|
||||
settings: {
|
||||
primaryColor: formState.primaryColor,
|
||||
secondaryColor: formState.secondaryColor,
|
||||
},
|
||||
};
|
||||
|
||||
if (isEdit.value && campaignId.value) {
|
||||
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, payload);
|
||||
message.success('کمپین بهروزرسانی شد');
|
||||
router.push('/m/campaigns');
|
||||
} else {
|
||||
const res = await apiClient.post('/admin/campaigns', payload);
|
||||
message.success('کمپین ایجاد شد');
|
||||
const createdId = res.data?.id;
|
||||
router.push(createdId ? `/m/campaigns/${createdId}/prizes` : '/m/campaigns');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || 'خطا در ذخیره';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
isEdit.value = true;
|
||||
campaignId.value = route.params.id as string;
|
||||
loadCampaign();
|
||||
} else {
|
||||
formState.dateRange = [dayjs(), dayjs().add(1, 'month')];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-form-page">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-steps">
|
||||
<div class="m-step" :class="{ active: step === 0, done: step > 0 }">۱. پایه</div>
|
||||
<div class="m-step" :class="{ active: step === 1, done: step > 1 }">۲. زمان</div>
|
||||
<div class="m-step" :class="{ active: step === 2 }">۳. ظاهر</div>
|
||||
</div>
|
||||
|
||||
<div v-show="step === 0" class="m-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="عنوان کمپین" required>
|
||||
<a-input v-model:value="formState.title" size="large" placeholder="مثلاً جشنواره نوروز" />
|
||||
</a-form-item>
|
||||
<a-form-item label="اسلاگ (URL)">
|
||||
<a-input v-model:value="formState.slug" size="large" placeholder="nowruz-2026" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select v-model:value="formState.status" size="large" style="width: 100%">
|
||||
<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>
|
||||
</a-form-item>
|
||||
<a-form-item label="شانس پیشفرض">
|
||||
<a-input-number v-model:value="formState.defaultChances" :min="0" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div v-show="step === 1" class="m-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="بازه زمانی" required>
|
||||
<a-range-picker
|
||||
v-model:value="formState.dateRange"
|
||||
show-time
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="متن دکمه CTA">
|
||||
<a-input v-model:value="formState.ctaText" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="قوانین">
|
||||
<a-textarea v-model:value="formState.rules" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div v-show="step === 2" class="m-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="طرح گردونه">
|
||||
<div class="m-wheel-grid">
|
||||
<button
|
||||
v-for="opt in wheelOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="m-wheel-opt"
|
||||
:class="{ active: formState.wheelDesign === opt.value }"
|
||||
@click="formState.wheelDesign = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="رنگ اصلی">
|
||||
<a-input v-model:value="formState.primaryColor" type="color" style="width: 100%; height: 44px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="رنگ ثانویه">
|
||||
<a-input v-model:value="formState.secondaryColor" type="color" style="width: 100%; height: 44px" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div class="m-form-actions">
|
||||
<a-button v-if="step > 0" size="large" block @click="step -= 1">قبلی</a-button>
|
||||
<a-button
|
||||
v-if="step < 2"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
@click="nextStep"
|
||||
>
|
||||
بعدی
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد کمپین' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-form-page { padding: 12px 14px 24px; }
|
||||
.m-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.m-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 8px 4px;
|
||||
border-radius: 10px;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
.m-step.active {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
.m-step.done {
|
||||
background: #c7d2fe;
|
||||
color: #3730a3;
|
||||
}
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f1f5f9;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.m-wheel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-wheel-opt {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 12px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
min-height: 48px;
|
||||
}
|
||||
.m-wheel-opt.active {
|
||||
border-color: #4f46e5;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
.m-form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-form-actions :deep(.ant-btn) {
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
GiftOutlined,
|
||||
MoreOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
import { openClientCampaign } from '../../utils/clientUrl';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: undefined as string | undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/campaigns', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
status: filters.status,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
campaigns.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateCampaign(id: string) {
|
||||
await apiClient.post(`/admin/campaigns/${id}/duplicate`);
|
||||
message.success('کمپین کپی شد');
|
||||
loadCampaigns();
|
||||
}
|
||||
|
||||
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 });
|
||||
message.success(newStatus === 'ACTIVE' ? 'کمپین فعال شد' : 'کمپین متوقف شد');
|
||||
loadCampaigns();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || 'خطا در تغییر وضعیت';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return { color: '#10b981', text: 'فعال', bg: '#ecfdf5' };
|
||||
case 'PAUSED':
|
||||
return { color: '#f59e0b', text: 'متوقف', bg: '#fffbeb' };
|
||||
case 'FINISHED':
|
||||
return { color: '#ef4444', text: 'پایان', bg: '#fef2f2' };
|
||||
case 'DRAFT':
|
||||
return { color: '#d97706', text: 'پیشنویس', bg: '#fffbeb' };
|
||||
default:
|
||||
return { color: '#94a3b8', text: status, bg: '#f8fafc' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
return toFarsiDigits(
|
||||
new Date(val).toLocaleDateString('fa-IR', { year: 'numeric', month: '2-digit', day: '2-digit' }),
|
||||
);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1;
|
||||
loadCampaigns();
|
||||
}
|
||||
|
||||
onMounted(loadCampaigns);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-search-bar">
|
||||
<a-input
|
||||
v-model:value="filters.search"
|
||||
allow-clear
|
||||
placeholder="جستجوی کمپین..."
|
||||
@pressEnter="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
|
||||
</a-input>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
placeholder="وضعیت"
|
||||
style="width: 110px"
|
||||
@change="onSearch"
|
||||
>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="PAUSED">متوقف</a-select-option>
|
||||
<a-select-option value="DRAFT">پیشنویس</a-select-option>
|
||||
<a-select-option value="FINISHED">پایان</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!campaigns.length && !loading" class="m-empty">کمپینی یافت نشد</div>
|
||||
|
||||
<div v-for="c in campaigns" :key="c.id" class="m-camp-card">
|
||||
<div class="m-camp-card__top">
|
||||
<div>
|
||||
<div class="m-camp-card__title">{{ c.title }}</div>
|
||||
<div class="m-camp-card__slug">/{{ c.slug }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="m-status"
|
||||
:style="{ color: getStatusInfo(c.status).color, background: getStatusInfo(c.status).bg }"
|
||||
>
|
||||
{{ getStatusInfo(c.status).text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="m-camp-card__meta">
|
||||
<span>{{ formatDate(c.startAt) }} → {{ formatDate(c.endAt) }}</span>
|
||||
<span>
|
||||
{{ formatFarsiNumber(c._count?.prizes || 0) }} جایزه ·
|
||||
{{ formatFarsiNumber(c._count?.spins || 0) }} اسپین
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="m-camp-card__actions">
|
||||
<button type="button" class="m-action" @click="openClientCampaign(c.slug)">
|
||||
<EyeOutlined /> گردونه
|
||||
</button>
|
||||
<button type="button" class="m-action" @click="router.push(`/m/campaigns/${c.id}/edit`)">
|
||||
<EditOutlined /> ویرایش
|
||||
</button>
|
||||
<button type="button" class="m-action" @click="router.push(`/m/campaigns/${c.id}/prizes`)">
|
||||
<GiftOutlined /> جوایز
|
||||
</button>
|
||||
<a-dropdown>
|
||||
<button type="button" class="m-action">
|
||||
<MoreOutlined /> بیشتر
|
||||
</button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="c.status !== 'ACTIVE'" @click="quickSetStatus(c.id, 'ACTIVE')">
|
||||
<CheckCircleOutlined /> فعالسازی
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="c.status === 'ACTIVE'" @click="quickSetStatus(c.id, 'PAUSED')">
|
||||
<PauseCircleOutlined /> توقف
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="confirmDuplicate(c)">
|
||||
<CopyOutlined /> کپی
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination
|
||||
v-model:current="filters.page"
|
||||
:total="total"
|
||||
:page-size="filters.pageSize"
|
||||
size="small"
|
||||
@change="loadCampaigns"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<button type="button" class="m-fab" @click="router.push('/m/campaigns/create')">
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 88px; position: relative; }
|
||||
.m-search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-search-bar :deep(.ant-input-affix-wrapper),
|
||||
.m-search-bar :deep(.ant-select-selector) {
|
||||
border-radius: 12px !important;
|
||||
min-height: 42px !important;
|
||||
}
|
||||
.m-empty {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
padding: 48px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-camp-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.m-camp-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-camp-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
.m-camp-card__slug {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.m-status {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
height: fit-content;
|
||||
}
|
||||
.m-camp-card__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-camp-card__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.m-action {
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 10px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 42px;
|
||||
}
|
||||
.m-pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.m-fab {
|
||||
position: fixed;
|
||||
left: 18px;
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
box-shadow: 0 10px 24px rgba(79, 70, 229, 0.4);
|
||||
z-index: 90;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
PercentageOutlined,
|
||||
PieChartOutlined,
|
||||
PlusCircleOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const dashboardData = ref<any>(null);
|
||||
const chartRef = ref<HTMLElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
NEW: { text: 'جدید', color: 'blue' },
|
||||
CONTACTED: { text: 'تماس', color: 'cyan' },
|
||||
DELIVERED: { text: 'تحویل', color: 'success' },
|
||||
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
|
||||
USED: { text: 'مصرف', color: 'green' },
|
||||
EXPIRED: { text: 'منقضی', color: 'default' },
|
||||
};
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/reports/dashboard');
|
||||
dashboardData.value = res.data;
|
||||
await nextTick();
|
||||
initChart();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartRef.value || !dashboardData.value?.spinsByDay) return;
|
||||
if (chartInstance) chartInstance.dispose();
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
|
||||
const days = dashboardData.value.spinsByDay;
|
||||
chartInstance.setOption({
|
||||
grid: { top: 16, right: 8, bottom: 28, left: 36 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: days.map((d: any) => toFarsiDigits(String(d.date).slice(5))),
|
||||
axisLabel: { fontSize: 10, color: '#94a3b8', fontFamily: 'IRANSans' },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 10, color: '#94a3b8', fontFamily: 'IRANSans' },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'اسپین',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: days.map((d: any) => d.spins),
|
||||
lineStyle: { width: 3, color: '#4f46e5' },
|
||||
itemStyle: { color: '#4f46e5' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(79,70,229,0.28)' },
|
||||
{ offset: 1, color: 'rgba(79,70,229,0.02)' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function copyCode(code: string) {
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code);
|
||||
message.success('کد کپی شد');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboard();
|
||||
window.addEventListener('resize', () => chartInstance?.resize());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-dash">
|
||||
<div class="m-dash__toolbar">
|
||||
<div>
|
||||
<div class="m-dash__live">
|
||||
<span class="pulse" />
|
||||
آنلاین
|
||||
</div>
|
||||
<p class="m-dash__sub">خلاصه عملکرد سامانه</p>
|
||||
</div>
|
||||
<button type="button" class="m-icon-btn" :disabled="loading" @click="loadDashboard">
|
||||
<ReloadOutlined :spin="loading" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-kpi-grid">
|
||||
<div class="m-kpi indigo">
|
||||
<div class="m-kpi__head">
|
||||
<span>کاربران</span>
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalUsers || 0) }}</div>
|
||||
<div class="m-kpi__sub">
|
||||
<ArrowUpOutlined />
|
||||
+{{ formatFarsiNumber(dashboardData?.stats?.todayUsers || 0) }} امروز
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-kpi amber">
|
||||
<div class="m-kpi__head">
|
||||
<span>اسپینها</span>
|
||||
<ReloadOutlined />
|
||||
</div>
|
||||
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalSpins || 0) }}</div>
|
||||
<div class="m-kpi__sub">
|
||||
<ArrowUpOutlined />
|
||||
+{{ formatFarsiNumber(dashboardData?.stats?.todaySpins || 0) }} امروز
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-kpi emerald">
|
||||
<div class="m-kpi__head">
|
||||
<span>برندگان</span>
|
||||
<TrophyOutlined />
|
||||
</div>
|
||||
<div class="m-kpi__val">{{ formatFarsiNumber(dashboardData?.stats?.totalWinners || 0) }}</div>
|
||||
<div class="m-kpi__sub">کل برندگان ثبتشده</div>
|
||||
</div>
|
||||
<div class="m-kpi violet">
|
||||
<div class="m-kpi__head">
|
||||
<span>نرخ برد</span>
|
||||
<PercentageOutlined />
|
||||
</div>
|
||||
<div class="m-kpi__val">{{ toFarsiDigits(String(dashboardData?.stats?.winRate ?? 0)) }}٪</div>
|
||||
<div class="m-kpi__sub">
|
||||
{{ formatFarsiNumber(dashboardData?.stats?.activeCampaignsCount || 0) }} کمپین فعال
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-quick">
|
||||
<button type="button" class="m-quick__btn primary" @click="router.push('/m/campaigns/create')">
|
||||
<PlusCircleOutlined />
|
||||
کمپین جدید
|
||||
</button>
|
||||
<button type="button" class="m-quick__btn" @click="router.push('/m/users')">
|
||||
<TeamOutlined />
|
||||
کاربران
|
||||
</button>
|
||||
<button type="button" class="m-quick__btn" @click="router.push('/m/winners')">
|
||||
<TrophyOutlined />
|
||||
برندگان
|
||||
</button>
|
||||
<button type="button" class="m-quick__btn" @click="router.push('/m/reports')">
|
||||
<PieChartOutlined />
|
||||
گزارش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="m-card">
|
||||
<div class="m-card__title">روند اسپین روزانه</div>
|
||||
<div ref="chartRef" class="m-chart" />
|
||||
</div>
|
||||
|
||||
<div class="m-card" v-if="dashboardData?.recentWinners?.length">
|
||||
<div class="m-card__title">آخرین برندگان</div>
|
||||
<div
|
||||
v-for="w in dashboardData.recentWinners.slice(0, 8)"
|
||||
:key="w.id"
|
||||
class="m-winner-row"
|
||||
>
|
||||
<div class="m-winner-row__main">
|
||||
<div class="m-winner-row__name">
|
||||
{{ w.user?.name || toFarsiDigits(w.user?.mobile || '—') }}
|
||||
</div>
|
||||
<div class="m-winner-row__meta">
|
||||
{{ w.prize?.title || '—' }} · {{ w.campaign?.title || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-winner-row__side">
|
||||
<a-tag :color="statusMap[w.status]?.color || 'default'" class="m-tag">
|
||||
{{ statusMap[w.status]?.text || w.status }}
|
||||
</a-tag>
|
||||
<button
|
||||
v-if="w.code"
|
||||
type="button"
|
||||
class="m-code"
|
||||
@click="copyCode(w.code)"
|
||||
>
|
||||
{{ w.code }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-dash {
|
||||
padding: 12px 14px 24px;
|
||||
}
|
||||
|
||||
.m-dash__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.m-dash__live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
|
||||
animation: pulse 1.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
.m-dash__sub {
|
||||
margin: 8px 2px 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m-icon-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #4f46e5;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.m-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.m-kpi {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.m-kpi__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.m-kpi__val {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.m-kpi__sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.m-kpi.indigo .m-kpi__head { color: #4f46e5; }
|
||||
.m-kpi.amber .m-kpi__head { color: #d97706; }
|
||||
.m-kpi.emerald .m-kpi__head { color: #059669; }
|
||||
.m-kpi.violet .m-kpi__head { color: #7c3aed; }
|
||||
|
||||
.m-quick {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.m-quick__btn {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 12px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.m-quick__btn.primary {
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.m-card__title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.m-chart {
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m-winner-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.m-winner-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.m-winner-row__name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.m-winner-row__meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.m-winner-row__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.m-tag {
|
||||
margin: 0 !important;
|
||||
font-size: 10px !important;
|
||||
line-height: 18px !important;
|
||||
}
|
||||
|
||||
.m-code {
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
color: #4f46e5;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
direction: ltr;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const formState = reactive({
|
||||
email: 'admin@luckywheel.local',
|
||||
password: 'Admin@SecurePass2026',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function onFinish() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login({
|
||||
email: formState.email,
|
||||
password: formState.password,
|
||||
});
|
||||
router.push('/m/dashboard');
|
||||
} catch {
|
||||
// Handled by axios interceptor
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-login">
|
||||
<div class="m-login__hero">
|
||||
<div class="m-login__blob blob-1" />
|
||||
<div class="m-login__blob blob-2" />
|
||||
<div class="m-login__brand">
|
||||
<div class="m-login__logo">🎡</div>
|
||||
<h1>گردونه شانس</h1>
|
||||
<p>پنل مدیریت موبایل</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-login__sheet">
|
||||
<h2>ورود به حساب</h2>
|
||||
<p class="m-login__hint">با حساب ادمین وارد شوید</p>
|
||||
|
||||
<a-form :model="formState" layout="vertical" @finish="onFinish">
|
||||
<a-form-item
|
||||
label="ایمیل"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'ایمیل را وارد کنید' },
|
||||
{ type: 'email', message: 'فرمت ایمیل نامعتبر است' },
|
||||
]"
|
||||
>
|
||||
<a-input v-model:value="formState.email" size="large" placeholder="admin@example.com">
|
||||
<template #prefix><MailOutlined style="color: #6366f1" /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="کلمه عبور"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'کلمه عبور را وارد کنید' }]"
|
||||
>
|
||||
<a-input-password v-model:value="formState.password" size="large" placeholder="••••••••">
|
||||
<template #prefix><LockOutlined style="color: #6366f1" /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading" class="m-login__btn">
|
||||
ورود
|
||||
</a-button>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-login {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.m-login__hero {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
padding: calc(env(safe-area-inset-top, 0px) + 48px) 24px 56px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #312e81 0%, #4f46e5 45%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.m-login__blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #a78bfa;
|
||||
top: -40px;
|
||||
left: -30px;
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background: #38bdf8;
|
||||
bottom: 20px;
|
||||
right: -20px;
|
||||
}
|
||||
|
||||
.m-login__brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.m-login__logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.m-login__brand h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.m-login__brand p {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-login__sheet {
|
||||
flex: 1;
|
||||
margin-top: -28px;
|
||||
background: #fff;
|
||||
border-radius: 28px 28px 0 0;
|
||||
padding: 28px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.m-login__sheet h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.m-login__hint {
|
||||
margin: 0 0 22px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-login__btn {
|
||||
height: 48px !important;
|
||||
border-radius: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper),
|
||||
:deep(.ant-input) {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, parseFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const campaignId = ref((route.params.campaignId as string) || '');
|
||||
const campaign = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
const prizes = ref<any[]>([]);
|
||||
const isFormVisible = ref(false);
|
||||
const isStockVisible = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const editingPrizeId = ref<string | null>(null);
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'DISCOUNT_CODE',
|
||||
stockType: 'LIMITED',
|
||||
initialStock: 100,
|
||||
remainingStock: 100,
|
||||
weight: 10,
|
||||
segmentColor: '#FF6B6B',
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
const stockState = reactive({
|
||||
prizeId: '',
|
||||
prizeTitle: '',
|
||||
currentStock: 0,
|
||||
change: 0,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const totalWeight = computed(() =>
|
||||
prizes.value.reduce((acc, p) => acc + (p.status === 'ACTIVE' || p.isActive ? p.weight : 0), 0),
|
||||
);
|
||||
|
||||
async function loadCampaignInfo() {
|
||||
if (!campaignId.value) return;
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
|
||||
campaign.value = res.data;
|
||||
}
|
||||
|
||||
async function loadPrizes() {
|
||||
if (!campaignId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}/prizes`);
|
||||
prizes.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingPrizeId.value = null;
|
||||
Object.assign(formState, {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'DISCOUNT_CODE',
|
||||
stockType: 'LIMITED',
|
||||
initialStock: 100,
|
||||
remainingStock: 100,
|
||||
weight: 10,
|
||||
segmentColor: '#3880ff',
|
||||
sortOrder: prizes.value.length + 1,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
isFormVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(record: any) {
|
||||
editingPrizeId.value = record.id;
|
||||
formState.title = record.title;
|
||||
formState.description = record.description || '';
|
||||
formState.type = record.type || 'DISCOUNT_CODE';
|
||||
formState.stockType = record.stockType || 'LIMITED';
|
||||
formState.initialStock = record.initialStock ?? 100;
|
||||
formState.remainingStock = record.remainingStock ?? 100;
|
||||
formState.weight = record.weight ?? 10;
|
||||
formState.segmentColor = record.segmentColor || record.color || '#3880ff';
|
||||
formState.sortOrder = record.sortOrder ?? 1;
|
||||
formState.status = record.status || 'ACTIVE';
|
||||
isFormVisible.value = true;
|
||||
}
|
||||
|
||||
function openStock(record: any) {
|
||||
stockState.prizeId = record.id;
|
||||
stockState.prizeTitle = record.title;
|
||||
stockState.currentStock = record.remainingStock;
|
||||
stockState.change = 0;
|
||||
stockState.reason = '';
|
||||
isStockVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!formState.title?.trim()) {
|
||||
message.error('عنوان جایزه الزامی است');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (formState.type === 'NO_PRIZE') formState.stockType = 'UNLIMITED';
|
||||
const payload: any = {
|
||||
title: formState.title.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
type: formState.type,
|
||||
stockType: formState.stockType,
|
||||
weight: parseFarsiNumber(formState.weight, 1) || 1,
|
||||
segmentColor: formState.segmentColor,
|
||||
sortOrder: parseFarsiNumber(formState.sortOrder, 1) || 1,
|
||||
status: formState.status,
|
||||
};
|
||||
if (formState.stockType === 'LIMITED') {
|
||||
payload.initialStock = parseFarsiNumber(formState.initialStock, 1);
|
||||
payload.remainingStock = parseFarsiNumber(formState.remainingStock, payload.initialStock);
|
||||
}
|
||||
if (editingPrizeId.value) {
|
||||
await apiClient.patch(`/admin/prizes/${editingPrizeId.value}`, payload);
|
||||
message.success('جایزه بهروزرسانی شد');
|
||||
} else {
|
||||
await apiClient.post(`/admin/campaigns/${campaignId.value}/prizes`, payload);
|
||||
message.success('جایزه ایجاد شد');
|
||||
}
|
||||
isFormVisible.value = false;
|
||||
loadPrizes();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || 'خطا در ثبت جایزه';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitStock() {
|
||||
const changeVal = parseFarsiNumber(stockState.change, 0);
|
||||
if (!changeVal) {
|
||||
message.warning('میزان تغییر نباید صفر باشد');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/prizes/${stockState.prizeId}/stock`, {
|
||||
change: changeVal,
|
||||
reason: stockState.reason || undefined,
|
||||
});
|
||||
message.success('موجودی تغییر کرد');
|
||||
isStockVisible.value = false;
|
||||
loadPrizes();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(record: any) {
|
||||
Modal.confirm({
|
||||
title: 'حذف جایزه',
|
||||
content: `جایزه «${record.title}» حذف شود؟`,
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
await apiClient.delete(`/admin/prizes/${record.id}`);
|
||||
message.success('حذف شد');
|
||||
loadPrizes();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
PHYSICAL: 'فیزیکی',
|
||||
DISCOUNT_CODE: 'کد تخفیف',
|
||||
CREDIT: 'اعتبار',
|
||||
CASH: 'نقدی',
|
||||
NO_PRIZE: 'پوچ',
|
||||
OTHER: 'سایر',
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
function weightPct(p: any) {
|
||||
if (!totalWeight.value) return '۰';
|
||||
return toFarsiDigits(((p.weight / totalWeight.value) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaignInfo();
|
||||
loadPrizes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-banner" v-if="campaign">
|
||||
<div>
|
||||
<div class="m-banner__title">{{ campaign.title }}</div>
|
||||
<div class="m-banner__sub">مدیریت جوایز کمپین</div>
|
||||
</div>
|
||||
<button type="button" class="m-icon-btn" @click="loadPrizes">
|
||||
<ReloadOutlined :spin="loading" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!prizes.length && !loading" class="m-empty">هنوز جایزهای تعریف نشده</div>
|
||||
|
||||
<div v-for="p in prizes" :key="p.id" class="m-prize-card">
|
||||
<div class="m-prize-card__color" :style="{ background: p.segmentColor || p.color || '#4f46e5' }" />
|
||||
<div class="m-prize-card__body">
|
||||
<div class="m-prize-card__top">
|
||||
<div class="m-prize-card__title">{{ p.title }}</div>
|
||||
<a-tag :color="p.status === 'ACTIVE' || p.isActive ? 'green' : 'default'">
|
||||
{{ p.status === 'ACTIVE' || p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="m-prize-card__meta">
|
||||
{{ typeLabel(p.type) }} · وزن {{ formatFarsiNumber(p.weight) }} ({{ weightPct(p) }}٪)
|
||||
</div>
|
||||
<div class="m-prize-card__meta">
|
||||
موجودی:
|
||||
{{
|
||||
p.stockType === 'UNLIMITED'
|
||||
? 'نامحدود'
|
||||
: formatFarsiNumber(p.remainingStock ?? 0) + ' / ' + formatFarsiNumber(p.initialStock ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<div class="m-prize-card__actions">
|
||||
<button type="button" class="m-action" @click="openEdit(p)"><EditOutlined /> ویرایش</button>
|
||||
<button
|
||||
v-if="p.stockType !== 'UNLIMITED'"
|
||||
type="button"
|
||||
class="m-action"
|
||||
@click="openStock(p)"
|
||||
>
|
||||
موجودی
|
||||
</button>
|
||||
<button type="button" class="m-action danger" @click="confirmDelete(p)">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<button type="button" class="m-fab" @click="openCreate"><PlusOutlined /></button>
|
||||
|
||||
<a-drawer v-model:open="isFormVisible" placement="bottom" :height="'85%'" title="جایزه" :closable="true">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input v-model:value="formState.title" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نوع">
|
||||
<a-select v-model:value="formState.type" size="large" style="width: 100%">
|
||||
<a-select-option value="DISCOUNT_CODE">کد تخفیف</a-select-option>
|
||||
<a-select-option value="PHYSICAL">فیزیکی</a-select-option>
|
||||
<a-select-option value="CREDIT">اعتبار</a-select-option>
|
||||
<a-select-option value="CASH">نقدی</a-select-option>
|
||||
<a-select-option value="NO_PRIZE">پوچ</a-select-option>
|
||||
<a-select-option value="OTHER">سایر</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="نوع موجودی">
|
||||
<a-select v-model:value="formState.stockType" size="large" style="width: 100%">
|
||||
<a-select-option value="LIMITED">محدود</a-select-option>
|
||||
<a-select-option value="UNLIMITED">نامحدود</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formState.stockType === 'LIMITED'" label="موجودی اولیه">
|
||||
<a-input-number v-model:value="formState.initialStock" :min="0" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formState.stockType === 'LIMITED'" label="موجودی مانده">
|
||||
<a-input-number v-model:value="formState.remainingStock" :min="0" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="وزن">
|
||||
<a-input-number v-model:value="formState.weight" :min="1" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="رنگ سگمنت">
|
||||
<a-input v-model:value="formState.segmentColor" type="color" style="width: 100%; height: 44px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select v-model:value="formState.status" size="large" style="width: 100%">
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="INACTIVE">غیرفعال</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitForm">
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer v-model:open="isStockVisible" placement="bottom" :height="360" title="تغییر موجودی">
|
||||
<p>{{ stockState.prizeTitle }} — فعلی: {{ formatFarsiNumber(stockState.currentStock) }}</p>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="میزان تغییر (+/-)">
|
||||
<a-input-number v-model:value="stockState.change" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="دلیل">
|
||||
<a-input v-model:value="stockState.reason" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitStock">
|
||||
اعمال
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 88px; }
|
||||
.m-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
.m-banner__title { font-weight: 800; font-size: 15px; color: #0f172a; }
|
||||
.m-banner__sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
.m-icon-btn {
|
||||
width: 40px; height: 40px; border: none; border-radius: 12px;
|
||||
background: #eef2ff; color: #4f46e5; font-size: 15px;
|
||||
}
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 40px 12px; }
|
||||
.m-prize-card {
|
||||
display: flex; gap: 0; background: #fff; border-radius: 16px;
|
||||
overflow: hidden; margin-bottom: 10px; border: 1px solid #f1f5f9;
|
||||
}
|
||||
.m-prize-card__color { width: 8px; flex-shrink: 0; }
|
||||
.m-prize-card__body { flex: 1; padding: 12px; }
|
||||
.m-prize-card__top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
||||
.m-prize-card__title { font-weight: 800; font-size: 14px; color: #0f172a; }
|
||||
.m-prize-card__meta { font-size: 11px; color: #64748b; margin-bottom: 4px; }
|
||||
.m-prize-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.m-action {
|
||||
flex: 1; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
|
||||
padding: 10px; font-size: 11px; font-weight: 700; color: #334155; min-height: 42px;
|
||||
}
|
||||
.m-action.danger { color: #ef4444; flex: 0 0 48px; }
|
||||
.m-fab {
|
||||
position: fixed; left: 18px; bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
width: 56px; height: 56px; border: none; border-radius: 18px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; font-size: 22px;
|
||||
box-shadow: 0 10px 24px rgba(79, 70, 229, 0.4); z-index: 90;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GiftOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber } from '@lucky-wheel/shared-types';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const report = ref<any>(null);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const filters = reactive({
|
||||
campaignId: undefined as string | undefined,
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
}
|
||||
|
||||
async function loadReport() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/reports/participation', {
|
||||
params: { campaignId: filters.campaignId || undefined },
|
||||
});
|
||||
report.value = res.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadReport();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-filters">
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
allow-clear
|
||||
placeholder="همه کمپینها"
|
||||
style="flex: 1"
|
||||
@change="loadReport"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
|
||||
</a-select>
|
||||
<button type="button" class="m-icon-btn" @click="loadReport">
|
||||
<ReloadOutlined :spin="loading" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-kpi-grid">
|
||||
<div class="m-kpi">
|
||||
<UserOutlined class="ico indigo" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.totalUsers || 0) }}</div>
|
||||
<div class="lbl">کل کاربران</div>
|
||||
</div>
|
||||
<div class="m-kpi">
|
||||
<ThunderboltOutlined class="ico amber" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.totalSpins || 0) }}</div>
|
||||
<div class="lbl">کل اسپین</div>
|
||||
</div>
|
||||
<div class="m-kpi">
|
||||
<TrophyOutlined class="ico emerald" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.winnersCount || 0) }}</div>
|
||||
<div class="lbl">برندگان</div>
|
||||
</div>
|
||||
<div class="m-kpi">
|
||||
<GiftOutlined class="ico violet" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.totalChancesAllocated || 0) }}</div>
|
||||
<div class="lbl">شانس تخصیص</div>
|
||||
</div>
|
||||
<div class="m-kpi">
|
||||
<TeamOutlined class="ico blue" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.totalChancesUsed || 0) }}</div>
|
||||
<div class="lbl">شانس مصرف</div>
|
||||
</div>
|
||||
<div class="m-kpi">
|
||||
<UserOutlined class="ico slate" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.zeroSpinUsers || 0) }}</div>
|
||||
<div class="lbl">بدون اسپین</div>
|
||||
</div>
|
||||
<div class="m-kpi wide">
|
||||
<ThunderboltOutlined class="ico indigo" />
|
||||
<div class="val">{{ formatFarsiNumber(report?.remainingChanceUsers || 0) }}</div>
|
||||
<div class="lbl">کاربران با شانس باقیمانده</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-filters { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
|
||||
.m-icon-btn {
|
||||
width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5;
|
||||
}
|
||||
.m-kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.m-kpi {
|
||||
background: #fff; border-radius: 16px; padding: 16px 12px; text-align: center;
|
||||
border: 1px solid #f1f5f9; box-shadow: 0 2px 10px rgba(15,23,42,.04);
|
||||
}
|
||||
.m-kpi.wide { grid-column: 1 / -1; }
|
||||
.ico { font-size: 18px; margin-bottom: 8px; }
|
||||
.ico.indigo { color: #4f46e5; }
|
||||
.ico.amber { color: #d97706; }
|
||||
.ico.emerald { color: #059669; }
|
||||
.ico.violet { color: #7c3aed; }
|
||||
.ico.blue { color: #2563eb; }
|
||||
.ico.slate { color: #64748b; }
|
||||
.val { font-size: 22px; font-weight: 800; color: #0f172a; }
|
||||
.lbl { font-size: 11px; color: #64748b; margin-top: 4px; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, SendOutlined } from '@ant-design/icons-vue';
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const resendingId = ref<string | null>(null);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
mobile: '',
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
SENT: { text: 'ارسال شد', color: 'success' },
|
||||
QUEUED: { text: 'صف', color: 'processing' },
|
||||
FAILED: { text: 'ناموفق', color: 'error' },
|
||||
};
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/sms-logs', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
mobile: filters.mobile || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
logs.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend(id: string) {
|
||||
resendingId.value = id;
|
||||
try {
|
||||
await apiClient.post(`/admin/sms-logs/${id}/resend`);
|
||||
message.success('ارسال مجدد انجام شد');
|
||||
loadLogs();
|
||||
} finally {
|
||||
resendingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(
|
||||
new Date(val).toLocaleDateString('fa-IR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-filters">
|
||||
<a-input v-model:value="filters.mobile" allow-clear placeholder="شماره موبایل..." @pressEnter="onSearch" />
|
||||
<div class="m-filters__row">
|
||||
<a-select v-model:value="filters.status" allow-clear placeholder="وضعیت" style="flex:1" @change="onSearch">
|
||||
<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>
|
||||
<button type="button" class="m-icon-btn" @click="loadLogs"><ReloadOutlined :spin="loading" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!logs.length && !loading" class="m-empty">لاگی یافت نشد</div>
|
||||
<div v-for="log in logs" :key="log.id" class="m-card">
|
||||
<div class="m-card__top">
|
||||
<span class="mobile" dir="ltr">{{ toFarsiDigits(log.mobile) }}</span>
|
||||
<a-tag :color="statusMap[log.status]?.color || 'default'">
|
||||
{{ statusMap[log.status]?.text || log.status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="m-card__msg">{{ log.message }}</div>
|
||||
<div class="m-card__meta">
|
||||
<span>{{ log.provider || '—' }}</span>
|
||||
<span>{{ formatDate(log.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="log.errorMessage" class="m-card__err">{{ log.errorMessage }}</div>
|
||||
<button
|
||||
v-if="log.status === 'FAILED'"
|
||||
type="button"
|
||||
class="m-resend"
|
||||
:disabled="resendingId === log.id"
|
||||
@click="resend(log.id)"
|
||||
>
|
||||
<SendOutlined /> ارسال مجدد
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination v-model:current="filters.page" :total="total" :page-size="filters.pageSize" size="small" @change="loadLogs" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.m-filters__row { display: flex; gap: 8px; }
|
||||
.m-filters :deep(.ant-input),
|
||||
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
|
||||
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
|
||||
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.mobile { font-weight: 800; color: #0f172a; font-size: 13px; }
|
||||
.m-card__msg { font-size: 12px; color: #334155; line-height: 1.6; margin-bottom: 8px; }
|
||||
.m-card__meta { display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
|
||||
.m-card__err { margin-top: 8px; font-size: 11px; color: #ef4444; background: #fef2f2; padding: 8px; border-radius: 8px; }
|
||||
.m-resend {
|
||||
margin-top: 10px; width: 100%; border: none; background: #eef2ff; color: #4f46e5;
|
||||
border-radius: 10px; padding: 10px; font-weight: 700; font-size: 12px; min-height: 42px;
|
||||
}
|
||||
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { 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 spins = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const campaigns = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
campaignId: undefined as string | undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
}
|
||||
|
||||
async function loadSpins() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/spins', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
campaignId: filters.campaignId || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
spins.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1;
|
||||
loadSpins();
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(
|
||||
new Date(val).toLocaleDateString('fa-IR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadSpins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-filters">
|
||||
<a-input
|
||||
v-model:value="filters.search"
|
||||
allow-clear
|
||||
placeholder="موبایل کاربر..."
|
||||
@pressEnter="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
|
||||
</a-input>
|
||||
<div class="m-filters__row">
|
||||
<a-select
|
||||
v-model:value="filters.campaignId"
|
||||
allow-clear
|
||||
placeholder="کمپین"
|
||||
style="flex: 1"
|
||||
@change="onSearch"
|
||||
>
|
||||
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
|
||||
</a-select>
|
||||
<button type="button" class="m-icon-btn" @click="loadSpins"><ReloadOutlined :spin="loading" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!spins.length && !loading" class="m-empty">اسپینی یافت نشد</div>
|
||||
<div v-for="s in spins" :key="s.id" class="m-card">
|
||||
<div class="m-card__top">
|
||||
<button
|
||||
type="button"
|
||||
class="m-card__user"
|
||||
@click="s.user?.id && router.push(`/m/users/${s.user.id}`)"
|
||||
>
|
||||
{{ s.user?.name || toFarsiDigits(s.user?.mobile || '—') }}
|
||||
</button>
|
||||
<span class="m-pill" :class="s.isWin ? 'win' : 'lose'">{{ s.isWin ? 'برد' : 'باخت' }}</span>
|
||||
</div>
|
||||
<div class="m-card__line">{{ s.campaign?.title || '—' }}</div>
|
||||
<div class="m-card__line">جایزه: {{ s.prize?.title || '—' }}</div>
|
||||
<div class="m-card__meta">
|
||||
<span dir="ltr">{{ s.ipAddress || '—' }}</span>
|
||||
<span>{{ formatDate(s.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination
|
||||
v-model:current="filters.page"
|
||||
:total="total"
|
||||
:page-size="filters.pageSize"
|
||||
size="small"
|
||||
@change="loadSpins"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.m-filters__row { display: flex; gap: 8px; }
|
||||
.m-filters :deep(.ant-input-affix-wrapper),
|
||||
.m-filters :deep(.ant-select-selector) {
|
||||
border-radius: 12px !important; min-height: 42px !important;
|
||||
}
|
||||
.m-icon-btn {
|
||||
width: 42px; height: 42px; border: none; border-radius: 12px;
|
||||
background: #fff; color: #4f46e5; box-shadow: 0 2px 8px rgba(15,23,42,.06);
|
||||
}
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.m-card {
|
||||
background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9;
|
||||
}
|
||||
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.m-card__user {
|
||||
border: none; background: transparent; padding: 0; font-weight: 800; color: #4f46e5; font-size: 13px;
|
||||
}
|
||||
.m-pill {
|
||||
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
|
||||
}
|
||||
.m-pill.win { background: #ecfdf5; color: #059669; }
|
||||
.m-pill.lose { background: #f1f5f9; color: #64748b; }
|
||||
.m-card__line { font-size: 12px; color: #334155; margin-bottom: 2px; }
|
||||
.m-card__meta {
|
||||
display: flex; justify-content: space-between; margin-top: 8px; font-size: 11px; color: #94a3b8;
|
||||
}
|
||||
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const userId = ref((route.params.id as string) || '');
|
||||
const loading = ref(false);
|
||||
const user = ref<any>(null);
|
||||
const tab = ref('chances');
|
||||
const allCampaigns = ref<any[]>([]);
|
||||
const isChanceVisible = ref(false);
|
||||
const isSubmittingChance = ref(false);
|
||||
const selectedCampaignId = ref('');
|
||||
const chanceAmount = ref(1);
|
||||
const chanceReason = ref('');
|
||||
|
||||
async function loadCampaigns() {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
allCampaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
if (!userId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/users/${userId.value}`);
|
||||
user.value = res.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openChance(campaignId?: string) {
|
||||
selectedCampaignId.value =
|
||||
campaignId ||
|
||||
user.value?.userCampaigns?.[0]?.campaignId ||
|
||||
allCampaigns.value?.[0]?.id ||
|
||||
'';
|
||||
chanceAmount.value = 1;
|
||||
chanceReason.value = 'اهدای شانس تشویقی توسط مدیر';
|
||||
isChanceVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitChance() {
|
||||
if (!selectedCampaignId.value) {
|
||||
message.error('انتخاب کمپین الزامی است');
|
||||
return;
|
||||
}
|
||||
isSubmittingChance.value = true;
|
||||
try {
|
||||
await apiClient.post('/admin/chances/adjust', {
|
||||
userId: userId.value,
|
||||
campaignId: selectedCampaignId.value,
|
||||
amount: chanceAmount.value,
|
||||
reason: chanceReason.value,
|
||||
});
|
||||
message.success('شانس تغییر کرد');
|
||||
isChanceVisible.value = false;
|
||||
loadUser();
|
||||
} finally {
|
||||
isSubmittingChance.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(
|
||||
new Date(val).toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUser();
|
||||
loadCampaigns();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-profile" v-if="user">
|
||||
<div class="m-profile__avatar">{{ (user.name || user.mobile || '؟').charAt(0) }}</div>
|
||||
<div class="m-profile__name">{{ user.name || 'بدون نام' }}</div>
|
||||
<div class="m-profile__mobile">{{ toFarsiDigits(user.mobile) }}</div>
|
||||
<div class="m-profile__meta">
|
||||
<span :class="user.status === 'BLOCKED' || user.isBlocked ? 'bad' : 'ok'">
|
||||
{{ user.status === 'BLOCKED' || user.isBlocked ? 'مسدود' : 'فعال' }}
|
||||
</span>
|
||||
<span>عضویت {{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="m-profile__actions">
|
||||
<button type="button" class="m-btn ghost" @click="loadUser"><ReloadOutlined /> بروزرسانی</button>
|
||||
<button type="button" class="m-btn primary" @click="openChance()">
|
||||
<PlusCircleOutlined /> افزودن شانس
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-tabs">
|
||||
<button type="button" :class="{ active: tab === 'chances' }" @click="tab = 'chances'">شانسها</button>
|
||||
<button type="button" :class="{ active: tab === 'prizes' }" @click="tab = 'prizes'">جوایز</button>
|
||||
<button type="button" :class="{ active: tab === 'spins' }" @click="tab = 'spins'">اسپینها</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'chances'">
|
||||
<div
|
||||
v-for="uc in user?.userCampaigns || []"
|
||||
:key="uc.id || uc.campaignId"
|
||||
class="m-card"
|
||||
>
|
||||
<div class="m-card__title">{{ uc.campaign?.title || 'کمپین' }}</div>
|
||||
<div class="m-card__row">
|
||||
<span>باقیمانده</span>
|
||||
<strong>{{ formatFarsiNumber(uc.remainingChances || 0) }}</strong>
|
||||
</div>
|
||||
<div class="m-card__row">
|
||||
<span>کل / مصرف</span>
|
||||
<strong>
|
||||
{{ formatFarsiNumber(uc.totalChances || 0) }} /
|
||||
{{ formatFarsiNumber(uc.usedChances || 0) }}
|
||||
</strong>
|
||||
</div>
|
||||
<button type="button" class="m-link" @click="openChance(uc.campaignId || uc.campaign?.id)">
|
||||
تنظیم شانس
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!(user?.userCampaigns || []).length" class="m-empty">شانسی ثبت نشده</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'prizes'">
|
||||
<div v-for="p in user?.prizes || []" :key="p.id" class="m-card">
|
||||
<div class="m-card__title">{{ p.prize?.title || '—' }}</div>
|
||||
<div class="m-card__row"><span>کد</span><strong dir="ltr">{{ p.code || '—' }}</strong></div>
|
||||
<div class="m-card__row"><span>وضعیت</span><strong>{{ p.status }}</strong></div>
|
||||
<div class="m-card__row"><span>تاریخ</span><strong>{{ formatDate(p.createdAt) }}</strong></div>
|
||||
</div>
|
||||
<div v-if="!(user?.prizes || []).length" class="m-empty">جایزهای نیست</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'spins'">
|
||||
<div v-for="s in user?.spins || []" :key="s.id" class="m-card">
|
||||
<div class="m-card__title">{{ s.campaign?.title || '—' }}</div>
|
||||
<div class="m-card__row">
|
||||
<span>نتیجه</span>
|
||||
<strong>{{ s.isWin ? 'برد' : 'باخت' }} · {{ s.prize?.title || '—' }}</strong>
|
||||
</div>
|
||||
<div class="m-card__row"><span>IP</span><strong dir="ltr">{{ s.ipAddress || '—' }}</strong></div>
|
||||
<div class="m-card__row"><span>زمان</span><strong>{{ formatDate(s.createdAt) }}</strong></div>
|
||||
</div>
|
||||
<div v-if="!(user?.spins || []).length" class="m-empty">اسپینی نیست</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer v-model:open="isChanceVisible" placement="bottom" :height="420" title="تنظیم شانس">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="کمپین">
|
||||
<a-select v-model:value="selectedCampaignId" size="large" style="width: 100%">
|
||||
<a-select-option v-for="c in allCampaigns" :key="c.id" :value="c.id">{{ c.title }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="مقدار">
|
||||
<a-input-number v-model:value="chanceAmount" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="دلیل">
|
||||
<a-input v-model:value="chanceReason" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block size="large" :loading="isSubmittingChance" @click="submitChance">
|
||||
اعمال
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-profile {
|
||||
background: #fff; border-radius: 18px; padding: 20px 16px; text-align: center;
|
||||
border: 1px solid #f1f5f9; margin-bottom: 12px;
|
||||
}
|
||||
.m-profile__avatar {
|
||||
width: 64px; height: 64px; margin: 0 auto 10px; border-radius: 20px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800;
|
||||
}
|
||||
.m-profile__name { font-size: 17px; font-weight: 800; color: #0f172a; }
|
||||
.m-profile__mobile { font-size: 13px; color: #64748b; direction: ltr; margin-top: 4px; }
|
||||
.m-profile__meta {
|
||||
display: flex; justify-content: center; gap: 10px; margin-top: 10px; font-size: 11px; color: #94a3b8;
|
||||
}
|
||||
.m-profile__meta .ok { color: #059669; font-weight: 700; }
|
||||
.m-profile__meta .bad { color: #ef4444; font-weight: 700; }
|
||||
.m-profile__actions { display: flex; gap: 8px; margin-top: 14px; }
|
||||
.m-btn {
|
||||
flex: 1; border: none; border-radius: 12px; min-height: 44px; font-weight: 700; font-size: 12px;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.m-btn.ghost { background: #f1f5f9; color: #334155; }
|
||||
.m-btn.primary { background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; }
|
||||
.m-tabs {
|
||||
display: flex; background: #fff; border-radius: 14px; padding: 4px; margin-bottom: 12px; gap: 4px;
|
||||
}
|
||||
.m-tabs button {
|
||||
flex: 1; border: none; background: transparent; border-radius: 10px; padding: 10px;
|
||||
font-size: 12px; font-weight: 700; color: #64748b; min-height: 40px;
|
||||
}
|
||||
.m-tabs button.active { background: #eef2ff; color: #4f46e5; }
|
||||
.m-card {
|
||||
background: #fff; border-radius: 14px; padding: 14px; margin-bottom: 8px; border: 1px solid #f1f5f9;
|
||||
}
|
||||
.m-card__title { font-weight: 800; font-size: 14px; margin-bottom: 8px; color: #0f172a; }
|
||||
.m-card__row {
|
||||
display: flex; justify-content: space-between; font-size: 12px; color: #64748b; margin-bottom: 4px;
|
||||
}
|
||||
.m-card__row strong { color: #0f172a; }
|
||||
.m-link {
|
||||
margin-top: 8px; border: none; background: #eef2ff; color: #4f46e5; border-radius: 10px;
|
||||
width: 100%; padding: 10px; font-weight: 700; font-size: 12px;
|
||||
}
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 28px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EyeOutlined,
|
||||
LockOutlined,
|
||||
PlusCircleOutlined,
|
||||
SearchOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
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 users = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const isChanceVisible = ref(false);
|
||||
const isSubmittingChance = ref(false);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
const chanceForm = reactive({
|
||||
userId: '',
|
||||
userMobile: '',
|
||||
campaignId: '',
|
||||
amount: 1,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/users', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
users.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmToggleBlock(record: any) {
|
||||
const isBlocked = record.status === 'BLOCKED';
|
||||
Modal.confirm({
|
||||
title: isBlocked ? 'رفع مسدودی' : 'مسدودسازی',
|
||||
content: `${toFarsiDigits(record.mobile)} را ${isBlocked ? 'رفع مسدودی' : 'مسدود'} میکنید؟`,
|
||||
okText: 'تأیید',
|
||||
okType: isBlocked ? 'primary' : 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
await apiClient.patch(`/admin/users/${record.id}/block`, {
|
||||
blocked: !isBlocked,
|
||||
reason: isBlocked ? 'رفع مسدودی توسط مدیر' : 'مسدودسازی توسط مدیر',
|
||||
});
|
||||
message.success(isBlocked ? 'مسدودی رفع شد' : 'کاربر مسدود شد');
|
||||
loadUsers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openChance(record: any) {
|
||||
chanceForm.userId = record.id;
|
||||
chanceForm.userMobile = record.mobile;
|
||||
chanceForm.campaignId = campaigns.value[0]?.id || '';
|
||||
chanceForm.amount = 1;
|
||||
chanceForm.reason = 'اهدای شانس توسط مدیر';
|
||||
isChanceVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitChance() {
|
||||
if (!chanceForm.campaignId) {
|
||||
message.error('کمپین را انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
isSubmittingChance.value = true;
|
||||
try {
|
||||
await apiClient.post('/admin/chances/adjust', {
|
||||
userId: chanceForm.userId,
|
||||
campaignId: chanceForm.campaignId,
|
||||
amount: chanceForm.amount,
|
||||
reason: chanceForm.reason,
|
||||
});
|
||||
message.success('شانس تنظیم شد');
|
||||
isChanceVisible.value = false;
|
||||
} finally {
|
||||
isSubmittingChance.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(new Date(val).toLocaleDateString('fa-IR'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
loadCampaigns();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-search-bar">
|
||||
<a-input
|
||||
v-model:value="filters.search"
|
||||
allow-clear
|
||||
placeholder="موبایل یا نام..."
|
||||
@pressEnter="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
|
||||
</a-input>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
placeholder="وضعیت"
|
||||
style="width: 110px"
|
||||
@change="onSearch"
|
||||
>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="BLOCKED">مسدود</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!users.length && !loading" class="m-empty">کاربری یافت نشد</div>
|
||||
|
||||
<div v-for="u in users" :key="u.id" class="m-user-card">
|
||||
<div class="m-user-card__avatar">{{ (u.name || u.mobile || '؟').charAt(0) }}</div>
|
||||
<div class="m-user-card__body">
|
||||
<div class="m-user-card__top">
|
||||
<div>
|
||||
<div class="m-user-card__name">{{ u.name || 'بدون نام' }}</div>
|
||||
<div class="m-user-card__mobile">{{ toFarsiDigits(u.mobile) }}</div>
|
||||
</div>
|
||||
<span class="m-badge" :class="u.status === 'BLOCKED' ? 'blocked' : 'active'">
|
||||
{{ u.status === 'BLOCKED' ? 'مسدود' : 'فعال' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-user-card__meta">
|
||||
{{ formatFarsiNumber(u._count?.spins || 0) }} اسپین ·
|
||||
{{ formatFarsiNumber(u._count?.userPrizes || u._count?.prizes || 0) }} جایزه ·
|
||||
عضویت {{ formatDate(u.createdAt) }}
|
||||
</div>
|
||||
<div class="m-user-card__actions">
|
||||
<button type="button" class="m-action" @click="router.push(`/m/users/${u.id}`)">
|
||||
<EyeOutlined /> پروفایل
|
||||
</button>
|
||||
<button type="button" class="m-action" @click="openChance(u)">
|
||||
<PlusCircleOutlined /> شانس
|
||||
</button>
|
||||
<button type="button" class="m-action" @click="confirmToggleBlock(u)">
|
||||
<LockOutlined v-if="u.status !== 'BLOCKED'" />
|
||||
<UnlockOutlined v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination
|
||||
v-model:current="filters.page"
|
||||
:total="total"
|
||||
:page-size="filters.pageSize"
|
||||
size="small"
|
||||
@change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer v-model:open="isChanceVisible" placement="bottom" :height="420" title="تنظیم شانس">
|
||||
<p class="m-drawer-hint">کاربر: {{ toFarsiDigits(chanceForm.userMobile) }}</p>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="کمپین">
|
||||
<a-select v-model:value="chanceForm.campaignId" size="large" style="width: 100%">
|
||||
<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="مقدار (+/-)">
|
||||
<a-input-number v-model:value="chanceForm.amount" size="large" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="دلیل">
|
||||
<a-input v-model:value="chanceForm.reason" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block size="large" :loading="isSubmittingChance" @click="submitChance">
|
||||
اعمال
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-search-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.m-search-bar :deep(.ant-input-affix-wrapper),
|
||||
.m-search-bar :deep(.ant-select-selector) {
|
||||
border-radius: 12px !important;
|
||||
min-height: 42px !important;
|
||||
}
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 48px 12px; }
|
||||
.m-user-card {
|
||||
display: flex; gap: 12px; background: #fff; border-radius: 16px;
|
||||
padding: 14px; margin-bottom: 10px; border: 1px solid #f1f5f9;
|
||||
}
|
||||
.m-user-card__avatar {
|
||||
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 800;
|
||||
}
|
||||
.m-user-card__body { flex: 1; min-width: 0; }
|
||||
.m-user-card__top { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.m-user-card__name { font-weight: 800; font-size: 14px; color: #0f172a; }
|
||||
.m-user-card__mobile { font-size: 12px; color: #64748b; direction: ltr; text-align: right; }
|
||||
.m-badge {
|
||||
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 999px; height: fit-content;
|
||||
}
|
||||
.m-badge.active { background: #ecfdf5; color: #059669; }
|
||||
.m-badge.blocked { background: #fef2f2; color: #ef4444; }
|
||||
.m-user-card__meta { font-size: 11px; color: #94a3b8; margin: 8px 0; }
|
||||
.m-user-card__actions { display: flex; gap: 6px; }
|
||||
.m-action {
|
||||
flex: 1; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
|
||||
padding: 10px 4px; font-size: 11px; font-weight: 700; color: #334155;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-height: 42px;
|
||||
}
|
||||
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
|
||||
.m-drawer-hint { color: #64748b; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyOutlined, EditOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { message } from 'ant-design-vue';
|
||||
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 winners = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const campaigns = ref<any[]>([]);
|
||||
const isStatusVisible = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
campaignId: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
search: '',
|
||||
});
|
||||
|
||||
const statusForm = reactive({
|
||||
id: '',
|
||||
winnerMobile: '',
|
||||
prizeTitle: '',
|
||||
status: 'CONTACTED',
|
||||
note: '',
|
||||
});
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
NEW: { text: 'جدید', color: 'blue' },
|
||||
CONTACTED: { text: 'تماس', color: 'cyan' },
|
||||
DELIVERED: { text: 'تحویل', color: 'success' },
|
||||
ACTIVE_CODE: { text: 'کد فعال', color: 'purple' },
|
||||
USED: { text: 'مصرف', color: 'green' },
|
||||
EXPIRED: { text: 'منقضی', color: 'default' },
|
||||
};
|
||||
|
||||
async function loadCampaigns() {
|
||||
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
|
||||
campaigns.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
}
|
||||
|
||||
async function loadWinners() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/user-prizes', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
campaignId: filters.campaignId || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: filters.search || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
winners.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openStatus(record: any) {
|
||||
statusForm.id = record.id;
|
||||
statusForm.winnerMobile = record.user?.mobile || '';
|
||||
statusForm.prizeTitle = record.prize?.title || '';
|
||||
statusForm.status = record.status || 'CONTACTED';
|
||||
statusForm.note = record.note || '';
|
||||
isStatusVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitStatus() {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/user-prizes/${statusForm.id}/status`, {
|
||||
status: statusForm.status,
|
||||
note: statusForm.note || undefined,
|
||||
});
|
||||
message.success('وضعیت بهروزرسانی شد');
|
||||
isStatusVisible.value = false;
|
||||
loadWinners();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyCode(code: string) {
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code);
|
||||
message.success('کد کپی شد');
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1;
|
||||
loadWinners();
|
||||
}
|
||||
|
||||
function formatDate(val?: string) {
|
||||
if (!val) return '—';
|
||||
return toFarsiDigits(new Date(val).toLocaleDateString('fa-IR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCampaigns();
|
||||
loadWinners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-page">
|
||||
<div class="m-filters">
|
||||
<a-input v-model:value="filters.search" allow-clear placeholder="جستجو..." @pressEnter="onSearch">
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
|
||||
</a-input>
|
||||
<div class="m-filters__row">
|
||||
<a-select v-model:value="filters.campaignId" allow-clear placeholder="کمپین" style="flex:1" @change="onSearch">
|
||||
<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" allow-clear placeholder="وضعیت" style="width:110px" @change="onSearch">
|
||||
<a-select-option v-for="(v, k) in statusMap" :key="k" :value="k">{{ v.text }}</a-select-option>
|
||||
</a-select>
|
||||
<button type="button" class="m-icon-btn" @click="loadWinners"><ReloadOutlined :spin="loading" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!winners.length && !loading" class="m-empty">برندهای یافت نشد</div>
|
||||
<div v-for="w in winners" :key="w.id" class="m-card">
|
||||
<div class="m-card__top">
|
||||
<button
|
||||
type="button"
|
||||
class="m-card__user"
|
||||
@click="w.user?.id && router.push(`/m/users/${w.user.id}`)"
|
||||
>
|
||||
{{ w.user?.name || toFarsiDigits(w.user?.mobile || '—') }}
|
||||
</button>
|
||||
<a-tag :color="statusMap[w.status]?.color || 'default'">
|
||||
{{ statusMap[w.status]?.text || w.status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="m-card__line">{{ w.prize?.title || '—' }} · {{ w.campaign?.title || '—' }}</div>
|
||||
<div class="m-card__meta">
|
||||
<button v-if="w.code" type="button" class="m-code" @click="copyCode(w.code)">
|
||||
<CopyOutlined /> {{ w.code }}
|
||||
</button>
|
||||
<span v-else>—</span>
|
||||
<span>{{ formatDate(w.wonAt || w.createdAt) }}</span>
|
||||
</div>
|
||||
<button type="button" class="m-edit" @click="openStatus(w)"><EditOutlined /> تغییر وضعیت</button>
|
||||
</div>
|
||||
<div v-if="total > filters.pageSize" class="m-pager">
|
||||
<a-pagination v-model:current="filters.page" :total="total" :page-size="filters.pageSize" size="small" @change="loadWinners" />
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer v-model:open="isStatusVisible" placement="bottom" :height="420" title="وضعیت برنده">
|
||||
<p class="hint">{{ toFarsiDigits(statusForm.winnerMobile) }} — {{ statusForm.prizeTitle }}</p>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select v-model:value="statusForm.status" size="large" style="width:100%">
|
||||
<a-select-option v-for="(v, k) in statusMap" :key="k" :value="k">{{ v.text }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea v-model:value="statusForm.note" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block size="large" :loading="isSubmitting" @click="submitStatus">ذخیره</a-button>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-page { padding: 12px 14px 24px; }
|
||||
.m-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.m-filters__row { display: flex; gap: 6px; }
|
||||
.m-filters :deep(.ant-input-affix-wrapper),
|
||||
.m-filters :deep(.ant-select-selector) { border-radius: 12px !important; min-height: 42px !important; }
|
||||
.m-icon-btn { width: 42px; height: 42px; border: none; border-radius: 12px; background: #fff; color: #4f46e5; }
|
||||
.m-empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.m-card { background: #fff; border-radius: 14px; padding: 12px; margin-bottom: 8px; border: 1px solid #f1f5f9; }
|
||||
.m-card__top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.m-card__user { border: none; background: transparent; padding: 0; font-weight: 800; color: #4f46e5; font-size: 13px; }
|
||||
.m-card__line { font-size: 12px; color: #334155; }
|
||||
.m-card__meta { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11px; color: #94a3b8; }
|
||||
.m-code { border: none; background: #f8fafc; color: #4f46e5; border-radius: 8px; padding: 4px 8px; font-size: 11px; direction: ltr; }
|
||||
.m-edit {
|
||||
margin-top: 10px; width: 100%; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 10px;
|
||||
padding: 10px; font-weight: 700; font-size: 12px; color: #334155; min-height: 42px;
|
||||
}
|
||||
.m-pager { display: flex; justify-content: center; padding: 12px 0; }
|
||||
.hint { color: #64748b; margin-bottom: 12px; font-size: 12px; }
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { formatFarsiNumber, parseFarsiNumber, toEnglishDigits, toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
import { openClientCampaign } from '../../utils/clientUrl';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -349,8 +350,7 @@ onMounted(() => {
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="campaign?.slug"
|
||||
:href="'http://localhost:5174/c/' + campaign.slug"
|
||||
target="_blank"
|
||||
@click="openClientCampaign(campaign.slug)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
مشاهده گردونه کاربر ↗
|
||||
|
||||
@@ -79,25 +79,54 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
const prizeTypeLabels: Record<string, string> = {
|
||||
DIGITAL_CODE: 'کد دیجیتال',
|
||||
PHYSICAL: 'کالای فیزیکی',
|
||||
DISCOUNT: 'کد تخفیف',
|
||||
CASH: 'جایزه نقدی',
|
||||
FREE_CHANCE: 'شانس مجدد',
|
||||
CUSTOM: 'سفارشی',
|
||||
const prizeTypeMap: Record<string, { text: string; color: string }> = {
|
||||
PHYSICAL: { text: 'کالای فیزیکی', color: 'blue' },
|
||||
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
|
||||
DISCOUNT: { text: 'کد تخفیف', color: 'green' },
|
||||
CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
|
||||
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
|
||||
CASH: { text: 'جایزه نقدی', color: 'gold' },
|
||||
NO_PRIZE: { text: 'پوچ', color: 'default' },
|
||||
OTHER: { text: 'شانس مجدد', color: 'cyan' },
|
||||
FREE_CHANCE: { text: 'شانس مجدد', color: 'cyan' },
|
||||
DIGITAL_CODE: { text: 'کد دیجیتال', color: 'green' },
|
||||
};
|
||||
|
||||
function getPrizeTypeTag(type?: string) {
|
||||
if (!type) return { text: '—', color: 'default' };
|
||||
return prizeTypeMap[type] || { text: type, color: 'blue' };
|
||||
}
|
||||
|
||||
function formatJalaliDateTime(val?: string | Date | null) {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
const dateStr = d.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return toFarsiDigits(`${dateStr} - ${timeStr}`);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
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: 'نوع جایزه', key: 'prizeType' },
|
||||
{ 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' },
|
||||
{ title: 'تاریخ و ساعت چرخش', key: 'createdAt' },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
@@ -188,8 +217,9 @@ onMounted(() => {
|
||||
</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>
|
||||
<a-tag :color="getPrizeTypeTag(record.prize?.type || record.prizeType).color" style="border-radius: 4px">
|
||||
{{ getPrizeTypeTag(record.prize?.type || record.prizeType).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'isWin'">
|
||||
@@ -208,7 +238,9 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
<span class="date-cell">
|
||||
{{ record.createdAtFormatted ? toFarsiDigits(record.createdAtFormatted) : formatJalaliDateTime(record.createdAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
@@ -311,8 +311,7 @@ onMounted(() => {
|
||||
background: #fff;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
border: 1px solid #f1f5f9;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,24 +128,53 @@ function copyToClipboard(text: string, label: string) {
|
||||
message.success(`${label} با موفقیت کپی شد.`);
|
||||
}
|
||||
|
||||
const prizeTypeLabels: Record<string, string> = {
|
||||
DIGITAL_CODE: 'کد دیجیتال',
|
||||
PHYSICAL: 'کالای فیزیکی',
|
||||
DISCOUNT: 'کد تخفیف',
|
||||
CASH: 'جایزه نقدی',
|
||||
FREE_CHANCE: 'شانس مجدد',
|
||||
CUSTOM: 'سفارشی',
|
||||
const prizeTypeMap: Record<string, { text: string; color: string }> = {
|
||||
PHYSICAL: { text: 'کالای فیزیکی', color: 'blue' },
|
||||
DISCOUNT_CODE: { text: 'کد تخفیف', color: 'green' },
|
||||
DISCOUNT: { text: 'کد تخفیف', color: 'green' },
|
||||
CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
|
||||
WALLET_CREDIT: { text: 'شارژ کیف پول', color: 'purple' },
|
||||
CASH: { text: 'جایزه نقدی', color: 'gold' },
|
||||
NO_PRIZE: { text: 'پوچ', color: 'default' },
|
||||
OTHER: { text: 'شانس مجدد', color: 'cyan' },
|
||||
FREE_CHANCE: { text: 'شانس مجدد', color: 'cyan' },
|
||||
DIGITAL_CODE: { text: 'کد دیجیتال', color: 'green' },
|
||||
};
|
||||
|
||||
function getPrizeTypeTag(type?: string) {
|
||||
if (!type) return { text: '—', color: 'default' };
|
||||
return prizeTypeMap[type] || { text: type, color: 'blue' };
|
||||
}
|
||||
|
||||
function formatJalaliDateTime(val?: string | Date | null) {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
const dateStr = d.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return toFarsiDigits(`${dateStr} - ${timeStr}`);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'mobile' },
|
||||
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'name' },
|
||||
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
|
||||
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
|
||||
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
|
||||
{ title: 'نوع جایزه', key: 'prizeType' },
|
||||
{ title: 'کد دیجیتال / پیگیری', dataIndex: 'code', key: 'code' },
|
||||
{ title: 'وضعیت پیگیری', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'تاریخ برنده شدن', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: 'تاریخ و ساعت برد', key: 'wonAt' },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
@@ -273,7 +302,9 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'prizeType'">
|
||||
<a-tag>{{ prizeTypeLabels[record.prize?.prizeType] || record.prize?.prizeType || '—' }}</a-tag>
|
||||
<a-tag :color="getPrizeTypeTag(record.prize?.type || record.prizeType).color" style="border-radius: 4px">
|
||||
{{ getPrizeTypeTag(record.prize?.type || record.prizeType).text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'code'">
|
||||
@@ -295,8 +326,10 @@ onMounted(() => {
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}</span>
|
||||
<template v-else-if="column.key === 'wonAt' || column.key === 'createdAt'">
|
||||
<span class="date-cell">
|
||||
{{ record.wonAtFormatted ? toFarsiDigits(record.wonAtFormatted) : formatJalaliDateTime(record.wonAt || record.createdAt) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_CLIENT_PUBLIC_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user