Files
web/src/pages/TenantFormPage.vue
T
mohmdsdgandCursor 6dbb15c6e1 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>
2026-08-17 21:39:50 +03:30

470 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
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'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const createdAt = ref<string | null>(null)
const isEdit = computed(() => Boolean(route.params.id))
const form = reactive({
product: 'hamkar' as 'hamkar' | 'khanegi',
slug: '',
nameFa: '',
nameEn: '',
domain: '',
domains: [] as string[],
avatarUrl: '',
appVersion: '1',
uiVariant: 'modular' as 'modular' | 'newTheme',
nodeServer: '',
nodeWs: '',
appUrl: '',
adminUrl: '',
remoteApiToken: '',
isActive: true,
notes: '',
})
const productLabel = computed(() => (form.product === 'hamkar' ? 'همکار' : 'خانگی'))
const canWrite = computed(() =>
form.product === 'hamkar' ? auth.can('hamkar:write') : auth.can('khanegi:write'),
)
function backTarget() {
return {
name: 'product-versions' as const,
params: { product: form.product },
}
}
async function load() {
if (!isEdit.value) {
const q = String(route.query.product || '')
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
form.domains = []
createdAt.value = null
return
}
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
createdAt.value = data.createdAt || null
Object.assign(form, {
product: data.product,
slug: data.slug,
nameFa: data.nameFa,
nameEn: data.nameEn || '',
domain: data.domain,
domains: Array.isArray(data.domains) ? data.domains.map(String) : [],
avatarUrl: data.avatarUrl || '',
appVersion: data.appVersion || '1',
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
nodeServer: data.nodeServer,
nodeWs: data.nodeWs || '',
appUrl: data.appUrl || '',
adminUrl: data.adminUrl || '',
remoteApiToken: '',
isActive: data.isActive,
notes: data.notes || '',
})
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
} finally {
loading.value = false
}
}
async function uploadLogo(options: {
file: string | Blob | File
onSuccess?: (body?: unknown) => void
onError?: (e: Error) => void
}) {
const file = options.file
if (!(file instanceof File) && !(file instanceof Blob)) return
uploading.value = true
try {
const body = new FormData()
body.append('file', file)
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
form.avatarUrl = data.url
options.onSuccess?.(data)
message.success('لوگو آپلود شد')
} catch (e: any) {
const msg = e?.response?.data?.message
options.onError?.(e instanceof Error ? e : new Error(String(msg || 'upload failed')))
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'آپلود ناموفق')
} finally {
uploading.value = false
}
}
function clearLogo() {
form.avatarUrl = ''
}
async function save() {
if (!canWrite.value) {
message.warning('دسترسی نوشتن ندارید')
return
}
saving.value = true
try {
const payload = {
...form,
domains: form.domains.map((item) => item.trim()).filter(Boolean),
}
if (!payload.remoteApiToken) {
delete (payload as any).remoteApiToken
}
if (isEdit.value) {
await api.patch(`/tenants/${route.params.id}`, payload)
message.success('به‌روزرسانی شد')
} else {
await api.post('/tenants', payload)
message.success('نسخه ایجاد شد')
}
router.push(backTarget())
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
watch(() => route.fullPath, load)
onMounted(load)
</script>
<template>
<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 class="page-head-text">
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
</div>
<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" 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 به‌عنوان <code dir="ltr">avatar</code> فرستاده می‌شود.
<template v-if="form.product === 'khanegi'">
رنگ و تم از «ظاهر اپ»
({{ form.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }}).
</template>
</p>
<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-form-item>
</section>
</div>
<section class="form-section">
<h3>اتصال سرور این نسخه</h3>
<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 form-section--last">
<h3>وضعیت</h3>
<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"
:loading="saving"
>
ذخیره
</a-button>
<a-button size="large" @click="router.push(backTarget())">انصراف</a-button>
</div>
</a-form>
</div>
<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;
font-size: 13px;
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;
gap: 12px;
flex-wrap: wrap;
}
.logo-preview,
.logo-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.logo-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.alias-list {
display: grid;
gap: 8px;
}
.alias-row {
display: flex;
gap: 8px;
align-items: center;
}
.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>