feat: add 'Other sites' module for tracking server/plugin/support/SMS of miscellaneous sites
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,8 @@ export const PERMISSIONS = [
|
||||
'hamkar:write',
|
||||
'khanegi:read',
|
||||
'khanegi:write',
|
||||
'others:read',
|
||||
'others:write',
|
||||
'admins:read',
|
||||
'admins:write',
|
||||
'audit:read',
|
||||
@@ -34,6 +36,7 @@ export const PERMISSION_MODULES: PermissionModule[] = [
|
||||
{ key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' },
|
||||
{ key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' },
|
||||
{ key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi:write' },
|
||||
{ key: 'others', label: 'سایتهای دیگر', read: 'others:read', write: 'others:write' },
|
||||
{ key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' },
|
||||
{ key: 'audit', label: 'لاگ', read: 'audit:read' },
|
||||
{ key: 'settings', label: 'تنظیمات حساس', write: 'settings:write' },
|
||||
|
||||
+20
-6
@@ -8,12 +8,14 @@ import {
|
||||
CustomerServiceOutlined,
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
GlobalOutlined,
|
||||
LoginOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -34,7 +36,7 @@ type ServerSummary = {
|
||||
|
||||
type Tenant = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug?: string
|
||||
nameFa?: string
|
||||
serverAccountId?: string
|
||||
@@ -50,7 +52,7 @@ type ExpiringPlugin = {
|
||||
expiryLabel: string
|
||||
tenant: {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
nameFa: string
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,7 @@ const expiringSupports = ref<ExpiringSupport[]>([])
|
||||
|
||||
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
|
||||
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
|
||||
const othersCount = computed(() => tenants.value.filter((t) => t.product === 'other').length)
|
||||
const lowestSmsTenants = computed(() => smsTenants.value.slice(0, 5))
|
||||
|
||||
const canSms = computed(() => auth.can('sms:read'))
|
||||
@@ -77,6 +80,7 @@ const canSupport = computed(() => auth.can('support:read'))
|
||||
const canAdmins = computed(() => auth.can('admins:read'))
|
||||
const canHamkar = computed(() => auth.can('hamkar:read'))
|
||||
const canKhanegi = computed(() => auth.can('khanegi:read'))
|
||||
const canOthers = computed(() => auth.can('others:read'))
|
||||
|
||||
const modules = computed(() =>
|
||||
[
|
||||
@@ -110,6 +114,16 @@ const modules = computed(() =>
|
||||
to: { name: 'product-versions' as const, params: { product: 'khanegi' } },
|
||||
visible: canKhanegi.value,
|
||||
},
|
||||
{
|
||||
key: 'others',
|
||||
className: 'others',
|
||||
title: 'سایتهای دیگر',
|
||||
desc: 'سایتهای متفرقه؛ فقط مشخصات و پیگیری سرور، افزونه، پشتیبانی و پیامک',
|
||||
meta: `${othersCount.value} سایت`,
|
||||
icon: GlobalOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'other' } },
|
||||
visible: canOthers.value,
|
||||
},
|
||||
].filter((item) => item.visible),
|
||||
)
|
||||
|
||||
@@ -118,7 +132,7 @@ async function load() {
|
||||
try {
|
||||
const tasks: Array<Promise<void>> = []
|
||||
|
||||
if (canHamkar.value || canKhanegi.value) {
|
||||
if (canHamkar.value || canKhanegi.value || canOthers.value) {
|
||||
tasks.push(
|
||||
api.get('/tenants').then((res) => {
|
||||
tenants.value = res.data
|
||||
@@ -234,7 +248,7 @@ onMounted(load)
|
||||
<div class="login-kicker">داشبورد کنترل</div>
|
||||
<h1 class="page-title">سلام، خوش آمدید</h1>
|
||||
<p class="page-sub">
|
||||
از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران و نسخههای همکار / خانگی دسترسی دارید.
|
||||
از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران، نسخههای همکار / خانگی و سایتهای دیگر دسترسی دارید.
|
||||
</p>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
@@ -257,7 +271,7 @@ onMounted(load)
|
||||
@click.stop="openSmsPanel(tenant.id)"
|
||||
>
|
||||
<strong>{{ tenant.nameFa }}</strong>
|
||||
<span>{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}</span>
|
||||
<span>{{ productLabel(tenant.product) }}</span>
|
||||
<small>{{ tenant.sms?.balanceLabel || 'نامشخص' }}</small>
|
||||
</button>
|
||||
<div v-if="!lowestSmsTenants.length" class="sms-mini-empty">
|
||||
@@ -292,7 +306,7 @@ onMounted(load)
|
||||
>
|
||||
<strong>{{ tenant.nameFa }}</strong>
|
||||
<span>
|
||||
{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
{{ productLabel(tenant.product) }}
|
||||
<template v-if="tenant.server?.serverName">
|
||||
· {{ tenant.server.serverName }}
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: 'hamkar' | 'khanegi' | 'other'
|
||||
nameFa: string
|
||||
domain: string
|
||||
slug?: string
|
||||
@@ -35,7 +35,7 @@ type RequestRow = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type MainTab = 'khanegi' | 'hamkar' | 'requests' | 'catalog'
|
||||
type MainTab = 'khanegi' | 'hamkar' | 'other' | 'requests' | 'catalog'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -68,8 +68,13 @@ const filteredTenants = computed(() => {
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
other: filteredTenants.value.filter((t) => t.product === 'other'),
|
||||
}))
|
||||
|
||||
const isTenantTab = computed(
|
||||
() => mainTab.value === 'khanegi' || mainTab.value === 'hamkar' || mainTab.value === 'other',
|
||||
)
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level && level !== 'none' ? level : 'unknown'
|
||||
}
|
||||
@@ -173,7 +178,7 @@ function setTab(tab: MainTab) {
|
||||
}
|
||||
|
||||
function tabFromQuery(raw: unknown): MainTab {
|
||||
if (raw === 'hamkar' || raw === 'requests' || raw === 'catalog') return raw
|
||||
if (raw === 'hamkar' || raw === 'other' || raw === 'requests' || raw === 'catalog') return raw
|
||||
return 'khanegi'
|
||||
}
|
||||
|
||||
@@ -211,11 +216,12 @@ onMounted(() => {
|
||||
<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 === 'other' }" @click="setTab('other')">سایتهای دیگر</button>
|
||||
<button type="button" :class="{ active: mainTab === 'requests' }" @click="setTab('requests')">درخواستها</button>
|
||||
<button type="button" :class="{ active: mainTab === 'catalog' }" @click="setTab('catalog')">قیمت فروشگاه</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mainTab === 'khanegi' || mainTab === 'hamkar'" class="toolbar">
|
||||
<div v-if="isTenantTab" class="toolbar">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
@@ -225,7 +231,7 @@ onMounted(() => {
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div v-if="mainTab === 'khanegi' || mainTab === 'hamkar'" class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
|
||||
<div v-if="isTenantTab" class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
|
||||
<button
|
||||
type="button"
|
||||
class="chip all"
|
||||
@@ -388,6 +394,28 @@ onMounted(() => {
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="mainTab === 'other'" class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>سایتهای دیگر</h3>
|
||||
<span>{{ groupedTenants.other.length }} سایت</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.other"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.summary?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.summary?.expiryLabel }}</span>
|
||||
<small>{{ t.pluginCount }} افزونه</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.other.length" class="empty-hint">سایتی نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
@@ -59,7 +60,7 @@ const detailTab = ref<'info' | 'credit' | 'renew'>('info')
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -476,7 +477,7 @@ onMounted(loadDetail)
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'افزونههای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
{{ productLabel(tenant?.product) }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,12 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
AppstoreAddOutlined,
|
||||
ArrowRightOutlined,
|
||||
CloudServerOutlined,
|
||||
CustomerServiceOutlined,
|
||||
DeleteOutlined,
|
||||
MessageOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
@@ -17,10 +21,11 @@ import { formatIranDateTime } from '@/utils/datetime'
|
||||
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
|
||||
import KhanegiDepositPanel from '@/components/KhanegiDepositPanel.vue'
|
||||
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
|
||||
import { normalizeProduct, productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type Tenant = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug: string
|
||||
nameFa: string
|
||||
nameEn?: string | null
|
||||
@@ -43,21 +48,36 @@ const loading = ref(false)
|
||||
const rows = ref<Tenant[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const product = computed(() => {
|
||||
const p = String(route.params.product || '')
|
||||
return p === 'khanegi' ? 'khanegi' : 'hamkar'
|
||||
const product = computed(() => normalizeProduct(route.params.product))
|
||||
const isOther = computed(() => product.value === 'other')
|
||||
|
||||
const canWrite = computed(() => {
|
||||
if (product.value === 'hamkar') return auth.can('hamkar:write')
|
||||
if (product.value === 'other') return auth.can('others:write')
|
||||
return auth.can('khanegi:write')
|
||||
})
|
||||
|
||||
const canWrite = computed(() =>
|
||||
product.value === 'hamkar' ? auth.can('hamkar:write') : auth.can('khanegi:write'),
|
||||
const title = computed(() => productLabel(product.value))
|
||||
const subtitle = computed(() => {
|
||||
if (product.value === 'hamkar') return 'نسخههای فروشی پروژه همکار'
|
||||
if (product.value === 'other') {
|
||||
return 'سایتهای متفرقه — فقط ثبت مشخصات و پیگیری زمان سرور، افزونه، پشتیبانی و پیامک'
|
||||
}
|
||||
return 'نسخههای فروشی پروژه خانگی (Modular)'
|
||||
})
|
||||
|
||||
const trackingLinks = computed(() =>
|
||||
[
|
||||
{ key: 'server', label: 'سرور', icon: CloudServerOutlined, route: 'server-tenant', perm: 'servers:read' as const },
|
||||
{ key: 'plugin', label: 'افزونه', icon: AppstoreAddOutlined, route: 'plugin-tenant', perm: 'plugins:read' as const },
|
||||
{ key: 'support', label: 'پشتیبانی', icon: CustomerServiceOutlined, route: 'support-tenant', perm: 'support:read' as const },
|
||||
{ key: 'sms', label: 'پیامک', icon: MessageOutlined, route: 'sms-tenant', perm: 'sms:read' as const },
|
||||
].filter((item) => auth.can(item.perm)),
|
||||
)
|
||||
|
||||
const title = computed(() => (product.value === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
const subtitle = computed(() =>
|
||||
product.value === 'hamkar'
|
||||
? 'نسخههای فروشی پروژه همکار'
|
||||
: 'نسخههای فروشی پروژه خانگی (Modular)',
|
||||
)
|
||||
function openTracking(routeName: string, tenantId: string) {
|
||||
router.push({ name: routeName, params: { tenantId } })
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
@@ -108,8 +128,10 @@ function goAppearance(item: Tenant) {
|
||||
|
||||
function remove(record: Tenant) {
|
||||
Modal.confirm({
|
||||
title: `حذف نسخه «${record.nameFa}»؟`,
|
||||
content: 'فقط از رجیستری سوپرادمین حذف میشود؛ سرور مشتری را پاک نمیکند.',
|
||||
title: `حذف ${isOther.value ? 'سایت' : 'نسخه'} «${record.nameFa}»؟`,
|
||||
content: isOther.value
|
||||
? 'سایت و سوابق سرور، افزونه، پشتیبانی و پیامک آن از سوپرادمین حذف میشود؛ خود سایت دست نمیخورد.'
|
||||
: 'فقط از رجیستری سوپرادمین حذف میشود؛ سرور مشتری را پاک نمیکند.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
@@ -146,7 +168,7 @@ onMounted(load)
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button v-if="canWrite" type="primary" @click="addVersion">
|
||||
<PlusOutlined /> نسخه جدید
|
||||
<PlusOutlined /> {{ isOther ? 'سایت جدید' : 'نسخه جدید' }}
|
||||
</a-button>
|
||||
<SettingsCatalogPanel v-if="product === 'khanegi'" :can-write="canWrite" />
|
||||
<KhanegiDepositPanel v-if="product === 'khanegi'" :can-write="canWrite" />
|
||||
@@ -209,7 +231,18 @@ onMounted(load)
|
||||
<template #icon><SlidersOutlined /></template>
|
||||
تنظیمات
|
||||
</a-button>
|
||||
<a-button size="small" @click="goAppearance(item)">
|
||||
<template v-if="isOther">
|
||||
<a-button
|
||||
v-for="link in trackingLinks"
|
||||
:key="link.key"
|
||||
size="small"
|
||||
@click="openTracking(link.route, item.id)"
|
||||
>
|
||||
<template #icon><component :is="link.icon" /></template>
|
||||
{{ link.label }}
|
||||
</a-button>
|
||||
</template>
|
||||
<a-button v-else size="small" @click="goAppearance(item)">
|
||||
<template #icon><SkinOutlined /></template>
|
||||
ظاهر
|
||||
</a-button>
|
||||
@@ -226,8 +259,10 @@ onMounted(load)
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else description="نسخهای ثبت نشده" style="margin: 32px 0">
|
||||
<a-button v-if="canWrite" type="primary" @click="addVersion">افزودن اولین نسخه</a-button>
|
||||
<a-empty v-else :description="isOther ? 'سایتی ثبت نشده' : 'نسخهای ثبت نشده'" style="margin: 32px 0">
|
||||
<a-button v-if="canWrite" type="primary" @click="addVersion">
|
||||
{{ isOther ? 'افزودن اولین سایت' : 'افزودن اولین نسخه' }}
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: 'hamkar' | 'khanegi' | 'other'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -53,6 +53,7 @@ const filteredTenants = computed(() => {
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
other: filteredTenants.value.filter((t) => t.product === 'other'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
@@ -213,6 +214,31 @@ onMounted(loadTenants)
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="groupedTenants.other.length" class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>سایتهای دیگر</h3>
|
||||
<span>{{ groupedTenants.other.length }} سایت</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.other"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.server?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.server?.expiryLabel }}</span>
|
||||
<small v-if="(t.serversCount || 0) > 1">
|
||||
{{ t.serversCount }} سرور
|
||||
</small>
|
||||
<small v-else-if="t.server?.serverName">{{ t.server.serverName }}</small>
|
||||
<small v-else>{{ t.domain }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -50,7 +51,7 @@ const creating = ref(false)
|
||||
const deleting = ref(false)
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -329,7 +330,7 @@ async function onServerAccessToggle(checked: boolean) {
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
{{ productLabel(tenant?.product) }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: 'hamkar' | 'khanegi' | 'other'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -44,6 +44,7 @@ const filteredTenants = computed(() => {
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
other: filteredTenants.value.filter((t) => t.product === 'other'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: BalanceLevel) {
|
||||
@@ -188,9 +189,30 @@ onMounted(loadTenants)
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="groupedTenants.other.length" class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>سایتهای دیگر</h3>
|
||||
<span>{{ groupedTenants.other.length }} سایت</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.other"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.sms?.balanceLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.sms?.balanceLabel }}</span>
|
||||
<small>{{ t.isBlocked ? 'مسدود' : t.domain }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-empty
|
||||
v-if="!groupedTenants.hamkar.length && !groupedTenants.khanegi.length"
|
||||
v-if="!groupedTenants.hamkar.length && !groupedTenants.khanegi.length && !groupedTenants.other.length"
|
||||
description="نسخهای نیست"
|
||||
/>
|
||||
</a-spin>
|
||||
|
||||
@@ -11,6 +11,7 @@ import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||||
import { formatMoney, formatNumber } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
@@ -43,7 +44,7 @@ const ledgerLoading = ref(false)
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -411,8 +412,8 @@ onMounted(async () => {
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'پنل پیامک' }}</h1>
|
||||
<p class="page-sub">مدیریت کیف پول، تعرفه و گزارش ارسال پیامک</p>
|
||||
<div v-if="tenant" class="page-head-meta">
|
||||
<a-tag :color="tenant.product === 'hamkar' ? 'cyan' : 'orange'">
|
||||
{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
<a-tag :color="tenant.product === 'hamkar' ? 'cyan' : tenant.product === 'other' ? 'purple' : 'orange'">
|
||||
{{ productLabel(tenant.product) }}
|
||||
</a-tag>
|
||||
<span class="domain">{{ tenant.domain }}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: 'hamkar' | 'khanegi' | 'other'
|
||||
nameFa: string
|
||||
domain: string
|
||||
slug?: string
|
||||
@@ -42,6 +42,7 @@ const filteredTenants = computed(() => {
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: filteredTenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: filteredTenants.value.filter((t) => t.product === 'khanegi'),
|
||||
other: filteredTenants.value.filter((t) => t.product === 'other'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
@@ -186,6 +187,27 @@ onMounted(loadTenants)
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="groupedTenants.other.length" class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>سایتهای دیگر</h3>
|
||||
<span>{{ groupedTenants.other.length }} سایت</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.other"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.summary?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.summary?.expiryLabel }}</span>
|
||||
<small>{{ t.supportCount }} پشتیبانی</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { productLabel, type Product } from '@/utils/product'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
@@ -47,7 +48,7 @@ const showCreate = ref(false)
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
product: Product
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
@@ -306,7 +307,7 @@ onMounted(loadDetail)
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'پشتیبانیهای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
{{ productLabel(tenant?.product) }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import VersionPageTabs from '@/components/VersionPageTabs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatIranDateTime } from '@/utils/datetime'
|
||||
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
|
||||
import { normalizeProduct, productLabel as labelOf, type Product } from '@/utils/product'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -20,7 +21,7 @@ const createdAt = ref<string | null>(null)
|
||||
const isEdit = computed(() => Boolean(route.params.id))
|
||||
|
||||
const form = reactive({
|
||||
product: 'hamkar' as 'hamkar' | 'khanegi',
|
||||
product: 'hamkar' as Product,
|
||||
slug: '',
|
||||
nameFa: '',
|
||||
nameEn: '',
|
||||
@@ -38,10 +39,13 @@ const form = reactive({
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const productLabel = computed(() => (form.product === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
const canWrite = computed(() =>
|
||||
form.product === 'hamkar' ? auth.can('hamkar:write') : auth.can('khanegi:write'),
|
||||
)
|
||||
const productLabel = computed(() => labelOf(form.product))
|
||||
const isOther = computed(() => form.product === 'other')
|
||||
const canWrite = computed(() => {
|
||||
if (form.product === 'hamkar') return auth.can('hamkar:write')
|
||||
if (form.product === 'other') return auth.can('others:write')
|
||||
return auth.can('khanegi:write')
|
||||
})
|
||||
|
||||
function backTarget() {
|
||||
return {
|
||||
@@ -52,8 +56,7 @@ function backTarget() {
|
||||
|
||||
async function load() {
|
||||
if (!isEdit.value) {
|
||||
const q = String(route.query.product || '')
|
||||
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
|
||||
form.product = normalizeProduct(route.query.product)
|
||||
form.domains = []
|
||||
createdAt.value = null
|
||||
return
|
||||
@@ -140,7 +143,7 @@ async function save() {
|
||||
.toLowerCase(),
|
||||
),
|
||||
}
|
||||
if (!payload.remoteApiToken) {
|
||||
if (!payload.remoteApiToken || isOther.value) {
|
||||
delete (payload as any).remoteApiToken
|
||||
}
|
||||
if (isEdit.value) {
|
||||
@@ -148,7 +151,7 @@ async function save() {
|
||||
message.success('بهروزرسانی شد')
|
||||
} else {
|
||||
await api.post('/tenants', payload)
|
||||
message.success('نسخه ایجاد شد')
|
||||
message.success(isOther.value ? 'سایت ایجاد شد' : 'نسخه ایجاد شد')
|
||||
}
|
||||
router.push(backTarget())
|
||||
} catch (e: any) {
|
||||
@@ -170,8 +173,17 @@ onMounted(load)
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
|
||||
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
|
||||
<template v-if="isOther">
|
||||
<h1 class="page-title">{{ isEdit ? 'مشخصات سایت' : 'سایت جدید' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ productLabel }} · فقط برای ثبت مشخصات و پیگیری سرور، افزونه، پشتیبانی و پیامک؛
|
||||
سوپرادمین کنترلی روی خود سایت ندارد
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
|
||||
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
|
||||
</template>
|
||||
</div>
|
||||
<VersionPageTabs
|
||||
v-if="isEdit && form.product === 'khanegi'"
|
||||
@@ -185,12 +197,13 @@ onMounted(load)
|
||||
<a-form :model="form" layout="vertical" class="tenant-form-body" @finish="save">
|
||||
<div class="form-top">
|
||||
<section class="form-section">
|
||||
<h3>هویت نسخه</h3>
|
||||
<h3>{{ isOther ? 'مشخصات سایت' : 'هویت نسخه' }}</h3>
|
||||
<div class="field-grid">
|
||||
<a-form-item label="محصول" name="product">
|
||||
<a-select v-model:value="form.product" :disabled="isEdit" size="large">
|
||||
<a-select-option value="hamkar">همکار</a-select-option>
|
||||
<a-select-option value="khanegi">خانگی</a-select-option>
|
||||
<a-select-option value="other">سایتهای دیگر</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="نام فارسی" name="nameFa" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
@@ -202,13 +215,20 @@ onMounted(load)
|
||||
<a-form-item label="نام لاتین (اختیاری)" name="nameEn">
|
||||
<a-input v-model:value="form.nameEn" size="large" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="دامنه فروشگاه" name="domain" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
<a-form-item
|
||||
:label="isOther ? 'دامنه سایت' : 'دامنه فروشگاه'"
|
||||
name="domain"
|
||||
:rules="[{ required: true, message: 'لازم است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.domain" size="large" placeholder="example.com" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isEdit" label="تاریخ ایجاد">
|
||||
<a-input :value="formatIranDateTime(createdAt)" size="large" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="دامنهها و IPهای اضافی" class="field-span-2">
|
||||
<a-form-item v-if="isOther" label="آدرس پنل مدیریت (اختیاری)" name="adminUrl">
|
||||
<a-input v-model:value="form.adminUrl" size="large" dir="ltr" placeholder="https://example.com/admin" />
|
||||
</a-form-item>
|
||||
<a-form-item v-else label="دامنهها و IPهای اضافی" class="field-span-2">
|
||||
<p class="field-hint">
|
||||
برای /get_user. دامنهٔ اصلی فروشگاه را اینجا تکرار نکنید — فقط alias/IP اضافی.
|
||||
هر دامنه/IP فقط روی یک نسخه میماند؛ اگر از قبل روی نسخهٔ دیگری باشد،
|
||||
@@ -236,7 +256,8 @@ onMounted(load)
|
||||
|
||||
<section class="form-section logo-card">
|
||||
<h3>لوگو</h3>
|
||||
<p class="field-hint">
|
||||
<p v-if="isOther" class="field-hint">فقط برای نمایش در سوپرادمین.</p>
|
||||
<p v-else class="field-hint">
|
||||
از طریق /get_user بهعنوان <code dir="ltr">avatar</code> فرستاده میشود.
|
||||
<template v-if="form.product === 'khanegi'">
|
||||
رنگ و تم از «ظاهر اپ»
|
||||
@@ -269,7 +290,7 @@ onMounted(load)
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<section v-if="!isOther" class="form-section">
|
||||
<h3>اتصال سرور این نسخه</h3>
|
||||
<div class="field-grid">
|
||||
<a-form-item label="آدرس API (node_server)" name="nodeServer" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
@@ -296,10 +317,10 @@ onMounted(load)
|
||||
<section class="form-section form-section--last">
|
||||
<h3>وضعیت</h3>
|
||||
<div class="field-grid">
|
||||
<a-form-item label="فعال در رجیستری">
|
||||
<a-form-item :label="isOther ? 'فعال (نمایش در پنلهای پیگیری)' : 'فعال در رجیستری'">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ورژن اپ" name="appVersion">
|
||||
<a-form-item v-if="!isOther" label="ورژن اپ" name="appVersion">
|
||||
<a-input
|
||||
v-model:value="form.appVersion"
|
||||
size="large"
|
||||
|
||||
+16
-3
@@ -91,19 +91,28 @@ const router = createRouter({
|
||||
path: 'products/:product',
|
||||
name: 'product-versions',
|
||||
component: () => import('@/pages/ProductVersionsPage.vue'),
|
||||
meta: { permissions: ['hamkar:read', 'khanegi:read'], permissionMode: 'any' },
|
||||
meta: {
|
||||
permissions: ['hamkar:read', 'khanegi:read', 'others:read'],
|
||||
permissionMode: 'any',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'versions/new',
|
||||
name: 'version-create',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
meta: { permissions: ['hamkar:write', 'khanegi:write'], permissionMode: 'any' },
|
||||
meta: {
|
||||
permissions: ['hamkar:write', 'khanegi:write', 'others:write'],
|
||||
permissionMode: 'any',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'versions/:id',
|
||||
name: 'version-edit',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
meta: { permissions: ['hamkar:read', 'khanegi:read'], permissionMode: 'any' },
|
||||
meta: {
|
||||
permissions: ['hamkar:read', 'khanegi:read', 'others:read'],
|
||||
permissionMode: 'any',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'versions/:id/ui',
|
||||
@@ -177,6 +186,10 @@ router.beforeEach(async (to) => {
|
||||
message.warning('به این بخش دسترسی ندارید')
|
||||
return { name: 'home' }
|
||||
}
|
||||
if (product === 'other' && !auth.can('others:read')) {
|
||||
message.warning('به این بخش دسترسی ندارید')
|
||||
return { name: 'home' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
|
||||
@@ -267,6 +267,7 @@ button, input, textarea, select,
|
||||
.module-card.admins .icon-wrap { background: linear-gradient(145deg, #5b8def, #3b6fd4); }
|
||||
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
|
||||
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); }
|
||||
.module-card.others .icon-wrap { background: linear-gradient(145deg, #a78bfa, #7c5cdb); }
|
||||
|
||||
.module-card h3 {
|
||||
margin: 0;
|
||||
@@ -320,6 +321,7 @@ button, input, textarea, select,
|
||||
.module-card.admins .progress > span { background: #3b6fd4; width: 66%; }
|
||||
.module-card.hamkar .progress > span { background: #20c997; width: 80%; }
|
||||
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
|
||||
.module-card.others .progress > span { background: #7c5cdb; width: 55%; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type Product = 'hamkar' | 'khanegi' | 'other'
|
||||
|
||||
const LABELS: Record<Product, string> = {
|
||||
hamkar: 'همکار',
|
||||
khanegi: 'خانگی',
|
||||
other: 'سایتهای دیگر',
|
||||
}
|
||||
|
||||
export function productLabel(product?: string | null): string {
|
||||
return LABELS[product as Product] ?? 'خانگی'
|
||||
}
|
||||
|
||||
export function normalizeProduct(raw: unknown): Product {
|
||||
return raw === 'khanegi' || raw === 'other' ? raw : 'hamkar'
|
||||
}
|
||||
Reference in New Issue
Block a user