SuperAdmin catalog supports three images per plugin and the requests table shows uploaded receipts. Co-authored-by: Cursor <cursoragent@cursor.com>
502 lines
15 KiB
Vue
502 lines
15 KiB
Vue
<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>
|