From c5e989811a353fcfca420bdf3ed7db957b98522b Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 7 Oct 2026 16:52:32 +0330 Subject: [PATCH] feat: add 'Other sites' module for tracking server/plugin/support/SMS of miscellaneous sites Co-authored-by: Cursor --- src/auth/permissions.ts | 3 ++ src/pages/HomePage.vue | 26 ++++++++--- src/pages/PluginPanelPage.vue | 38 ++++++++++++++--- src/pages/PluginTenantPage.vue | 5 ++- src/pages/ProductVersionsPage.vue | 71 +++++++++++++++++++++++-------- src/pages/ServerPanelPage.vue | 28 +++++++++++- src/pages/ServerTenantPage.vue | 5 ++- src/pages/SmsPanelPage.vue | 26 ++++++++++- src/pages/SmsTenantPage.vue | 7 +-- src/pages/SupportPanelPage.vue | 24 ++++++++++- src/pages/SupportTenantPage.vue | 5 ++- src/pages/TenantFormPage.vue | 57 +++++++++++++++++-------- src/router/index.ts | 19 +++++++-- src/styles.css | 2 + src/utils/product.ts | 15 +++++++ 15 files changed, 268 insertions(+), 63 deletions(-) create mode 100644 src/utils/product.ts diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 8e5610d..289c9d8 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -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' }, diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index f783a68..3aa6180 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -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([]) 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> = [] - 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)

سلام، خوش آمدید

- از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران و نسخه‌های همکار / خانگی دسترسی دارید. + از اینجا به پنل پیامکی، سرور، افزونه، پشتیبانی، مدیران، نسخه‌های همکار / خانگی و سایت‌های دیگر دسترسی دارید.

@@ -257,7 +271,7 @@ onMounted(load) @click.stop="openSmsPanel(tenant.id)" > {{ tenant.nameFa }} - {{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }} + {{ productLabel(tenant.product) }} {{ tenant.sms?.balanceLabel || 'نامشخص' }}
@@ -292,7 +306,7 @@ onMounted(load) > {{ tenant.nameFa }} - {{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }} + {{ productLabel(tenant.product) }} diff --git a/src/pages/PluginPanelPage.vue b/src/pages/PluginPanelPage.vue index 9c077ce..a67b7cb 100644 --- a/src/pages/PluginPanelPage.vue +++ b/src/pages/PluginPanelPage.vue @@ -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(() => {
+
-
+
{
-
+
+ +
+
+

سایت‌های دیگر

+ {{ groupedTenants.other.length }} سایت +
+
+ +
سایتی نیست
+
+
diff --git a/src/pages/PluginTenantPage.vue b/src/pages/PluginTenantPage.vue index fac7a33..aea02b7 100644 --- a/src/pages/PluginTenantPage.vue +++ b/src/pages/PluginTenantPage.vue @@ -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)

{{ tenant?.nameFa || 'افزونه‌های نسخه' }}

- {{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} + {{ productLabel(tenant?.product) }} diff --git a/src/pages/ProductVersionsPage.vue b/src/pages/ProductVersionsPage.vue index 3df1c47..202505c 100644 --- a/src/pages/ProductVersionsPage.vue +++ b/src/pages/ProductVersionsPage.vue @@ -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([]) 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) - نسخه جدید + {{ isOther ? 'سایت جدید' : 'نسخه جدید' }} @@ -209,7 +231,18 @@ onMounted(load) تنظیمات - + + ظاهر @@ -226,8 +259,10 @@ onMounted(load)

- - افزودن اولین نسخه + + + {{ isOther ? 'افزودن اولین سایت' : 'افزودن اولین نسخه' }} +
diff --git a/src/pages/ServerPanelPage.vue b/src/pages/ServerPanelPage.vue index e9e0e1b..bc42f46 100644 --- a/src/pages/ServerPanelPage.vue +++ b/src/pages/ServerPanelPage.vue @@ -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)
نسخه‌ای نیست
+ +
+
+

سایت‌های دیگر

+ {{ groupedTenants.other.length }} سایت +
+
+ +
+
diff --git a/src/pages/ServerTenantPage.vue b/src/pages/ServerTenantPage.vue index 08ab975..268f678 100644 --- a/src/pages/ServerTenantPage.vue +++ b/src/pages/ServerTenantPage.vue @@ -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) {

{{ tenant?.nameFa || 'سرور نسخه' }}

- {{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} + {{ productLabel(tenant?.product) }} diff --git a/src/pages/SmsPanelPage.vue b/src/pages/SmsPanelPage.vue index d3ecb94..9043663 100644 --- a/src/pages/SmsPanelPage.vue +++ b/src/pages/SmsPanelPage.vue @@ -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)

نسخه‌ای نیست
+ +
+
+

سایت‌های دیگر

+ {{ groupedTenants.other.length }} سایت +
+
+ +
+
diff --git a/src/pages/SmsTenantPage.vue b/src/pages/SmsTenantPage.vue index 445c28e..0cf231a 100644 --- a/src/pages/SmsTenantPage.vue +++ b/src/pages/SmsTenantPage.vue @@ -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 () => {

{{ tenant?.nameFa || 'پنل پیامک' }}

مدیریت کیف پول، تعرفه و گزارش ارسال پیامک

- - {{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }} + + {{ productLabel(tenant.product) }} {{ tenant.domain }}
diff --git a/src/pages/SupportPanelPage.vue b/src/pages/SupportPanelPage.vue index 2af80f5..1b0c27c 100644 --- a/src/pages/SupportPanelPage.vue +++ b/src/pages/SupportPanelPage.vue @@ -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)
نسخه‌ای نیست
+ +
+
+

سایت‌های دیگر

+ {{ groupedTenants.other.length }} سایت +
+
+ +
+
diff --git a/src/pages/SupportTenantPage.vue b/src/pages/SupportTenantPage.vue index 58f8039..e28e155 100644 --- a/src/pages/SupportTenantPage.vue +++ b/src/pages/SupportTenantPage.vue @@ -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)

{{ tenant?.nameFa || 'پشتیبانی‌های نسخه' }}

- {{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} + {{ productLabel(tenant?.product) }} diff --git a/src/pages/TenantFormPage.vue b/src/pages/TenantFormPage.vue index af39132..cae8c93 100644 --- a/src/pages/TenantFormPage.vue +++ b/src/pages/TenantFormPage.vue @@ -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(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)

-

{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}

-

محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)

+ +
-

هویت نسخه

+

{{ isOther ? 'مشخصات سایت' : 'هویت نسخه' }}

همکار خانگی + سایت‌های دیگر @@ -202,13 +215,20 @@ onMounted(load) - + - + + + +

برای /get_user. دامنهٔ اصلی فروشگاه را اینجا تکرار نکنید — فقط alias/IP اضافی. هر دامنه/IP فقط روی یک نسخه می‌ماند؛ اگر از قبل روی نسخهٔ دیگری باشد، @@ -236,7 +256,8 @@ onMounted(load)

لوگو

-

+

فقط برای نمایش در سوپرادمین.

+

از طریق /get_user به‌عنوان avatar فرستاده می‌شود.