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

309 lines
8.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>