Files
web/src/pages/TenantFormPage.vue
T

301 lines
10 KiB
Vue
Raw Normal View History

2026-07-23 10:24:50 +03:30
<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'
2026-07-23 10:24:50 +03:30
import api from '@/api/http'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
2026-07-23 10:24:50 +03:30
const isEdit = computed(() => Boolean(route.params.id))
const form = reactive({
product: 'hamkar' as 'hamkar' | 'khanegi',
slug: '',
nameFa: '',
nameEn: '',
domain: '',
avatarUrl: '',
appVersion: '1',
uiVariant: 'modular' as 'modular' | 'newTheme',
2026-07-23 10:24:50 +03:30
nodeServer: '',
nodeWs: '',
appUrl: '',
adminUrl: '',
remoteApiToken: '',
isActive: true,
notes: '',
})
const productLabel = computed(() => (form.product === 'hamkar' ? 'همکار' : 'خانگی'))
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'
return
}
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
Object.assign(form, {
product: data.product,
slug: data.slug,
nameFa: data.nameFa,
nameEn: data.nameEn || '',
domain: data.domain,
avatarUrl: data.avatarUrl || '',
appVersion: data.appVersion || '1',
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
2026-07-23 10:24:50 +03:30
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 = ''
}
2026-07-23 10:24:50 +03:30
async function save() {
saving.value = true
try {
const payload = { ...form }
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" v-if="!loading">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px">
<a-button type="text" @click="router.push(backTarget())">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
</div>
<a-button
v-if="isEdit && form.product === 'khanegi'"
type="primary"
ghost
@click="
router.push({
name: form.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular',
params: { id: route.params.id },
})
"
2026-07-23 10:24:50 +03:30
>
ظاهر اپ
</a-button>
</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>
</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>
2026-07-23 10:24:50 +03:30
</a-form-item>
</section>
<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>
</section>
<section class="form-section">
<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>
2026-07-23 10:24:50 +03:30
<a-form-item label="یادداشت داخلی">
<a-textarea v-model:value="form.notes" :rows="3" />
</a-form-item>
</section>
<div class="sticky-actions">
<a-button type="primary" html-type="submit" size="large" block :loading="saving">ذخیره</a-button>
<a-button size="large" @click="router.push(backTarget())">انصراف</a-button>
</div>
</a-form>
</div>
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 200px">
<a-spin />
</div>
</template>
<style scoped>
.field-hint {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.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;
}
</style>