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 auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const adminSmsEnabled = ref(true)
|
const adminSmsEnabled = ref(true)
|
||||||
|
const pluginStoreEnabled = ref(false)
|
||||||
const smsToggleLoading = ref(false)
|
const smsToggleLoading = ref(false)
|
||||||
|
const storeToggleLoading = ref(false)
|
||||||
const canSettingsWrite = computed(() => auth.can('settings:write'))
|
const canSettingsWrite = computed(() => auth.can('settings:write'))
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
@@ -26,6 +28,7 @@ async function loadSmsToggle() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get('/settings/admin-sms')
|
const { data } = await api.get('/settings/admin-sms')
|
||||||
adminSmsEnabled.value = data.adminSmsEnabled !== false
|
adminSmsEnabled.value = data.adminSmsEnabled !== false
|
||||||
|
pluginStoreEnabled.value = data.pluginStoreEnabled === true
|
||||||
} catch {
|
} catch {
|
||||||
// keep default on; non-blocking for layout
|
// 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)
|
onMounted(loadSmsToggle)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,6 +101,20 @@ onMounted(loadSmsToggle)
|
|||||||
@change="onSmsToggle"
|
@change="onSmsToggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<button type="button" class="icon-btn" title="داشبورد" @click="goHome">
|
||||||
<HomeOutlined />
|
<HomeOutlined />
|
||||||
</button>
|
</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 router = useRouter()
|
||||||
const loadingList = ref(false)
|
const loadingList = ref(false)
|
||||||
const tenants = ref<TenantRow[]>([])
|
const tenants = ref<TenantRow[]>([])
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
const toneFilter = ref<'healthy' | 'warning' | 'critical' | 'unknown' | null>(null)
|
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 filteredTenants = computed(() => {
|
||||||
const term = q.value.trim().toLowerCase()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -86,7 +159,13 @@ onMounted(loadTenants)
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<a-input
|
||||||
v-model:value="q"
|
v-model:value="q"
|
||||||
allow-clear
|
allow-clear
|
||||||
@@ -95,9 +174,12 @@ onMounted(loadTenants)
|
|||||||
>
|
>
|
||||||
<template #prefix><SearchOutlined /></template>
|
<template #prefix><SearchOutlined /></template>
|
||||||
</a-input>
|
</a-input>
|
||||||
|
<a-button v-if="mainTab === 'khanegi'" type="primary" @click="router.push({ name: 'plugin-catalog' })">
|
||||||
|
قیمت فروشگاه
|
||||||
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
|
<div v-if="mainTab !== 'requests'" class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chip all"
|
class="chip all"
|
||||||
@@ -140,11 +222,55 @@ onMounted(loadTenants)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<div class="sms-side-head"><AppstoreAddOutlined /> نسخهها</div>
|
||||||
<a-spin :spinning="loadingList">
|
<a-spin :spinning="loadingList">
|
||||||
<div class="tenant-groups">
|
<div class="tenant-groups">
|
||||||
<section class="tenant-group">
|
<section v-if="mainTab === 'hamkar'" class="tenant-group">
|
||||||
<div class="tenant-group-head">
|
<div class="tenant-group-head">
|
||||||
<h3>همکار</h3>
|
<h3>همکار</h3>
|
||||||
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
|
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
|
||||||
@@ -166,7 +292,7 @@ onMounted(loadTenants)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tenant-group">
|
<section v-if="mainTab === 'khanegi'" class="tenant-group">
|
||||||
<div class="tenant-group-head">
|
<div class="tenant-group-head">
|
||||||
<h3>خانگی</h3>
|
<h3>خانگی</h3>
|
||||||
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
|
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
|
||||||
@@ -194,6 +320,33 @@ onMounted(loadTenants)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type PluginItem = {
|
|||||||
planMonths: number | null
|
planMonths: number | null
|
||||||
expiresAt: string | null
|
expiresAt: string | null
|
||||||
alertDaysBefore: number
|
alertDaysBefore: number
|
||||||
|
settingKey?: string | null
|
||||||
|
autoCutOnExpiry?: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
summary: {
|
summary: {
|
||||||
expiryLevel: ExpiryLevel
|
expiryLevel: ExpiryLevel
|
||||||
@@ -53,6 +55,8 @@ const tenant = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const plugins = ref<PluginItem[]>([])
|
const plugins = ref<PluginItem[]>([])
|
||||||
const planOptions = ref<{ months: number; label: string }[]>([])
|
const planOptions = ref<{ months: number; label: string }[]>([])
|
||||||
|
const catalogKeys = ref<{ settingKey: string; titleFa: string }[]>([])
|
||||||
|
const isKhanegi = computed(() => tenant.value?.product === 'khanegi')
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -63,6 +67,8 @@ const form = reactive({
|
|||||||
planMonths: 1 as number,
|
planMonths: 1 as number,
|
||||||
alertDaysBefore: 3,
|
alertDaysBefore: 3,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
settingKey: undefined as string | undefined,
|
||||||
|
autoCutOnExpiry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
@@ -73,6 +79,8 @@ const createForm = reactive({
|
|||||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||||
planMonths: 1,
|
planMonths: 1,
|
||||||
alertDaysBefore: 3,
|
alertDaysBefore: 3,
|
||||||
|
settingKey: undefined as string | undefined,
|
||||||
|
autoCutOnExpiry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const renewForm = reactive({
|
const renewForm = reactive({
|
||||||
@@ -112,6 +120,8 @@ function fillForm(plugin: PluginItem) {
|
|||||||
planMonths: plugin.planMonths || 1,
|
planMonths: plugin.planMonths || 1,
|
||||||
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
||||||
isActive: plugin.isActive !== false,
|
isActive: plugin.isActive !== false,
|
||||||
|
settingKey: plugin.settingKey || undefined,
|
||||||
|
autoCutOnExpiry: plugin.autoCutOnExpiry === true,
|
||||||
})
|
})
|
||||||
renewForm.planMonths = plugin.planMonths || 1
|
renewForm.planMonths = plugin.planMonths || 1
|
||||||
renewForm.priceRials = plugin.priceRials ?? 0
|
renewForm.priceRials = plugin.priceRials ?? 0
|
||||||
@@ -133,6 +143,7 @@ async function loadDetail() {
|
|||||||
tenant.value = data.tenant
|
tenant.value = data.tenant
|
||||||
plugins.value = data.plugins || []
|
plugins.value = data.plugins || []
|
||||||
planOptions.value = data.planOptions || []
|
planOptions.value = data.planOptions || []
|
||||||
|
catalogKeys.value = data.catalogKeys || []
|
||||||
|
|
||||||
const keep =
|
const keep =
|
||||||
selectedId.value && plugins.value.some((p) => p.id === selectedId.value)
|
selectedId.value && plugins.value.some((p) => p.id === selectedId.value)
|
||||||
@@ -165,6 +176,10 @@ async function createPlugin() {
|
|||||||
chargedAt: createForm.chargedAt || undefined,
|
chargedAt: createForm.chargedAt || undefined,
|
||||||
planMonths: Number(createForm.planMonths),
|
planMonths: Number(createForm.planMonths),
|
||||||
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
|
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
|
||||||
|
...(isKhanegi.value && {
|
||||||
|
settingKey: createForm.settingKey || undefined,
|
||||||
|
autoCutOnExpiry: createForm.autoCutOnExpiry,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
message.success('افزونه اضافه شد')
|
message.success('افزونه اضافه شد')
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
@@ -175,6 +190,8 @@ async function createPlugin() {
|
|||||||
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||||
createForm.planMonths = 1
|
createForm.planMonths = 1
|
||||||
createForm.alertDaysBefore = 3
|
createForm.alertDaysBefore = 3
|
||||||
|
createForm.settingKey = undefined
|
||||||
|
createForm.autoCutOnExpiry = false
|
||||||
await loadDetail()
|
await loadDetail()
|
||||||
if (data?.plugin?.id) selectPlugin(data.plugin.id)
|
if (data?.plugin?.id) selectPlugin(data.plugin.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -202,6 +219,10 @@ async function savePlugin() {
|
|||||||
planMonths: Number(form.planMonths),
|
planMonths: Number(form.planMonths),
|
||||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
|
...(isKhanegi.value && {
|
||||||
|
settingKey: form.settingKey || null,
|
||||||
|
autoCutOnExpiry: form.autoCutOnExpiry,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
message.success('ذخیره شد')
|
message.success('ذخیره شد')
|
||||||
await loadDetail()
|
await loadDetail()
|
||||||
@@ -332,6 +353,19 @@ onMounted(loadDetail)
|
|||||||
<a-form-item label="فعال">
|
<a-form-item label="فعال">
|
||||||
<a-switch v-model:checked="form.isActive" />
|
<a-switch v-model:checked="form.isActive" />
|
||||||
</a-form-item>
|
</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>
|
</a-form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -472,6 +506,19 @@ onMounted(loadDetail)
|
|||||||
<a-form-item label="قیمت دوره (تومان)">
|
<a-form-item label="قیمت دوره (تومان)">
|
||||||
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 150,000" />
|
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 150,000" />
|
||||||
</a-form-item>
|
</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-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import api from '@/api/http'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatIranDateTime } from '@/utils/datetime'
|
import { formatIranDateTime } from '@/utils/datetime'
|
||||||
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
|
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
|
||||||
|
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
|
||||||
|
|
||||||
type Tenant = {
|
type Tenant = {
|
||||||
id: string
|
id: string
|
||||||
@@ -133,7 +134,7 @@ onMounted(load)
|
|||||||
<img
|
<img
|
||||||
v-if="item.avatarUrl"
|
v-if="item.avatarUrl"
|
||||||
class="version-avatar"
|
class="version-avatar"
|
||||||
:src="item.avatarUrl"
|
:src="rewriteLegacyUploadUrl(item.avatarUrl)"
|
||||||
:alt="item.nameFa"
|
:alt="item.nameFa"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import api from '@/api/http'
|
|||||||
import VersionPageTabs from '@/components/VersionPageTabs.vue'
|
import VersionPageTabs from '@/components/VersionPageTabs.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatIranDateTime } from '@/utils/datetime'
|
import { formatIranDateTime } from '@/utils/datetime'
|
||||||
|
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -69,7 +70,7 @@ async function load() {
|
|||||||
nameEn: data.nameEn || '',
|
nameEn: data.nameEn || '',
|
||||||
domain: data.domain,
|
domain: data.domain,
|
||||||
domains: Array.isArray(data.domains) ? data.domains.map(String) : [],
|
domains: Array.isArray(data.domains) ? data.domains.map(String) : [],
|
||||||
avatarUrl: data.avatarUrl || '',
|
avatarUrl: rewriteLegacyUploadUrl(data.avatarUrl || ''),
|
||||||
appVersion: data.appVersion || '1',
|
appVersion: data.appVersion || '1',
|
||||||
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
|
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
|
||||||
nodeServer: data.nodeServer,
|
nodeServer: data.nodeServer,
|
||||||
@@ -100,7 +101,7 @@ async function uploadLogo(options: {
|
|||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', file)
|
body.append('file', file)
|
||||||
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
|
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
|
||||||
form.avatarUrl = data.url
|
form.avatarUrl = rewriteLegacyUploadUrl(data.url)
|
||||||
options.onSuccess?.(data)
|
options.onSuccess?.(data)
|
||||||
message.success('لوگو آپلود شد')
|
message.success('لوگو آپلود شد')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/pages/PluginPanelPage.vue'),
|
component: () => import('@/pages/PluginPanelPage.vue'),
|
||||||
meta: { permissions: ['plugins:read'] },
|
meta: { permissions: ['plugins:read'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'plugins/catalog',
|
||||||
|
name: 'plugin-catalog',
|
||||||
|
component: () => import('@/pages/PluginCatalogPage.vue'),
|
||||||
|
meta: { permissions: ['plugins:read'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'plugins/:tenantId',
|
path: 'plugins/:tenantId',
|
||||||
name: 'plugin-tenant',
|
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