Files
web/src/pages/PluginCatalogPage.vue
T
mohmdsdgandCursor 14e9af2804 feat(plugins): catalog photo slots and receipt preview
SuperAdmin catalog supports three images per plugin and the requests table shows uploaded receipts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:56:10 +03:30

502 lines
15 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 { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import MoneyInput from '@/components/MoneyInput.vue'
import { useAuthStore } from '@/stores/auth'
import { SUPERADMIN_SYSTEM_SETTING_KEYS } from '@/config/superAdminSettingKeys'
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
import {
resolveSettingDescription,
resolveSettingTitle,
} from '@/config/khanegiSettingCatalog'
withDefaults(
defineProps<{
embedded?: boolean
}>(),
{ embedded: false },
)
type PriceRow = {
durationDays: number
enabled: boolean
originalPriceRials: number
finalPriceRials: number
}
type CatalogItem = {
id: string
settingKey: string
titleFa: string
description: string
images: string[]
inStore: boolean
prices: PriceRow[]
}
type TemplateRow = {
key?: string
title_fa?: string
description?: string
type?: string
}
const DURATION_DAYS = [30, 90, 180, 360] as const
const router = useRouter()
const auth = useAuthStore()
const canWrite = auth.can('plugins:write')
const loading = ref(false)
const savingId = ref<string | null>(null)
const uploadingKey = ref<string | null>(null)
const items = ref<CatalogItem[]>([])
const showCreate = ref(false)
const creating = ref(false)
const createForm = reactive({
settingKey: '',
titleFa: '',
description: '',
inStore: true,
})
const durationLabel: Record<number, string> = {
30: '۳۰ روزه',
90: '۹۰ روزه',
180: '۱۸۰ روزه',
360: '۳۶۰ روزه',
}
function emptyPrices(): PriceRow[] {
return DURATION_DAYS.map((durationDays) => ({
durationDays,
enabled: false,
originalPriceRials: 0,
finalPriceRials: 0,
}))
}
function asCatalog(data: unknown): CatalogItem[] {
if (!Array.isArray(data)) return []
return (data as Array<Record<string, any>>).map((row) => ({
id: String(row?.id || ''),
settingKey: String(row?.settingKey || ''),
titleFa: String(row?.titleFa || ''),
description: String(row?.description || ''),
images: Array.isArray(row?.images)
? row.images.map((url: unknown) => rewriteLegacyUploadUrl(url)).filter(Boolean).slice(0, 3)
: [],
inStore: row?.inStore !== false,
prices: Array.isArray(row?.prices) && row.prices.length ? row.prices : emptyPrices(),
}))
}
function buildCards(catalogRows: CatalogItem[], templates: TemplateRow[]): CatalogItem[] {
const byKey = new Map(catalogRows.map((row) => [row.settingKey, row]))
const templateByKey = new Map(
templates.filter((row) => row.key).map((row) => [String(row.key), row]),
)
const extra = catalogRows
.map((row) => row.settingKey)
.filter((key) => key && !SUPERADMIN_SYSTEM_SETTING_KEYS.has(key))
const keys = [...SUPERADMIN_SYSTEM_SETTING_KEYS, ...extra]
return keys.map((key) => {
const hit = byKey.get(key)
const tpl = templateByKey.get(key)
const titleFa = resolveSettingTitle({ key, title_fa: hit?.titleFa || tpl?.title_fa || '' })
const description = resolveSettingDescription({
key,
description: hit?.description || tpl?.description || '',
type: tpl?.type || 'switch',
})
if (hit) {
return {
...hit,
titleFa: hit.titleFa || titleFa,
description: hit.description || description,
images: hit.images || [],
}
}
return {
id: '',
settingKey: key,
titleFa,
description,
images: [],
inStore: true,
prices: emptyPrices(),
}
})
}
async function load() {
loading.value = true
try {
const [{ data: catalog }, templatesRes] = await Promise.all([
api.get('/plugins/store/catalog'),
api.get('/setting-templates').catch(() => ({ data: { items: [] } })),
])
const templateRows = Array.isArray(templatesRes.data?.items) ? templatesRes.data.items : []
items.value = buildCards(asCatalog(catalog), templateRows)
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت کاتالوگ')
items.value = buildCards([], [])
} finally {
loading.value = false
}
}
async function ensureCatalogId(item: CatalogItem): Promise<string> {
if (item.id) return item.id
try {
const { data } = await api.post('/plugins/store/catalog', {
settingKey: item.settingKey,
titleFa: item.titleFa,
description: item.description,
inStore: item.inStore,
})
item.id = String(data?.id || '')
return item.id
} catch (e: any) {
const { data } = await api.get('/plugins/store/catalog')
const found = asCatalog(data).find((row) => row.settingKey === item.settingKey)
if (found?.id) {
item.id = found.id
return found.id
}
throw e
}
}
async function save(item: CatalogItem) {
for (const price of item.prices) {
if (price.enabled && (price.originalPriceRials == null || price.finalPriceRials == null)) {
message.warning(`برای ${durationLabel[price.durationDays]} باید قیمت بزنید`)
return
}
}
savingId.value = item.settingKey
try {
const id = await ensureCatalogId(item)
await api.patch(`/plugins/store/catalog/${id}`, {
inStore: item.inStore,
titleFa: item.titleFa,
description: item.description,
images: item.images || [],
prices: item.prices,
})
message.success('ذخیره شد')
await load()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
savingId.value = null
}
}
async function createSetting() {
if (!createForm.settingKey.trim() || !createForm.titleFa.trim()) {
message.warning('کلید و عنوان لازم است')
return
}
creating.value = true
try {
await api.post('/plugins/store/catalog', { ...createForm })
message.success('تنظیم اضافه شد')
showCreate.value = false
createForm.settingKey = ''
createForm.titleFa = ''
createForm.description = ''
createForm.inStore = true
await load()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
} finally {
creating.value = false
}
}
async function uploadSlot(
item: CatalogItem,
options: { file: string | Blob | File; onSuccess?: (body?: unknown) => void; onError?: (e: Error) => void },
) {
const file = options.file
if (!(file instanceof File) && !(file instanceof Blob)) return
if ((item.images || []).length >= 3) {
message.warning('حداکثر سه عکس')
return
}
uploadingKey.value = item.settingKey
try {
const body = new FormData()
body.append('file', file)
const { data } = await api.post<{ url: string }>('/plugins/store/upload-image', body)
const url = rewriteLegacyUploadUrl(data.url)
if (url) item.images = [...(item.images || []), url].slice(0, 3)
options.onSuccess?.(data)
} catch (e: any) {
const msg = e?.response?.data?.message
options.onError?.(e instanceof Error ? e : new Error(String(msg || 'upload failed')))
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'آپلود ناموفق')
} finally {
uploadingKey.value = null
}
}
function removeSlot(item: CatalogItem, index: number) {
item.images = (item.images || []).filter((_, i) => i !== index)
}
function slotUploader(item: CatalogItem) {
return (options: {
file: string | Blob | File
onSuccess?: (body?: unknown) => void
onError?: (e: Error) => void
}) => uploadSlot(item, options)
}
onMounted(load)
</script>
<template>
<div :class="embedded ? 'catalog-embed' : 'page-shell'">
<div
v-if="!embedded"
style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px"
>
<a-button type="text" @click="router.push({ name: 'plugin-panel' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">قیمت فروشگاه خانگی</h1>
<p class="page-sub">برای همهٔ نسخه‌های خانگی یکی است. مدت خاموش در ادمین دیده نمی‌شود.</p>
</div>
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
<PlusOutlined /> تنظیم جدید
</a-button>
</div>
<div v-else class="catalog-embed-bar">
<p class="catalog-hint">
همهٔ کلیدهای دسترسی قالب خانگی. مدت خاموش در ادمین ماژول دیده نمی‌شود.
</p>
<a-button v-if="canWrite" type="primary" size="small" @click="showCreate = true">
<PlusOutlined /> تنظیم جدید
</a-button>
</div>
<a-spin :spinning="loading">
<div class="catalog-grid">
<article v-for="item in items" :key="item.settingKey" class="catalog-card">
<a-form layout="vertical" class="catalog-form">
<a-form-item label="عنوان" required>
<a-input v-model:value="item.titleFa" :disabled="!canWrite" placeholder="مثلاً ماژول جدید" />
</a-form-item>
<a-form-item label="کلید تنظیمات">
<a-input :value="item.settingKey" disabled dir="ltr" />
</a-form-item>
<a-form-item label="توضیح">
<a-textarea v-model:value="item.description" :rows="4" :maxlength="4000" :disabled="!canWrite" />
</a-form-item>
<a-form-item label="عکس‌ها">
<p class="img-hint">حداکثر سه عکس. در ادمین ماژول عکس اول روی کارت است.</p>
<div class="img-slots">
<div v-for="(url, index) in (item.images || [])" :key="url + index" class="img-slot">
<img :src="url" alt="" />
<a-button
v-if="canWrite"
size="small"
danger
class="img-slot-del"
@click="removeSlot(item, index)"
>
<DeleteOutlined />
</a-button>
</div>
<a-upload
v-if="canWrite && (item.images || []).length < 3"
name="file"
list-type="picture-card"
:show-upload-list="false"
accept="image/jpeg,image/png,image/gif,image/webp"
:custom-request="slotUploader(item)"
:disabled="uploadingKey === item.settingKey"
>
<div class="img-add">
<PlusOutlined />
<span>{{ uploadingKey === item.settingKey ? '…' : 'آپلود' }}</span>
</div>
</a-upload>
</div>
</a-form-item>
<a-form-item label="به فروشگاه افزونه اضافه شود">
<a-switch v-model:checked="item.inStore" :disabled="!canWrite" />
</a-form-item>
</a-form>
<div v-for="price in item.prices" :key="price.durationDays" class="price-row">
<a-switch v-model:checked="price.enabled" :disabled="!canWrite" />
<span class="dur">{{ durationLabel[price.durationDays] }}</span>
<div class="price-fields" :class="{ dim: !price.enabled }">
<label>
اصلی
<MoneyInput v-model:value="price.originalPriceRials" :disabled="!canWrite || !price.enabled" />
</label>
<label>
نهایی
<MoneyInput v-model:value="price.finalPriceRials" :disabled="!canWrite || !price.enabled" />
</label>
</div>
</div>
<a-button
v-if="canWrite"
type="primary"
block
:loading="savingId === item.settingKey"
@click="save(item)"
>
ذخیره
</a-button>
</article>
<div v-if="!items.length" class="empty-hint">کلید دسترسی‌ای در قالب نیست</div>
</div>
</a-spin>
<a-modal
v-model:open="showCreate"
title="تنظیم جدید"
ok-text="ثبت"
cancel-text="انصراف"
:confirm-loading="creating"
@ok="createSetting"
>
<a-form layout="vertical" style="margin-top: 12px">
<a-form-item label="عنوان" required>
<a-input v-model:value="createForm.titleFa" placeholder="مثلاً ماژول جدید" />
</a-form-item>
<a-form-item label="کلید تنظیمات" required>
<a-input v-model:value="createForm.settingKey" placeholder="enable_something" dir="ltr" />
</a-form-item>
<a-form-item label="توضیح">
<a-textarea v-model:value="createForm.description" :rows="4" :maxlength="4000" />
</a-form-item>
<a-form-item label="به فروشگاه افزونه اضافه شود">
<a-switch v-model:checked="createForm.inStore" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.catalog-embed-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.catalog-hint {
margin: 0;
flex: 1;
min-width: 12rem;
color: #64748b;
font-size: 0.85rem;
}
.catalog-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 640px) {
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.catalog-card {
background: #fff;
border: 1px solid #e8eef5;
border-radius: 18px;
padding: 14px;
display: grid;
gap: 10px;
}
.catalog-form :deep(.ant-form-item) {
margin-bottom: 10px;
}
.catalog-form :deep(.ant-form-item:last-child) {
margin-bottom: 0;
}
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
.price-row {
display: grid;
grid-template-columns: auto 4.5rem 1fr;
gap: 8px;
align-items: center;
}
.dur { font-weight: 700; font-size: 0.82rem; }
.price-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.price-fields.dim { opacity: 0.45; }
.price-fields label {
display: grid;
gap: 4px;
font-size: 0.75rem;
color: #64748b;
}
.img-hint {
margin: 0 0 6px;
font-size: 0.75rem;
color: #64748b;
}
.img-slots {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.img-slot {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 10px;
border: 1px solid #e8eef5;
background: #f8fafc;
}
.img-slot img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.img-slot-del {
position: absolute;
top: 4px;
left: 4px;
}
.img-add {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 0.75rem;
}
.img-slots :deep(.ant-upload.ant-upload-select-picture-card) {
width: 100%;
height: auto;
aspect-ratio: 1;
margin: 0;
}
</style>