feat(settings): catalog keys UI and per-tenant program settings editor

Add/remove keys from the Khanegi versions list (form or JSON), edit values per version, and proxy /get_settings for local work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 20:14:17 +03:30
co-authored by Cursor
parent 0e7f97d938
commit b879d085d3
14 changed files with 3103 additions and 41 deletions
+19
View File
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, SearchOutlined } from '@ant-design/ic
import api from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import { formatIranDateTime } from '@/utils/datetime'
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
type Tenant = {
id: string
@@ -20,6 +21,7 @@ type Tenant = {
nodeServer: string
notes?: string | null
hasAppUiConfig?: boolean
hasProgramSettings?: boolean
uiVariant?: 'modular' | 'newTheme'
createdAt?: string
}
@@ -123,6 +125,8 @@ onMounted(load)
</a-button>
</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">
@@ -154,6 +158,7 @@ onMounted(load)
بررسی محصول
</a-tag>
<a-tag v-if="item.hasAppUiConfig" color="purple">ظاهر اپ</a-tag>
<a-tag v-if="item.hasProgramSettings" color="cyan">تنظیمات برنامه</a-tag>
<a-tag v-if="product === 'khanegi'" :color="item.uiVariant === 'newTheme' ? 'geekblue' : 'cyan'">
{{ item.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }}
</a-tag>
@@ -164,6 +169,20 @@ onMounted(load)
<a-button type="primary" ghost block style="flex: 1" @click="edit(item.id)">
مدیریت
</a-button>
<a-button
v-if="product === 'khanegi'"
type="default"
block
style="flex: 1"
@click="
router.push({
name: 'version-program-settings',
params: { id: item.id },
})
"
>
تنظیمات برنامه
</a-button>
<a-button
type="default"
block
+749
View File
@@ -0,0 +1,749 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
AccountBookOutlined,
ArrowRightOutlined,
BankOutlined,
CalculatorOutlined,
ClusterOutlined,
CreditCardOutlined,
DollarOutlined,
EllipsisOutlined,
FileTextOutlined,
GlobalOutlined,
HomeOutlined,
LineChartOutlined,
LinkOutlined,
MessageOutlined,
SearchOutlined,
SendOutlined,
ShopOutlined,
TeamOutlined,
WalletOutlined,
} from '@ant-design/icons-vue'
import api from '@/api/http'
import SettingCard from '@/components/program-settings/SettingCard.vue'
import {
KHANEGI_SYSTEM_SETTING_GROUPS,
getKhanegiSystemGroupId,
type SettingGroupDef,
} from '@/config/khanegiSystemSettingGroups'
import {
SETTING_TYPE_OPTIONS,
formatValueForSave,
isSettingVisible,
normalizeSettingRow,
resolveSettingFieldType,
} from '@/config/khanegiSettingCatalog'
import type { KhanegiSettingRow } from '@/config/khanegiSettingRow'
import { useAuthStore } from '@/stores/auth'
type TenantMeta = {
id: string
nameFa: string
slug: string
domain: string
product: 'hamkar' | 'khanegi'
appUrl?: string | null
adminUrl?: string | null
uiVariant?: 'modular' | 'newTheme'
nodeServer?: string
}
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const canWrite = computed(() => auth.can('khanegi:write'))
const loading = ref(true)
const importing = ref(false)
const search = ref('')
const filterType = ref<string | undefined>()
const tenant = ref<TenantMeta | null>(null)
const apiBase = ref<string | null>(null)
const items = ref<KhanegiSettingRow[]>([])
const drafts = ref<Record<string, string>>({})
const savingKeys = ref<Set<string>>(new Set())
const savedFlashKeys = ref<Set<string>>(new Set())
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
const flashTimers = new Map<string, ReturnType<typeof setTimeout>>()
const activeKey = ref<string | undefined>(undefined)
const creds = reactive({
mobile: '',
password: '',
})
type ImportPreviewRow = {
key: string
title_fa: string
khanegi_value: string
superadmin_value: string | null
status: 'new_in_khanegi' | 'both'
scope: 'none' | 'this' | 'all'
}
const previewRows = ref<ImportPreviewRow[]>([])
const onlySaKeys = ref<string[]>([])
const previewOpen = ref(false)
const applying = ref(false)
const typeSelectOptions = SETTING_TYPE_OPTIONS.map((t) => ({
value: t.key,
label: t.value,
}))
const GROUP_ICONS: Record<string, Component> = {
customer_features: TeamOutlined,
support_chat: MessageOutlined,
general: HomeOutlined,
chart: LineChartOutlined,
pricing: DollarOutlined,
montzar: LinkOutlined,
store: ShopOutlined,
deposit_methods: CreditCardOutlined,
payping: WalletOutlined,
accounting_provider: AccountBookOutlined,
accounting_zarin: BankOutlined,
accounting_tahesab: CalculatorOutlined,
accounting_pargar: FileTextOutlined,
price_channel: SendOutlined,
infra: ClusterOutlined,
misc: EllipsisOutlined,
}
const count = computed(() => items.value.length)
function asRow(raw: Record<string, unknown>): KhanegiSettingRow {
return normalizeSettingRow({
id: typeof raw.id === 'number' ? raw.id : undefined,
key: String(raw.key || ''),
value: raw.value == null ? '' : String(raw.value),
title_fa: raw.title_fa == null ? '' : String(raw.title_fa),
description: raw.description == null ? '' : String(raw.description),
type: raw.type == null ? '' : String(raw.type),
is_active: (raw.is_active as KhanegiSettingRow['is_active']) ?? 1,
can_edit: (raw.can_edit as KhanegiSettingRow['can_edit']) ?? 0,
})
}
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
return [...items.value]
.filter((row) => {
if (!isSettingVisible(row.key)) return false
if (filterType.value && resolveSettingFieldType(row) !== filterType.value) return false
if (!q) return true
return (
(row.key || '').toLowerCase().includes(q) ||
(row.title_fa || '').toLowerCase().includes(q) ||
(row.value || '').toLowerCase().includes(q) ||
(row.description || '').toLowerCase().includes(q)
)
})
.sort((a, b) =>
(a.title_fa || a.key || '').localeCompare(b.title_fa || b.key || '', 'fa'),
)
})
const groupedFiltered = computed(() => {
const map: Record<string, KhanegiSettingRow[]> = {}
for (const g of KHANEGI_SYSTEM_SETTING_GROUPS) map[g.id] = []
for (const row of filteredRows.value) {
const gid = getKhanegiSystemGroupId(row.key || '')
map[gid].push(row)
}
return map
})
const visibleSettingGroups = computed(() =>
KHANEGI_SYSTEM_SETTING_GROUPS.filter((g) => (groupedFiltered.value[g.id]?.length ?? 0) > 0),
)
function subsectionBlocks(group: SettingGroupDef) {
const rows = groupedFiltered.value[group.id] ?? []
if (!group.subsections?.length) {
return [{ id: 'all', title: group.title, rows }]
}
const used = new Set<string>()
const blocks = group.subsections
.map((sub) => {
const subRows = rows.filter((row) => {
const key = row.key || ''
if (used.has(key) || !sub.match(key)) return false
used.add(key)
return true
})
return { id: sub.id, title: sub.title, rows: subRows }
})
.filter((b) => b.rows.length > 0)
const leftovers = rows.filter((row) => !used.has(row.key || ''))
if (leftovers.length) {
blocks.push({ id: `${group.id}-other`, title: 'سایر', rows: leftovers })
}
return blocks
}
function rowColSpan(row: KhanegiSettingRow): number {
return resolveSettingFieldType(row) === 'longtext' ? 24 : 8
}
function rowKey(row: KhanegiSettingRow) {
return String(row.key || row.id || '')
}
function draftValue(row: KhanegiSettingRow) {
const k = rowKey(row)
if (drafts.value[k] !== undefined) return drafts.value[k]
return row.value ?? ''
}
function isSaving(row: KhanegiSettingRow) {
return savingKeys.value.has(rowKey(row))
}
function isSavedFlash(row: KhanegiSettingRow) {
return savedFlashKeys.value.has(rowKey(row))
}
function onValueChange(row: KhanegiSettingRow, value: string) {
if (!canWrite.value) return
drafts.value[rowKey(row)] = value
scheduleSave(row)
}
function onFieldBlur(row: KhanegiSettingRow) {
if (!canWrite.value) return
scheduleSave(row, true)
}
function scheduleSave(row: KhanegiSettingRow, flush = false) {
const k = rowKey(row)
const prev = saveTimers.get(k)
if (prev) clearTimeout(prev)
const type = resolveSettingFieldType(row)
const delay = flush ? 0 : type === 'switch' ? 200 : type === 'longtext' ? 2500 : 700
saveTimers.set(
k,
setTimeout(() => {
void persistChanged()
}, delay),
)
}
async function persistChanged() {
if (!canWrite.value) return
const keys = Object.keys(drafts.value)
if (!keys.length) return
const patchItems = keys
.map((k) => {
const row = items.value.find((item) => rowKey(item) === k)
if (!row) return null
const raw = drafts.value[k] !== undefined ? drafts.value[k] : (row.value ?? '')
const type = resolveSettingFieldType(row)
return {
id: row.id,
key: row.key,
value: formatValueForSave(raw, type, row.key),
title_fa: row.title_fa ?? '',
description: row.description ?? '',
type: row.type ?? type,
is_active: row.is_active ?? 1,
can_edit: 0,
}
})
.filter((row): row is NonNullable<typeof row> => Boolean(row?.key))
if (!patchItems.length) return
keys.forEach((k) => savingKeys.value.add(k))
savingKeys.value = new Set(savingKeys.value)
try {
const { data } = await api.patch(`/tenants/${route.params.id}/program-settings`, {
items: patchItems,
})
applyItems(Array.isArray(data.items) ? data.items : items.value)
drafts.value = {}
keys.forEach((k) => {
savedFlashKeys.value.add(k)
const old = flashTimers.get(k)
if (old) clearTimeout(old)
flashTimers.set(
k,
setTimeout(() => {
savedFlashKeys.value.delete(k)
savedFlashKeys.value = new Set(savedFlashKeys.value)
}, 1800),
)
})
savedFlashKeys.value = new Set(savedFlashKeys.value)
} catch (e: any) {
message.error(errText(e) || 'ذخیره تنظیمات ناموفق بود')
} finally {
keys.forEach((k) => savingKeys.value.delete(k))
savingKeys.value = new Set(savingKeys.value)
}
}
function uiRouteName() {
return tenant.value?.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular'
}
function openUrl(url?: string | null) {
const href = String(url || '').trim()
if (!href) {
message.warning('آدرس اپ برای این نسخه ثبت نشده')
return
}
const withProto = /^https?:\/\//i.test(href) ? href : `https://${href}`
window.open(withProto, '_blank', 'noopener,noreferrer')
}
function errText(e: any) {
const msg = e?.response?.data?.message
return Array.isArray(msg) ? msg.join('، ') : msg || 'خطا'
}
function applyItems(raw: unknown[]) {
items.value = raw.map((row) => asRow((row || {}) as Record<string, unknown>))
}
async function load() {
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}/program-settings`)
tenant.value = data.tenant
apiBase.value = data.apiBase || null
applyItems(Array.isArray(data.items) ? data.items : [])
} catch (e: any) {
message.error(errText(e) || 'خطا در بارگذاری تنظیمات برنامه')
if (e?.response?.status === 403 || e?.response?.status === 404) {
router.push({ name: 'home' })
}
} finally {
loading.value = false
}
}
async function importSettings() {
if (!canWrite.value) {
message.warning('دسترسی نوشتن ندارید')
return
}
if (!creds.mobile.trim() || !creds.password) {
message.warning('نام کاربری و رمز عبور ادمین خانگی را وارد کنید')
return
}
importing.value = true
try {
const { data } = await api.post(`/tenants/${route.params.id}/program-settings/import`, {
mobile: creds.mobile.trim(),
password: creds.password,
preview: true,
})
previewRows.value = (data.decisionsNeeded || []).map((row: ImportPreviewRow) => ({
...row,
scope: 'this',
}))
onlySaKeys.value = data.onlySuperAdminKeys || []
apiBase.value = data.apiBase || apiBase.value
previewOpen.value = true
} catch (e: any) {
message.error(errText(e) || 'پیش‌نمایش ایمپورت ناموفق بود')
} finally {
importing.value = false
}
}
async function applyImport() {
applying.value = true
try {
const { data } = await api.post(`/tenants/${route.params.id}/program-settings/import`, {
mobile: creds.mobile.trim(),
password: creds.password,
preview: false,
decisions: previewRows.value.map((row) => ({ key: row.key, scope: row.scope })),
})
tenant.value = data.tenant
apiBase.value = data.apiBase || apiBase.value
applyItems(Array.isArray(data.items) ? data.items : [])
creds.password = ''
previewOpen.value = false
message.success('ایمپورت اعمال شد. کلیدهای فقط-سوپرادمین با پیش‌فرض قالب پر شدند.')
} catch (e: any) {
message.error(errText(e) || 'اعمال ایمپورت ناموفق بود')
} finally {
applying.value = false
}
}
const defaultOpenGroupId = computed(() => {
const visibleIds = visibleSettingGroups.value.map((g) => g.id)
const preferred = ['customer_features', 'chart', 'pricing', 'payping', 'store', 'general']
return preferred.find((id) => visibleIds.includes(id)) ?? visibleIds[0]
})
watch(
visibleSettingGroups,
(groups) => {
const ids = new Set(groups.map((g) => g.id))
if (activeKey.value && !ids.has(activeKey.value)) {
activeKey.value = groups[0]?.id
}
if (!activeKey.value && groups.length) {
activeKey.value = defaultOpenGroupId.value
}
},
{ flush: 'post' },
)
onMounted(load)
</script>
<template>
<div class="page-shell sa-admin-settings" v-if="!loading">
<div class="head-row">
<a-button type="text" @click="router.push({ name: 'version-edit', params: { id: route.params.id } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">تنظیمات برنامه</h1>
<p class="page-sub">
{{ tenant?.nameFa || 'نسخه خانگی' }}
<template v-if="tenant?.domain"> · {{ tenant.domain }}</template>
· کنترل کامل از سوپرادمین (جایگزین تنظیمات پیشرفته ادمین خانگی)
</p>
</div>
<a-space wrap>
<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>
<section class="form-section">
<h3>انتقال از اپ خانگی</h3>
<p class="field-hint">
مقدار کلیدها را از ادمین خانگی می‌آورد. برای هر کلید انتخاب کن: خیر / فقط این پروژه / همه پروژه‌ها.
اگر سوپرادمین کلیدی داشته باشد که خانگی ندارد، مقدار پیش‌فرض قالب برای همین پروژه گذاشته می‌شود.
</p>
<a-form :model="creds" layout="vertical">
<div class="import-grid">
<a-form-item label="نام کاربری / موبایل ادمین" name="mobile">
<a-input
v-model:value="creds.mobile"
size="large"
dir="ltr"
autocomplete="username"
placeholder="09xxxxxxxxx"
/>
</a-form-item>
<a-form-item label="رمز عبور ادمین" name="password">
<a-input-password
v-model:value="creds.password"
size="large"
autocomplete="current-password"
/>
</a-form-item>
</div>
<a-button type="primary" html-type="button" size="large" :loading="importing" @click="importSettings">
پیش‌نمایش ایمپورت
</a-button>
<p v-if="apiBase" class="import-meta">
از <span dir="ltr">{{ apiBase }}</span> · {{ count }} مورد
</p>
</a-form>
</section>
<a-modal
v-model:open="previewOpen"
title="تصمیم برای هر کلید"
:confirm-loading="applying"
ok-text="اعمال"
cancel-text="انصراف"
width="760px"
@ok="applyImport"
>
<p class="field-hint">
کلیدهایی که فقط در سوپرادمین هستند با پیش‌فرض قالب روی این پروژه تکمیل می‌شوند
<template v-if="onlySaKeys.length"> ({{ onlySaKeys.length }} کلید)</template>.
</p>
<div v-if="previewRows.length" class="preview-list">
<div v-for="row in previewRows" :key="row.key" class="preview-row">
<div>
<code dir="ltr">{{ row.key }}</code>
<div class="preview-title">{{ row.title_fa }}</div>
<div class="preview-vals" dir="ltr">خانگی: {{ row.khanegi_value || '—' }}</div>
</div>
<a-radio-group v-model:value="row.scope" size="small">
<a-radio-button value="none">خیر</a-radio-button>
<a-radio-button value="this">فقط این پروژه</a-radio-button>
<a-radio-button value="all">همه پروژه‌ها</a-radio-button>
</a-radio-group>
</div>
</div>
<a-empty v-else description="کلید مشترکی از خانگی نیامد" />
</a-modal>
<section class="form-section">
<div class="settings-toolbar">
<a-input
v-model:value="search"
allow-clear
placeholder="جستجو کلید یا عنوان..."
class="settings-toolbar__search"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<div class="settings-toolbar__filters">
<label class="settings-filter-field">
<span class="settings-filter-field__label">نوع فیلد</span>
<a-select
v-model:value="filterType"
allow-clear
placeholder="همه"
class="settings-filter-field__control"
:options="typeSelectOptions"
/>
</label>
</div>
</div>
<a-collapse
v-if="visibleSettingGroups.length"
v-model:activeKey="activeKey"
accordion
class="settings-collapse"
:bordered="false"
>
<a-collapse-panel v-for="group in visibleSettingGroups" :key="group.id">
<template #header>
<div class="settings-collapse-header">
<component :is="GROUP_ICONS[group.id] || EllipsisOutlined" class="settings-collapse-icon" />
<span class="settings-collapse-title">{{ group.title }}</span>
<a-badge
:count="groupedFiltered[group.id].length"
:number-style="{ backgroundColor: '#d4af37', color: '#fff', fontSize: '11px' }"
/>
</div>
</template>
<template v-if="group.subsections?.length">
<div v-for="block in subsectionBlocks(group)" :key="block.id" class="settings-subsection">
<h3 class="settings-subsection__title">{{ block.title }}</h3>
<a-row :gutter="[16, 16]" class="settings-grid">
<a-col
v-for="row in block.rows"
:key="rowKey(row)"
:xs="24"
:sm="12"
:lg="rowColSpan(row)"
>
<SettingCard
:row="row"
:model-value="draftValue(row)"
:disabled="!canWrite"
:saving="isSaving(row)"
:saved-flash="isSavedFlash(row)"
@update:value="onValueChange(row, $event)"
@blur="onFieldBlur(row)"
/>
</a-col>
</a-row>
</div>
</template>
<a-row v-else :gutter="[16, 16]" class="settings-grid">
<a-col
v-for="row in groupedFiltered[group.id]"
:key="rowKey(row)"
:xs="24"
:sm="12"
:lg="rowColSpan(row)"
>
<SettingCard
:row="row"
:model-value="draftValue(row)"
:disabled="!canWrite"
:saving="isSaving(row)"
:saved-flash="isSavedFlash(row)"
@update:value="onValueChange(row, $event)"
@blur="onFieldBlur(row)"
/>
</a-col>
</a-row>
</a-collapse-panel>
</a-collapse>
<a-empty
v-else
description="هنوز تنظیم سیستمی منتقل نشده — از فرم بالا با یوزر و پسورد ادمین خانگی بگیرید"
/>
</section>
</div>
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 220px">
<a-spin />
</div>
</template>
<style scoped>
.sa-admin-settings {
--admin-border: var(--line);
--admin-bg-elevated: #fff;
--admin-bg-muted: #f8fafc;
--admin-text: var(--ink);
--admin-text-primary: var(--ink);
--admin-text-secondary: #64748b;
--admin-shadow: rgba(45, 55, 72, 0.06);
}
.head-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
}
.field-hint {
margin: 0 0 14px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.import-grid {
display: grid;
gap: 8px 16px;
}
@media (min-width: 768px) {
.import-grid {
grid-template-columns: 1fr 1fr;
}
}
.import-meta {
margin: 12px 0 0;
color: #64748b;
font-size: 13px;
}
.settings-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
margin-bottom: 20px;
padding: 14px 16px;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
border-radius: 8px;
}
.settings-toolbar__search {
flex: 1;
min-width: 200px;
max-width: 320px;
}
.settings-toolbar__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
}
.settings-filter-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 148px;
margin: 0;
}
.settings-filter-field__label {
font-size: 12px;
font-weight: 500;
color: #64748b;
}
.settings-filter-field__control {
width: 148px;
}
.settings-collapse {
background: transparent;
}
.settings-collapse :deep(.ant-collapse-item) {
margin-bottom: 12px;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px var(--admin-shadow);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.settings-collapse :deep(.ant-collapse-item:hover) {
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.08);
border-color: #f4e9c8;
}
.settings-collapse :deep(.ant-collapse-header) {
padding: 14px 18px !important;
background: var(--admin-bg-muted);
font-weight: 600;
font-size: 14px;
color: var(--admin-text);
}
.settings-collapse :deep(.ant-collapse-item-active) {
border-color: #f4e9c8;
box-shadow: 0 6px 16px rgba(212, 175, 55, 0.12);
}
.settings-collapse-header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.settings-collapse-icon {
color: #d4af37;
flex-shrink: 0;
font-size: 18px;
}
.settings-collapse-title {
flex: 1;
font-weight: 600;
color: var(--admin-text);
}
.settings-subsection + .settings-subsection {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--admin-border);
}
.settings-subsection__title {
margin: 0 0 0.75rem;
font-size: 13px;
font-weight: 700;
color: var(--admin-text-primary);
}
.settings-grid {
margin-bottom: 8px;
}
.preview-list {
display: grid;
gap: 10px;
max-height: 52vh;
overflow: auto;
}
.preview-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line, #e2e8f0);
border-radius: 12px;
background: #fff;
}
.preview-title {
font-size: 13px;
color: #334155;
}
.preview-vals {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
</style>
+69 -14
View File
@@ -23,6 +23,7 @@ const form = reactive({
nameFa: '',
nameEn: '',
domain: '',
domains: [] as string[],
avatarUrl: '',
appVersion: '1',
uiVariant: 'modular' as 'modular' | 'newTheme',
@@ -51,6 +52,7 @@ async function load() {
if (!isEdit.value) {
const q = String(route.query.product || '')
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
form.domains = []
createdAt.value = null
return
}
@@ -65,6 +67,7 @@ async function load() {
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',
@@ -119,7 +122,10 @@ async function save() {
}
saving.value = true
try {
const payload = { ...form }
const payload = {
...form,
domains: form.domains.map((item) => item.trim()).filter(Boolean),
}
if (!payload.remoteApiToken) {
delete (payload as any).remoteApiToken
}
@@ -153,19 +159,30 @@ onMounted(load)
<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 },
})
"
>
ظاهر اپ
</a-button>
<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>
</div>
<a-form :model="form" layout="vertical" @finish="save">
@@ -189,6 +206,29 @@ onMounted(load)
<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های اضافی">
<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 @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>
@@ -322,4 +362,19 @@ onMounted(load)
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;
}
</style>