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:
@@ -230,10 +230,8 @@ onMounted(loadSmsToggle)
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
+261
-30
@@ -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
|
||||
<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"
|
||||
v-model:checked="item.inStore"
|
||||
checked-children="فروشگاه"
|
||||
un-checked-children="مخفی"
|
||||
/>
|
||||
size="small"
|
||||
danger
|
||||
class="img-slot-del"
|
||||
@click="removeSlot(item, index)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<p v-if="item.description" class="desc">{{ item.description }}</p>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user