309 lines
8.3 KiB
Vue
309 lines
8.3 KiB
Vue
<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>
|