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:
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { SETTING_TYPE_OPTIONS } from '@/config/khanegiSettingCatalog'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean }>()
|
||||
|
||||
const addOpen = ref(false)
|
||||
const deleteOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const activeTab = ref('form')
|
||||
const jsonText = ref('[\n {\n "key": "name_fa",\n "value": "",\n "title_fa": "نام فارسی",\n "description": "",\n "type": "text",\n "can_edit": 0,\n "is_active": 1\n }\n]')
|
||||
const jsonText = ref(
|
||||
'[\n {\n "key": "name_fa",\n "value": "",\n "title_fa": "نام فارسی",\n "description": "",\n "type": "text",\n "can_edit": 0,\n "is_active": 1\n }\n]',
|
||||
)
|
||||
const deleteKey = ref('')
|
||||
|
||||
const form = reactive({
|
||||
@@ -33,10 +37,15 @@ function parseJsonItems(raw: string): Array<Record<string, unknown>> {
|
||||
throw new Error('JSON باید آرایه باشد: [{"key":"name_fa",...}]')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
function errText(e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'خطا'
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
if (!props.canWrite) {
|
||||
message.warning('دسترسی نوشتن ندارید')
|
||||
return
|
||||
return Promise.reject()
|
||||
}
|
||||
let items: Array<Record<string, unknown>> = []
|
||||
try {
|
||||
@@ -45,78 +54,77 @@ async function submit() {
|
||||
} else {
|
||||
if (!form.key.trim()) {
|
||||
message.warning('اسم کلید لازم است (مثلاً name_fa)')
|
||||
return
|
||||
return Promise.reject()
|
||||
}
|
||||
items = [{ ...form, key: form.key.trim() }]
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || 'JSON نامعتبر است')
|
||||
return
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: 'ثبت کلید روی همهٔ نسخهها؟',
|
||||
content: `${items.length} کلید به قالب اضافه میشود و روی پروژههایی که این کلید را ندارند اعمال میشود.`,
|
||||
okText: 'ثبت',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
saving.value = true
|
||||
try {
|
||||
const { data } = await api.post('/setting-templates/keys', { items })
|
||||
message.success(`${data.count ?? items.length} کلید ثبت شد`)
|
||||
form.key = ''
|
||||
form.title_fa = ''
|
||||
form.value = ''
|
||||
form.description = ''
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
|
||||
throw e
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
saving.value = true
|
||||
try {
|
||||
const { data } = await api.post('/setting-templates/keys', { items })
|
||||
message.success(`${data.count ?? items.length} کلید ثبت شد`)
|
||||
form.key = ''
|
||||
form.title_fa = ''
|
||||
form.value = ''
|
||||
form.description = ''
|
||||
addOpen.value = false
|
||||
} catch (e: any) {
|
||||
message.error(errText(e) || 'ثبت ناموفق')
|
||||
return Promise.reject()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeKey() {
|
||||
async function submitDelete() {
|
||||
const key = deleteKey.value.trim()
|
||||
if (!key) {
|
||||
message.warning('اسم کلید را بنویس (مثلاً name_fa)')
|
||||
return
|
||||
return Promise.reject()
|
||||
}
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete('/setting-templates/keys', { params: { key } })
|
||||
message.success('کلید حذف شد')
|
||||
deleteKey.value = ''
|
||||
deleteOpen.value = false
|
||||
} catch (e: any) {
|
||||
message.error(errText(e) || 'حذف ناموفق')
|
||||
return Promise.reject()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `حذف کلید «${key}» از همهٔ پروژهها؟`,
|
||||
content: 'از قالب و تنظیمات همهٔ نسخههای خانگی پاک میشود.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete('/setting-templates/keys', { params: { key } })
|
||||
message.success('کلید حذف شد')
|
||||
deleteKey.value = ''
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'حذف ناموفق')
|
||||
throw e
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="form-section catalog-panel">
|
||||
<h3>کلید تنظیمات (همهٔ نسخهها)</h3>
|
||||
<div class="catalog-actions">
|
||||
<a-button v-if="canWrite" @click="addOpen = true">
|
||||
<PlusOutlined /> افزودن کلید
|
||||
</a-button>
|
||||
<a-button v-if="canWrite" danger ghost @click="deleteOpen = true">
|
||||
<DeleteOutlined /> حذف کلید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="addOpen"
|
||||
title="افزودن کلید تنظیمات"
|
||||
ok-text="ثبت کلید"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="saving"
|
||||
:width="720"
|
||||
destroy-on-close
|
||||
@ok="submitAdd"
|
||||
>
|
||||
<p class="field-hint">
|
||||
کلید جدید با ثبت هم به قالب نسخههای بعدی میرود هم روی پروژههای فعلی اعمال میشود.
|
||||
با ثبت، کلید به قالب نسخههای بعدی میرود و روی پروژههایی که این کلید را ندارند اعمال میشود.
|
||||
اسم کلید مثل <code dir="ltr">name_fa</code> است نه عنوان فارسی.
|
||||
</p>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="form" tab="فرم">
|
||||
<a-form layout="vertical">
|
||||
@@ -143,26 +151,37 @@ function removeKey() {
|
||||
<a-textarea v-model:value="jsonText" :rows="10" dir="ltr" class="json-box" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-modal>
|
||||
|
||||
<a-button v-if="canWrite" type="primary" size="large" :loading="saving" @click="submit">
|
||||
<PlusOutlined /> ثبت کلید
|
||||
</a-button>
|
||||
|
||||
<div class="delete-row">
|
||||
<a-input
|
||||
v-model:value="deleteKey"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
placeholder="حذف با اسم کلید — مثلاً name_fa"
|
||||
/>
|
||||
<a-button v-if="canWrite" danger :loading="deleting" @click="removeKey">
|
||||
<DeleteOutlined /> حذف از همه
|
||||
</a-button>
|
||||
</div>
|
||||
</section>
|
||||
<a-modal
|
||||
v-model:open="deleteOpen"
|
||||
title="حذف کلید از همهٔ نسخهها"
|
||||
ok-text="حذف از همه"
|
||||
ok-type="danger"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="deleting"
|
||||
:width="520"
|
||||
destroy-on-close
|
||||
@ok="submitDelete"
|
||||
>
|
||||
<p class="field-hint">
|
||||
این کلید از قالب و تنظیمات همهٔ پروژههای خانگی پاک میشود. اسم کلید را بنویس، نه عنوان فارسی.
|
||||
</p>
|
||||
<a-input
|
||||
v-model:value="deleteKey"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
placeholder="مثلاً name_fa"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0 0 12px;
|
||||
color: #64748b;
|
||||
@@ -175,13 +194,9 @@ function removeKey() {
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.catalog-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.delete-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
.catalog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.json-box {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tenantId: string
|
||||
active: 'identity' | 'ui' | 'settings'
|
||||
uiRouteName?: 'version-ui' | 'version-ui-modular'
|
||||
}>(),
|
||||
{ uiRouteName: 'version-ui-modular' },
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function go(name: string, tab: 'identity' | 'ui' | 'settings') {
|
||||
if (tab === props.active) return
|
||||
router.push({ name, params: { id: props.tenantId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-nav">
|
||||
<a-button
|
||||
:type="active === 'identity' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="go('version-edit', 'identity')"
|
||||
>
|
||||
هویت نسخه
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="active === 'ui' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="go(uiRouteName, 'ui')"
|
||||
>
|
||||
ظاهر اپ
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="active === 'settings' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="go('version-program-settings', 'settings')"
|
||||
>
|
||||
تنظیمات برنامه
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.page-nav :deep(.ant-btn) {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
padding-inline: 14px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.page-nav :deep(.ant-btn-primary) {
|
||||
background: #fff;
|
||||
color: var(--teal-deep);
|
||||
box-shadow: 0 1px 4px rgba(45, 55, 72, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -103,19 +103,21 @@ onMounted(loadSmsToggle)
|
||||
min-height: 100vh;
|
||||
max-width: 100%;
|
||||
background: #eef2f7;
|
||||
overflow-x: hidden;
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #e8eef5;
|
||||
}
|
||||
@@ -235,6 +237,7 @@ onMounted(loadSmsToggle)
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.shell { padding-top: 72px; }
|
||||
.topbar { padding: 16px 24px; }
|
||||
.content { padding: 24px; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user