feat(plugins): SuperAdmin store tabs, pricing, and request approval

Khanegi plugin prices and purchase requests sit beside existing version cards. Also rewrite leftover nerkh upload URLs on tenant forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 16:56:52 +03:30
co-authored by Cursor
parent c970d74f75
commit 7eacb513cd
8 changed files with 530 additions and 9 deletions
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
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 api from '@/api/http'
import MoneyInput from '@/components/MoneyInput.vue'
import { useAuthStore } from '@/stores/auth'
type PriceRow = {
durationDays: number
enabled: boolean
originalPriceRials: number
finalPriceRials: number
}
type CatalogItem = {
id: string
settingKey: string
titleFa: string
description: string | null
inStore: boolean
prices: PriceRow[]
}
const router = useRouter()
const auth = useAuthStore()
const canWrite = auth.can('plugins:write')
const loading = ref(false)
const savingId = 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: '۳۶۰ روزه',
}
async function load() {
loading.value = true
try {
const { data } = await api.get('/plugins/store/catalog')
items.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت کاتالوگ')
} finally {
loading.value = false
}
}
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.id
try {
await api.patch(`/plugins/store/catalog/${item.id}`, {
inStore: item.inStore,
titleFa: item.titleFa,
description: item.description,
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
}
}
onMounted(load)
</script>
<template>
<div class="page-shell">
<div 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>
<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>
<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.id"
@click="save(item)"
>
ذخیره
</a-button>
</article>
</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="2" />
</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-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 12px;
}
.catalog-card {
background: #fff;
border: 1px solid #e8eef5;
border-radius: 18px;
padding: 14px;
display: grid;
gap: 10px;
}
.catalog-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
}
.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;
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;
}
</style>