Files
Lucky_Wheel/apps/admin/src/views/mobile/MCampaignListView.vue
T

309 lines
8.3 KiB
Vue
Raw Normal View History

<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>