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>
+159 -6
View File
@@ -20,11 +20,27 @@ type TenantRow = {
}
}
type RequestRow = {
id: string
tenantName: string
domain: string
pluginTitle: string
durationDays: number
finalPriceRials: number
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
createdAt: string
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<'healthy' | 'warning' | 'critical' | 'unknown' | null>(null)
const mainTab = ref<'khanegi' | 'hamkar' | 'requests'>('khanegi')
const requests = ref<RequestRow[]>([])
const loadingRequests = ref(false)
const actingId = ref<string | null>(null)
const autoCut = ref(true)
const filteredTenants = computed(() => {
const term = q.value.trim().toLowerCase()
@@ -68,7 +84,64 @@ async function loadTenants() {
}
}
onMounted(loadTenants)
const statusLabel: Record<RequestRow['status'], string> = {
pending: 'در انتظار',
approved: 'تأیید شده',
rejected: 'رد شده',
cancelled: 'لغو شده',
}
async function loadRequests() {
loadingRequests.value = true
try {
const { data } = await api.get('/plugins/store/requests')
requests.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت درخواست‌ها')
} finally {
loadingRequests.value = false
}
}
async function approve(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/approve`, {
autoCutOnExpiry: autoCut.value,
})
message.success('تأیید شد و سوییچ روشن شد')
await loadRequests()
await loadTenants()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'تأیید ناموفق')
} finally {
actingId.value = null
}
}
async function reject(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/reject`)
message.success('رد شد')
await loadRequests()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'رد ناموفق')
} finally {
actingId.value = null
}
}
function setTab(tab: 'khanegi' | 'hamkar' | 'requests') {
mainTab.value = tab
if (tab === 'requests') void loadRequests()
}
onMounted(() => {
loadTenants()
})
</script>
<template>
@@ -86,7 +159,13 @@ onMounted(loadTenants)
</div>
</div>
<div class="toolbar">
<div class="main-tabs">
<button type="button" :class="{ active: mainTab === 'khanegi' }" @click="setTab('khanegi')">خانگی</button>
<button type="button" :class="{ active: mainTab === 'hamkar' }" @click="setTab('hamkar')">همکار</button>
<button type="button" :class="{ active: mainTab === 'requests' }" @click="setTab('requests')">درخواست‌ها</button>
</div>
<div v-if="mainTab !== 'requests'" class="toolbar">
<a-input
v-model:value="q"
allow-clear
@@ -95,9 +174,12 @@ onMounted(loadTenants)
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-button v-if="mainTab === 'khanegi'" type="primary" @click="router.push({ name: 'plugin-catalog' })">
قیمت فروشگاه
</a-button>
</div>
<div class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
<div v-if="mainTab !== 'requests'" class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
<button
type="button"
class="chip all"
@@ -140,11 +222,55 @@ onMounted(loadTenants)
</button>
</div>
<section class="sms-selector">
<section v-if="mainTab === 'requests'" class="sms-selector">
<div class="sms-side-head">درخواست‌های خانگی</div>
<div class="req-toolbar">
<a-checkbox v-model:checked="autoCut">قطع خودکار بعد از انقضا (موقع تأیید)</a-checkbox>
</div>
<a-spin :spinning="loadingRequests">
<div class="req-table-wrap">
<table class="req-table">
<thead>
<tr>
<th>نسخه</th>
<th>افزونه</th>
<th>مدت</th>
<th>مبلغ</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in requests" :key="row.id">
<td>
<strong>{{ row.tenantName }}</strong>
<small>{{ row.domain }}</small>
</td>
<td>{{ row.pluginTitle }}</td>
<td>{{ row.durationDays }} روز</td>
<td>{{ Number(row.finalPriceRials || 0).toLocaleString('fa-IR') }}</td>
<td>{{ statusLabel[row.status] }}</td>
<td>
<template v-if="row.status === 'pending'">
<a-button size="small" type="primary" :loading="actingId === row.id" @click="approve(row)">تأیید</a-button>
<a-button size="small" danger :loading="actingId === row.id" @click="reject(row)">رد</a-button>
</template>
</td>
</tr>
<tr v-if="!requests.length">
<td colspan="6" class="empty-hint">درخواستی نیست</td>
</tr>
</tbody>
</table>
</div>
</a-spin>
</section>
<section v-else class="sms-selector">
<div class="sms-side-head"><AppstoreAddOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section class="tenant-group">
<section v-if="mainTab === 'hamkar'" class="tenant-group">
<div class="tenant-group-head">
<h3>همکار</h3>
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
@@ -166,7 +292,7 @@ onMounted(loadTenants)
</div>
</section>
<section class="tenant-group">
<section v-if="mainTab === 'khanegi'" class="tenant-group">
<div class="tenant-group-head">
<h3>خانگی</h3>
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
@@ -194,6 +320,33 @@ onMounted(loadTenants)
</template>
<style scoped>
.main-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.main-tabs button {
appearance: none;
border: 1px solid #d7e0ea;
background: #fff;
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.main-tabs button.active {
background: #17324d;
color: #fff;
border-color: #17324d;
}
.req-toolbar { margin-bottom: 12px; }
.req-table-wrap { overflow-x: auto; }
.req-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.req-table th, .req-table td { padding: 8px 6px; text-align: right; border-bottom: 1px solid #eef2f7; }
.req-table small { display: block; color: #94a3b8; }
.req-table .ant-btn + .ant-btn { margin-right: 6px; }
.legend {
display: flex;
flex-wrap: wrap;
+47
View File
@@ -23,6 +23,8 @@ type PluginItem = {
planMonths: number | null
expiresAt: string | null
alertDaysBefore: number
settingKey?: string | null
autoCutOnExpiry?: boolean
isActive: boolean
summary: {
expiryLevel: ExpiryLevel
@@ -53,6 +55,8 @@ const tenant = ref<{
} | null>(null)
const plugins = ref<PluginItem[]>([])
const planOptions = ref<{ months: number; label: string }[]>([])
const catalogKeys = ref<{ settingKey: string; titleFa: string }[]>([])
const isKhanegi = computed(() => tenant.value?.product === 'khanegi')
const form = reactive({
name: '',
@@ -63,6 +67,8 @@ const form = reactive({
planMonths: 1 as number,
alertDaysBefore: 3,
isActive: true,
settingKey: undefined as string | undefined,
autoCutOnExpiry: false,
})
const createForm = reactive({
@@ -73,6 +79,8 @@ const createForm = reactive({
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
alertDaysBefore: 3,
settingKey: undefined as string | undefined,
autoCutOnExpiry: false,
})
const renewForm = reactive({
@@ -112,6 +120,8 @@ function fillForm(plugin: PluginItem) {
planMonths: plugin.planMonths || 1,
alertDaysBefore: plugin.alertDaysBefore ?? 3,
isActive: plugin.isActive !== false,
settingKey: plugin.settingKey || undefined,
autoCutOnExpiry: plugin.autoCutOnExpiry === true,
})
renewForm.planMonths = plugin.planMonths || 1
renewForm.priceRials = plugin.priceRials ?? 0
@@ -133,6 +143,7 @@ async function loadDetail() {
tenant.value = data.tenant
plugins.value = data.plugins || []
planOptions.value = data.planOptions || []
catalogKeys.value = data.catalogKeys || []
const keep =
selectedId.value && plugins.value.some((p) => p.id === selectedId.value)
@@ -165,6 +176,10 @@ async function createPlugin() {
chargedAt: createForm.chargedAt || undefined,
planMonths: Number(createForm.planMonths),
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
...(isKhanegi.value && {
settingKey: createForm.settingKey || undefined,
autoCutOnExpiry: createForm.autoCutOnExpiry,
}),
})
message.success('افزونه اضافه شد')
showCreate.value = false
@@ -175,6 +190,8 @@ async function createPlugin() {
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
createForm.planMonths = 1
createForm.alertDaysBefore = 3
createForm.settingKey = undefined
createForm.autoCutOnExpiry = false
await loadDetail()
if (data?.plugin?.id) selectPlugin(data.plugin.id)
} catch (e: any) {
@@ -202,6 +219,10 @@ async function savePlugin() {
planMonths: Number(form.planMonths),
alertDaysBefore: Number(form.alertDaysBefore) || 3,
isActive: form.isActive,
...(isKhanegi.value && {
settingKey: form.settingKey || null,
autoCutOnExpiry: form.autoCutOnExpiry,
}),
})
message.success('ذخیره شد')
await loadDetail()
@@ -332,6 +353,19 @@ onMounted(loadDetail)
<a-form-item label="فعال">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
<template v-if="isKhanegi">
<a-form-item label="کلید تنظیمات (فروشگاه)">
<a-select
v-model:value="form.settingKey"
allow-clear
placeholder="دستی — بدون فروشگاه"
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
/>
</a-form-item>
<a-form-item label="قطع خودکار بعد از انقضا">
<a-switch v-model:checked="form.autoCutOnExpiry" />
</a-form-item>
</template>
</a-form>
</section>
@@ -472,6 +506,19 @@ onMounted(loadDetail)
<a-form-item label="قیمت دوره (تومان)">
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 150,000" />
</a-form-item>
<template v-if="isKhanegi">
<a-form-item label="کلید تنظیمات (فروشگاه)">
<a-select
v-model:value="createForm.settingKey"
allow-clear
placeholder="دستی — بدون فروشگاه"
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
/>
</a-form-item>
<a-form-item label="قطع خودکار بعد از انقضا">
<a-switch v-model:checked="createForm.autoCutOnExpiry" />
</a-form-item>
</template>
</a-form>
</a-modal>
</div>
+2 -1
View File
@@ -7,6 +7,7 @@ import api from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import { formatIranDateTime } from '@/utils/datetime'
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
type Tenant = {
id: string
@@ -133,7 +134,7 @@ onMounted(load)
<img
v-if="item.avatarUrl"
class="version-avatar"
:src="item.avatarUrl"
:src="rewriteLegacyUploadUrl(item.avatarUrl)"
:alt="item.nameFa"
/>
<div
+3 -2
View File
@@ -7,6 +7,7 @@ import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import { useAuthStore } from '@/stores/auth'
import { formatIranDateTime } from '@/utils/datetime'
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
const route = useRoute()
const router = useRouter()
@@ -69,7 +70,7 @@ async function load() {
nameEn: data.nameEn || '',
domain: data.domain,
domains: Array.isArray(data.domains) ? data.domains.map(String) : [],
avatarUrl: data.avatarUrl || '',
avatarUrl: rewriteLegacyUploadUrl(data.avatarUrl || ''),
appVersion: data.appVersion || '1',
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
nodeServer: data.nodeServer,
@@ -100,7 +101,7 @@ async function uploadLogo(options: {
const body = new FormData()
body.append('file', file)
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
form.avatarUrl = data.url
form.avatarUrl = rewriteLegacyUploadUrl(data.url)
options.onSuccess?.(data)
message.success('لوگو آپلود شد')
} catch (e: any) {