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:
@@ -9,7 +9,9 @@ import api from '@/api/http'
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const adminSmsEnabled = ref(true)
|
||||
const pluginStoreEnabled = ref(false)
|
||||
const smsToggleLoading = ref(false)
|
||||
const storeToggleLoading = ref(false)
|
||||
const canSettingsWrite = computed(() => auth.can('settings:write'))
|
||||
|
||||
function goHome() {
|
||||
@@ -26,6 +28,7 @@ async function loadSmsToggle() {
|
||||
try {
|
||||
const { data } = await api.get('/settings/admin-sms')
|
||||
adminSmsEnabled.value = data.adminSmsEnabled !== false
|
||||
pluginStoreEnabled.value = data.pluginStoreEnabled === true
|
||||
} catch {
|
||||
// keep default on; non-blocking for layout
|
||||
}
|
||||
@@ -50,6 +53,25 @@ async function onSmsToggle(checked: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onStoreToggle(checked: boolean) {
|
||||
if (!canSettingsWrite.value) return
|
||||
storeToggleLoading.value = true
|
||||
const previous = pluginStoreEnabled.value
|
||||
pluginStoreEnabled.value = checked
|
||||
try {
|
||||
const { data } = await api.patch('/settings/plugin-store', { enabled: checked })
|
||||
pluginStoreEnabled.value = data.pluginStoreEnabled === true
|
||||
message.success(
|
||||
pluginStoreEnabled.value ? 'فروشگاه افزونه روشن شد' : 'فروشگاه افزونه خاموش شد',
|
||||
)
|
||||
} catch (e: any) {
|
||||
pluginStoreEnabled.value = previous
|
||||
message.error(e?.response?.data?.message || 'تغییر فروشگاه افزونه ناموفق')
|
||||
} finally {
|
||||
storeToggleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSmsToggle)
|
||||
</script>
|
||||
|
||||
@@ -79,6 +101,20 @@ onMounted(loadSmsToggle)
|
||||
@change="onSmsToggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="canSettingsWrite"
|
||||
class="sms-toggle"
|
||||
:class="{ off: !pluginStoreEnabled }"
|
||||
>
|
||||
<span class="sms-toggle-label">فروشگاه افزونه</span>
|
||||
<a-switch
|
||||
:checked="pluginStoreEnabled"
|
||||
:loading="storeToggleLoading"
|
||||
checked-children="روشن"
|
||||
un-checked-children="خاموش"
|
||||
@change="onStoreToggle"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="icon-btn" title="داشبورد" @click="goHome">
|
||||
<HomeOutlined />
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -59,6 +59,12 @@ const router = createRouter({
|
||||
component: () => import('@/pages/PluginPanelPage.vue'),
|
||||
meta: { permissions: ['plugins:read'] },
|
||||
},
|
||||
{
|
||||
path: 'plugins/catalog',
|
||||
name: 'plugin-catalog',
|
||||
component: () => import('@/pages/PluginCatalogPage.vue'),
|
||||
meta: { permissions: ['plugins:read'] },
|
||||
},
|
||||
{
|
||||
path: 'plugins/:tenantId',
|
||||
name: 'plugin-tenant',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const LEGACY_UPLOAD_HOSTS = new Set([
|
||||
'nerkh.gold',
|
||||
'www.nerkh.gold',
|
||||
'tablo.talasevin.ir',
|
||||
'ah.aygingold.ir',
|
||||
'abshode-service.montgit.ir',
|
||||
])
|
||||
|
||||
function saPublicBase(): string {
|
||||
const api = String(import.meta.env.VITE_API_BASE || '')
|
||||
try {
|
||||
if (/^https?:\/\//i.test(api)) {
|
||||
return new URL(api).origin
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin
|
||||
}
|
||||
return 'https://adm.montzar.ir'
|
||||
}
|
||||
|
||||
/** لوگوهای قدیمی nerkh را از هاست سوپرادمین بخوان */
|
||||
export function rewriteLegacyUploadUrl(raw: unknown): string {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return ''
|
||||
if (value.startsWith('/uploads/')) return `${saPublicBase()}${value}`
|
||||
if (!/^https?:\/\//i.test(value)) return value
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
const host = parsed.hostname.toLowerCase()
|
||||
if (!LEGACY_UPLOAD_HOSTS.has(host)) return value
|
||||
if (!/\/uploads\//i.test(parsed.pathname)) return value
|
||||
return `${saPublicBase()}${parsed.pathname}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user