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>
This commit is contained in:
2026-09-08 14:56:10 +03:30
co-authored by Cursor
parent ca923c7d9b
commit 14e9af2804
3 changed files with 275 additions and 35 deletions
-2
View File
@@ -230,10 +230,8 @@ onMounted(loadSmsToggle)
.content {
padding: 12px;
max-width: 1200px;
width: 100%;
min-width: 0;
margin: 0 auto;
}
@media (min-width: 768px) {
+263 -32
View File
@@ -2,10 +2,16 @@
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined } from '@ant-design/icons-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<{
@@ -25,16 +31,27 @@ type CatalogItem = {
id: string
settingKey: string
titleFa: string
description: string | null
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)
@@ -52,18 +69,107 @@ const durationLabel: Record<number, string> = {
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 } = await api.get('/plugins/store/catalog')
items.value = data
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)) {
@@ -71,12 +177,14 @@ async function save(item: CatalogItem) {
return
}
}
savingId.value = item.id
savingId.value = item.settingKey
try {
await api.patch(`/plugins/store/catalog/${item.id}`, {
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('ذخیره شد')
@@ -112,6 +220,45 @@ async function createSetting() {
}
}
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>
@@ -134,7 +281,7 @@ onMounted(load)
</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 /> تنظیم جدید
@@ -143,20 +290,52 @@ onMounted(load)
<a-spin :spinning="loading">
<div class="catalog-grid">
<article v-for="item in items" :key="item.id" class="catalog-card">
<div class="catalog-head">
<div>
<strong>{{ item.titleFa }}</strong>
<small>{{ item.settingKey }}</small>
</div>
<a-switch
v-if="canWrite"
v-model:checked="item.inStore"
checked-children="فروشگاه"
un-checked-children="مخفی"
/>
</div>
<p v-if="item.description" class="desc">{{ item.description }}</p>
<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>
@@ -175,12 +354,13 @@ onMounted(load)
v-if="canWrite"
type="primary"
block
:loading="savingId === item.id"
:loading="savingId === item.settingKey"
@click="save(item)"
>
ذخیره
</a-button>
</article>
<div v-if="!items.length" class="empty-hint">کلید دسترسی‌ای در قالب نیست</div>
</div>
</a-spin>
@@ -200,7 +380,7 @@ onMounted(load)
<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="2" />
<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" />
@@ -228,9 +408,15 @@ onMounted(load)
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
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;
@@ -239,15 +425,18 @@ onMounted(load)
display: grid;
gap: 10px;
}
.catalog-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
.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;
}
.catalog-head strong { display: block; }
.catalog-head small { color: #94a3b8; direction: ltr; }
.desc { margin: 0; color: #64748b; font-size: 0.85rem; }
.price-row {
display: grid;
grid-template-columns: auto 4.5rem 1fr;
@@ -267,4 +456,46 @@ onMounted(load)
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>
+12 -1
View File
@@ -31,6 +31,7 @@ type RequestRow = {
durationDays: number
finalPriceRials: number
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
receiptUrl?: string | null
createdAt: string
}
@@ -283,6 +284,7 @@ onMounted(() => {
<th>مدت</th>
<th>مبلغ</th>
<th>وضعیت</th>
<th>فیش</th>
<th></th>
</tr>
</thead>
@@ -296,6 +298,15 @@ onMounted(() => {
<td>{{ row.durationDays }} روز</td>
<td>{{ Number(row.finalPriceRials || 0).toLocaleString('fa-IR') }}</td>
<td>{{ statusLabel[row.status] }}</td>
<td>
<a-image
v-if="row.receiptUrl"
:src="row.receiptUrl"
:width="44"
:preview="{ zIndex: 1200 }"
/>
<span v-else>—</span>
</td>
<td>
<template v-if="row.status === 'pending'">
<a-button size="small" type="primary" :loading="actingId === row.id" @click="approve(row)">تأیید</a-button>
@@ -304,7 +315,7 @@ onMounted(() => {
</td>
</tr>
<tr v-if="!requests.length">
<td colspan="6" class="empty-hint">درخواستی نیست</td>
<td colspan="7" class="empty-hint">درخواستی نیست</td>
</tr>
</tbody>
</table>