diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index 2484b3e..9903514 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -230,10 +230,8 @@ onMounted(loadSmsToggle) .content { padding: 12px; - max-width: 1200px; width: 100%; min-width: 0; - margin: 0 auto; } @media (min-width: 768px) { diff --git a/src/pages/PluginCatalogPage.vue b/src/pages/PluginCatalogPage.vue index ed49350..ff3e296 100644 --- a/src/pages/PluginCatalogPage.vue +++ b/src/pages/PluginCatalogPage.vue @@ -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(null) +const uploadingKey = ref(null) const items = ref([]) const showCreate = ref(false) const creating = ref(false) @@ -52,18 +69,107 @@ const durationLabel: Record = { 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>).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 { + 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) @@ -134,7 +281,7 @@ onMounted(load)

- همهٔ کلیدهای تنظیمات خانگی سوپرادمین. مدت خاموش در ادمین ماژول دیده نمی‌شود. + همهٔ کلیدهای دسترسی قالب خانگی. مدت خاموش در ادمین ماژول دیده نمی‌شود.

تنظیم جدید @@ -143,20 +290,52 @@ onMounted(load)
-
-
-
- {{ item.titleFa }} - {{ item.settingKey }} -
- -
-

{{ item.description }}

+
+ + + + + + + + + + + +

حداکثر سه عکس. در ادمین ماژول عکس اول روی کارت است.

+
+
+ + + + +
+ +
+ + {{ uploadingKey === item.settingKey ? '…' : 'آپلود' }} +
+
+
+
+ + + +
{{ durationLabel[price.durationDays] }} @@ -175,12 +354,13 @@ onMounted(load) v-if="canWrite" type="primary" block - :loading="savingId === item.id" + :loading="savingId === item.settingKey" @click="save(item)" > ذخیره
+
کلید دسترسی‌ای در قالب نیست
@@ -200,7 +380,7 @@ onMounted(load) - + @@ -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; +} diff --git a/src/pages/PluginPanelPage.vue b/src/pages/PluginPanelPage.vue index f2609f9..38af35c 100644 --- a/src/pages/PluginPanelPage.vue +++ b/src/pages/PluginPanelPage.vue @@ -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(() => { مدت مبلغ وضعیت + فیش @@ -296,6 +298,15 @@ onMounted(() => { {{ row.durationDays }} روز {{ Number(row.finalPriceRials || 0).toLocaleString('fa-IR') }} {{ statusLabel[row.status] }} + + + — +