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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user