feat(versions): shared identity/UI/settings tabs and denser desktop form

Keep the same pill nav on all three Khanegi version pages, tighten the identity form on desktop, and split catalog add/delete into separate modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 21:39:50 +03:30
co-authored by Cursor
parent b879d085d3
commit 6dbb15c6e1
8 changed files with 512 additions and 281 deletions
+41 -21
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
@@ -153,34 +154,41 @@ onMounted(load)
<a-button type="text" @click="router.push({ name: 'product-versions', params: { product: 'khanegi' } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<div class="head-text">
<h1 class="page-title">ظاهر اپ خانگی — ماژولار</h1>
<p class="page-sub">
{{ tenantName || 'نسخه' }}
<template v-if="domain"> · {{ domain }}</template>
</p>
</div>
<a-space>
<a-select
:value="uiVariant"
style="width: 180px"
:options="[
{ value: 'modular', label: 'Modular' },
{ value: 'newTheme', label: 'New theme' },
]"
:disabled="!canWrite || switching || saving || loading"
@change="(v: string) => v === 'newTheme' && switchToNewTheme()"
<div class="head-aside">
<VersionPageTabs
active="ui"
:tenant-id="String(route.params.id)"
ui-route-name="version-ui-modular"
/>
<a-button
v-if="canWrite"
type="primary"
size="large"
:loading="saving"
@click="save"
>
ذخیره
</a-button>
</a-space>
<a-space wrap>
<a-select
:value="uiVariant"
style="width: 180px"
:options="[
{ value: 'modular', label: 'Modular' },
{ value: 'newTheme', label: 'New theme' },
]"
:disabled="!canWrite || switching || saving || loading"
@change="(v: string) => v === 'newTheme' && switchToNewTheme()"
/>
<a-button
v-if="canWrite"
type="primary"
size="large"
:loading="saving"
@click="save"
>
ذخیره
</a-button>
</a-space>
</div>
</div>
<a-spin :spinning="loading || switching">
@@ -269,8 +277,20 @@ onMounted(load)
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.head-text {
flex: 1 1 180px;
min-width: 0;
}
.head-aside {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.hint {
color: #64748b;
margin: 0 0 16px;
+1 -2
View File
@@ -123,10 +123,9 @@ onMounted(load)
<a-button v-if="canWrite" type="primary" @click="addVersion">
<PlusOutlined /> نسخه جدید
</a-button>
<SettingsCatalogPanel v-if="product === 'khanegi'" :can-write="canWrite" />
</div>
<SettingsCatalogPanel v-if="product === 'khanegi'" :can-write="canWrite" />
<a-spin :spinning="loading">
<div v-if="filtered.length" class="version-list">
<article v-for="item in filtered" :key="item.id" class="version-card">
+21 -10
View File
@@ -24,6 +24,7 @@ import {
WalletOutlined,
} from '@ant-design/icons-vue'
import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import SettingCard from '@/components/program-settings/SettingCard.vue'
import {
KHANEGI_SYSTEM_SETTING_GROUPS,
@@ -405,7 +406,7 @@ onMounted(load)
<a-button type="text" @click="router.push({ name: 'version-edit', params: { id: route.params.id } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<div class="head-text">
<h1 class="page-title">تنظیمات برنامه</h1>
<p class="page-sub">
{{ tenant?.nameFa || 'نسخه خانگی' }}
@@ -413,19 +414,17 @@ onMounted(load)
· کنترل کامل از سوپرادمین (جایگزین تنظیمات پیشرفته ادمین خانگی)
</p>
</div>
<a-space wrap>
<div class="head-aside">
<VersionPageTabs
active="settings"
:tenant-id="String(route.params.id)"
:ui-route-name="uiRouteName()"
/>
<a-button v-if="tenant?.appUrl" @click="openUrl(tenant.appUrl)">
<GlobalOutlined />
اپ
</a-button>
<a-button
type="primary"
ghost
@click="router.push({ name: uiRouteName(), params: { id: route.params.id } })"
>
ظاهر اپ
</a-button>
</a-space>
</div>
</div>
<section class="form-section">
@@ -605,8 +604,20 @@ onMounted(load)
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.head-text {
flex: 1 1 180px;
min-width: 0;
}
.head-aside {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.field-hint {
margin: 0 0 14px;
color: #64748b;
+233 -144
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import { useAuthStore } from '@/stores/auth'
import { formatIranDateTime } from '@/utils/datetime'
@@ -150,174 +151,165 @@ onMounted(load)
</script>
<template>
<div class="page-shell" v-if="!loading">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px">
<a-button type="text" @click="router.push(backTarget())">
<div class="page-shell tenant-form" v-if="!loading">
<div class="page-head">
<a-button type="text" class="back-btn" @click="router.push(backTarget())">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<div class="page-head-text">
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
</div>
<a-space v-if="isEdit && form.product === 'khanegi'" wrap>
<a-button
@click="
router.push({
name: 'version-program-settings',
params: { id: route.params.id },
})
"
>
تنظیمات برنامه
</a-button>
<a-button
type="primary"
ghost
@click="
router.push({
name: form.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular',
params: { id: route.params.id },
})
"
>
ظاهر اپ
</a-button>
</a-space>
<VersionPageTabs
v-if="isEdit && form.product === 'khanegi'"
class="page-nav-slot"
active="identity"
:tenant-id="String(route.params.id)"
:ui-route-name="form.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular'"
/>
</div>
<a-form :model="form" layout="vertical" @finish="save">
<section class="form-section">
<h3>هویت نسخه</h3>
<a-form-item label="محصول" name="product">
<a-select v-model:value="form.product" :disabled="isEdit">
<a-select-option value="hamkar">همکار</a-select-option>
<a-select-option value="khanegi">خانگی</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام فارسی" name="nameFa" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.nameFa" size="large" placeholder="مثلاً باختر گلد" />
</a-form-item>
<a-form-item label="slug لاتین" name="slug" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.slug" size="large" placeholder="bakhtargold" dir="ltr" />
</a-form-item>
<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-input v-model:value="form.domain" size="large" placeholder="example.com" dir="ltr" />
</a-form-item>
<a-form-item label="دامنه‌ها و IPهای اضافی">
<a-form :model="form" layout="vertical" class="tenant-form-body" @finish="save">
<div class="form-top">
<section class="form-section">
<h3>هویت نسخه</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>
</a-form-item>
<a-form-item label="نام فارسی" name="nameFa" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.nameFa" size="large" placeholder="مثلاً باختر گلد" />
</a-form-item>
<a-form-item label="slug لاتین" name="slug" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.slug" size="large" placeholder="bakhtargold" dir="ltr" />
</a-form-item>
<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-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">
<p class="field-hint">
برای /get_user. داخل همان محصول (خانگی یا همکار) هر دامنه/IP فقط روی یک نسخه می‌ماند؛
اگر از قبل روی نسخه دیگری از همین محصول باشد، از آنجا برداشته و به این نسخه اضافه می‌شود.
خانگی و همکار روی هم اثر ندارند.
</p>
<div class="alias-list">
<div v-for="(_, index) in form.domains" :key="index" class="alias-row">
<a-input
v-model:value="form.domains[index]"
size="large"
dir="ltr"
placeholder="مثلاً 192.168.1.10 یا shop.example.com"
/>
<a-button danger @click="form.domains.splice(index, 1)">
<DeleteOutlined />
</a-button>
</div>
<a-button class="alias-add" @click="form.domains.push('')">
<PlusOutlined /> افزودن دامنه / IP
</a-button>
</div>
</a-form-item>
</div>
</section>
<section class="form-section logo-card">
<h3>لوگو</h3>
<p class="field-hint">
برای /get_user. داخل همان محصول (خانگی یا همکار) هر دامنه/IP فقط روی یک نسخه می‌ماند؛
اگر از قبل روی نسخه دیگری از همین محصول باشد، از آنجا برداشته و به این نسخه اضافه می‌شود.
خانگی و همکار روی هم اثر ندارند.
از طریق /get_user به‌عنوان <code dir="ltr">avatar</code> فرستاده می‌شود.
<template v-if="form.product === 'khanegi'">
رنگ و تم از «ظاهر اپ»
({{ form.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }}).
</template>
</p>
<div class="alias-list">
<div v-for="(_, index) in form.domains" :key="index" class="alias-row">
<a-input
v-model:value="form.domains[index]"
size="large"
dir="ltr"
placeholder="مثلاً 192.168.1.10 یا shop.example.com"
/>
<a-button danger @click="form.domains.splice(index, 1)">
<DeleteOutlined />
<a-form-item name="avatarUrl" class="logo-item">
<div class="logo-upload-row">
<a-upload
name="file"
list-type="picture-card"
:show-upload-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
:custom-request="uploadLogo"
:disabled="uploading"
>
<div v-if="form.avatarUrl" class="logo-preview">
<img :src="form.avatarUrl" alt="لوگو" />
</div>
<div v-else class="logo-placeholder">
<PlusOutlined />
<div>{{ uploading ? 'در حال آپلود…' : 'آپلود لوگو' }}</div>
</div>
</a-upload>
<a-button v-if="form.avatarUrl" danger @click="clearLogo">
<DeleteOutlined /> حذف لوگو
</a-button>
</div>
<a-button @click="form.domains.push('')">
<PlusOutlined /> افزودن دامنه / IP
</a-button>
</div>
</a-form-item>
<a-form-item v-if="isEdit" label="تاریخ ایجاد">
<a-input :value="formatIranDateTime(createdAt)" size="large" disabled />
</a-form-item>
</section>
<section class="form-section">
<h3>لوگو</h3>
<p class="field-hint">
از طریق /get_user به‌عنوان <code dir="ltr">avatar</code> به نسخه‌ها فرستاده می‌شود.
<template v-if="form.product === 'khanegi'">
رنگ و تم از صفحه «ظاهر اپ» تنظیم می‌شود
({{ form.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }}).
</template>
</p>
<a-form-item name="avatarUrl">
<div class="logo-upload-row">
<a-upload
name="file"
list-type="picture-card"
:show-upload-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
:custom-request="uploadLogo"
:disabled="uploading"
>
<div v-if="form.avatarUrl" class="logo-preview">
<img :src="form.avatarUrl" alt="لوگو" />
</div>
<div v-else class="logo-placeholder">
<PlusOutlined />
<div>{{ uploading ? 'در حال آپلود…' : 'آپلود لوگو' }}</div>
</div>
</a-upload>
<a-button v-if="form.avatarUrl" danger @click="clearLogo">
<DeleteOutlined /> حذف لوگو
</a-button>
</div>
</a-form-item>
</section>
</a-form-item>
</section>
</div>
<section class="form-section">
<h3>اتصال سرور این نسخه</h3>
<a-form-item label="آدرس API (node_server)" name="nodeServer" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.nodeServer" size="large" dir="ltr" placeholder="https://example.com/server" />
</a-form-item>
<a-form-item
:label="form.product === 'hamkar' ? 'WebSocket (node_ws)' : 'WebSocket اختیاری (خانگی بیشتر SSE است)'"
name="nodeWs"
>
<a-input v-model:value="form.nodeWs" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="app_url" name="appUrl">
<a-input v-model:value="form.appUrl" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="admin_url" name="adminUrl">
<a-input v-model:value="form.adminUrl" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="توکن ریموت سوپر (برای تنظیمات داخل سرور نسخه)" name="remoteApiToken">
<a-input-password v-model:value="form.remoteApiToken" size="large" />
</a-form-item>
<div class="field-grid">
<a-form-item label="آدرس API (node_server)" name="nodeServer" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.nodeServer" size="large" dir="ltr" placeholder="https://example.com/server" />
</a-form-item>
<a-form-item
:label="form.product === 'hamkar' ? 'WebSocket (node_ws)' : 'WebSocket اختیاری (خانگی بیشتر SSE است)'"
name="nodeWs"
>
<a-input v-model:value="form.nodeWs" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="app_url" name="appUrl">
<a-input v-model:value="form.appUrl" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="admin_url" name="adminUrl">
<a-input v-model:value="form.adminUrl" size="large" dir="ltr" />
</a-form-item>
<a-form-item label="توکن ریموت سوپر (برای تنظیمات داخل سرور نسخه)" name="remoteApiToken" class="field-span-2">
<a-input-password v-model:value="form.remoteApiToken" size="large" />
</a-form-item>
</div>
</section>
<section class="form-section">
<section class="form-section form-section--last">
<h3>وضعیت</h3>
<a-form-item label="فعال در رجیستری">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
<a-form-item label="ورژن اپ" name="appVersion">
<a-input
v-model:value="form.appVersion"
size="large"
dir="ltr"
placeholder="مثلاً 2.0.1"
/>
<template #extra>
از طریق <code dir="ltr">/get_version</code> به اپ فرستاده می‌شود. با تغییرش، کلاینت کش کانفیگ را رفرش و صفحه را reload می‌کند.
</template>
</a-form-item>
<a-form-item label="یادداشت داخلی">
<a-textarea v-model:value="form.notes" :rows="3" />
</a-form-item>
<div class="field-grid">
<a-form-item label="فعال در رجیستری">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
<a-form-item label="ورژن اپ" name="appVersion">
<a-input
v-model:value="form.appVersion"
size="large"
dir="ltr"
placeholder="مثلاً 2.0.1"
/>
<template #extra>
از طریق <code dir="ltr">/get_version</code> به اپ فرستاده می‌شود. با تغییرش، کلاینت کش کانفیگ را رفرش و صفحه را reload می‌کند.
</template>
</a-form-item>
<a-form-item label="یادداشت داخلی" class="field-span-2">
<a-textarea v-model:value="form.notes" :rows="3" />
</a-form-item>
</div>
</section>
<div class="sticky-actions">
<a-button
v-if="canWrite"
class="save-btn"
type="primary"
html-type="submit"
size="large"
block
:loading="saving"
>
ذخیره
@@ -326,12 +318,33 @@ onMounted(load)
</div>
</a-form>
</div>
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 200px">
<div v-else class="page-shell tenant-form-loading">
<a-spin />
</div>
</template>
<style scoped>
.page-head {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.back-btn {
flex-shrink: 0;
}
.page-head-text {
flex: 1 1 180px;
min-width: 0;
}
.page-nav-slot {
margin-inline-start: auto;
}
.field-hint {
margin: 0 0 12px;
color: #64748b;
@@ -339,6 +352,20 @@ onMounted(load)
line-height: 1.6;
}
.field-grid {
display: grid;
grid-template-columns: 1fr;
column-gap: 16px;
}
.field-grid :deep(.ant-form-item) {
margin-bottom: 14px;
}
.logo-item {
margin-bottom: 0;
}
.logo-upload-row {
display: flex;
align-items: flex-start;
@@ -377,4 +404,66 @@ onMounted(load)
.alias-row :deep(.ant-input) {
flex: 1;
}
.alias-add {
justify-self: start;
}
.save-btn {
width: 100%;
}
.tenant-form-loading {
display: grid;
place-items: center;
min-height: 200px;
}
@media (min-width: 768px) {
.field-grid {
grid-template-columns: 1fr 1fr;
}
.field-span-2 {
grid-column: 1 / -1;
}
.save-btn {
width: auto;
min-width: 168px;
}
.tenant-form :deep(.sticky-actions) {
justify-content: flex-start;
}
}
@media (min-width: 960px) {
.form-top {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 20px;
align-items: start;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.form-top .form-section {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.logo-card.form-section {
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: #f8fafc;
}
.form-section--last {
border-bottom: none;
}
}
</style>
+43 -21
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import {
cloneKhanegiAppSnapshot,
createDefaultKhanegiUiConfig,
@@ -316,31 +317,38 @@ onMounted(load)
<a-button type="text" class="back-btn" @click="router.push({ name: 'version-edit', params: { id: route.params.id } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<div class="head-text">
<h1 class="page-title">ظاهر اپ خانگی — تم جدید</h1>
<p class="page-sub">{{ tenantName }} · ۳ تم آماده + سفارشی</p>
</div>
<a-space>
<a-select
:value="uiVariant"
style="width: 180px"
:options="[
{ value: 'modular', label: 'Modular' },
{ value: 'newTheme', label: 'New theme' },
]"
:disabled="!canWrite || switching || saving || loading"
@change="(v: string) => v === 'modular' && switchToModular()"
<div class="head-aside">
<VersionPageTabs
active="ui"
:tenant-id="String(route.params.id)"
ui-route-name="version-ui"
/>
<a-button
v-if="canWrite"
type="primary"
size="large"
:loading="saving"
@click="save"
>
ذخیره ظاهر
</a-button>
</a-space>
<a-space wrap>
<a-select
:value="uiVariant"
style="width: 180px"
:options="[
{ value: 'modular', label: 'Modular' },
{ value: 'newTheme', label: 'New theme' },
]"
:disabled="!canWrite || switching || saving || loading"
@change="(v: string) => v === 'modular' && switchToModular()"
/>
<a-button
v-if="canWrite"
type="primary"
size="large"
:loading="saving"
@click="save"
>
ذخیره ظاهر
</a-button>
</a-space>
</div>
</div>
<section class="form-section preset-section">
@@ -599,9 +607,23 @@ onMounted(load)
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.head-text {
flex: 1 1 180px;
min-width: 0;
}
.head-aside {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));