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
+3
View File
@@ -0,0 +1,3 @@
# لوکال: درخواست‌ها مستقیم به دامنه سوپرادمین می‌روند (نه localhost).
# پروداکشن/بیلد روی سرور: این خط را خالی بگذار تا same-origin یعنی /api باشد.
VITE_API_BASE=https://adm.montzar.ir/api
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { message, Modal } 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 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 deleteKey = ref('')
const form = reactive({
key: '',
title_fa: '',
value: '',
description: '',
type: 'text',
type_data: '',
can_edit: 0,
is_active: 1,
})
const typeOptions = SETTING_TYPE_OPTIONS.map((t) => ({ value: t.key, label: t.value }))
function parseJsonItems(raw: string): Array<Record<string, unknown>> {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.items)) return parsed.items
throw new Error('JSON باید آرایه باشد: [{"key":"name_fa",...}]')
}
async function submit() {
if (!props.canWrite) {
message.warning('دسترسی نوشتن ندارید')
return
}
let items: Array<Record<string, unknown>> = []
try {
if (activeTab.value === 'json') {
items = parseJsonItems(jsonText.value)
} else {
if (!form.key.trim()) {
message.warning('اسم کلید لازم است (مثلاً name_fa)')
return
}
items = [{ ...form, key: form.key.trim() }]
}
} catch (e: any) {
message.error(e?.message || 'JSON نامعتبر است')
return
}
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
}
},
})
}
function removeKey() {
const key = deleteKey.value.trim()
if (!key) {
message.warning('اسم کلید را بنویس (مثلاً name_fa)')
return
}
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>
<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">
<div class="catalog-grid">
<a-form-item label="کلید (key)" required>
<a-input v-model:value="form.key" dir="ltr" placeholder="name_fa" size="large" />
</a-form-item>
<a-form-item label="عنوان فارسی">
<a-input v-model:value="form.title_fa" size="large" />
</a-form-item>
<a-form-item label="مقدار پیش‌فرض">
<a-input v-model:value="form.value" dir="ltr" size="large" />
</a-form-item>
<a-form-item label="نوع">
<a-select v-model:value="form.type" :options="typeOptions" size="large" />
</a-form-item>
</div>
<a-form-item label="توضیح">
<a-textarea v-model:value="form.description" :rows="2" />
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="json" tab="JSON">
<a-textarea v-model:value="jsonText" :rows="10" dir="ltr" class="json-box" />
</a-tab-pane>
</a-tabs>
<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>
</template>
<style scoped>
.field-hint {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.catalog-grid {
display: grid;
grid-template-columns: 1fr 1fr;
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;
}
.json-box {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}
</style>
@@ -0,0 +1,212 @@
<template>
<div
class="setting-card"
:class="{
'setting-card--readonly': readonly,
'setting-card--saving': saving
}"
>
<div class="setting-card__head">
<div class="setting-card__title-row">
<span v-if="readonly" class="setting-card__lock" title="فقط‌خواندنی">🔒</span>
<span class="setting-card__title">{{ title }}</span>
</div>
<div class="setting-card__key">کلید: {{ settingKey }}</div>
<div v-if="switchHelp" class="setting-card__help setting-card__help--switch">
<div v-if="switchHelp.note" class="setting-card__help-note">{{ switchHelp.note }}</div>
<div v-if="switchHelp.on" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--on">روشن</span>
<span class="setting-card__help-text">{{ switchHelp.on }}</span>
</div>
<div v-if="switchHelp.off" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--off">خاموش</span>
<span class="setting-card__help-text">{{ switchHelp.off }}</span>
</div>
</div>
<div v-else-if="help" class="setting-card__help">{{ help }}</div>
</div>
<div
class="setting-card__body"
:class="{ 'setting-card__body--switch': fieldType === 'switch' }"
>
<SettingValueField
:model-value="modelValue"
:setting-key="settingKey"
:setting-type="settingType"
:description="description"
:disabled="fieldDisabled"
:rows="fieldType === 'longtext' ? 10 : 6"
@update:value="emit('update:value', $event)"
@blur="emit('blur')"
/>
</div>
<div v-if="saving" class="setting-card__status">
<a-spin size="small" />
<span>در حال ذخیره...</span>
</div>
<div v-else-if="savedFlash" class="setting-card__status setting-card__status--ok">ذخیره شد</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { KhanegiSettingRow as Const } from '@/config/khanegiSettingRow'
import SettingValueField from './SettingValueField.vue'
import {
helpText,
parseSwitchHelp,
resolveSettingTitle,
resolveSettingFieldType,
type SettingFieldType
} from '@/config/khanegiSettingCatalog'
const props = defineProps<{
row: Const
modelValue: string
readonly?: boolean
disabled?: boolean
saving?: boolean
savedFlash?: boolean
}>()
const fieldDisabled = computed(() => props.disabled ?? props.readonly ?? false)
const emit = defineEmits<{
'update:value': [value: string]
blur: []
}>()
const settingKey = computed(() => props.row.key ?? '')
const settingType = computed(() => props.row.type)
const description = computed(() => props.row.description)
const title = computed(() => resolveSettingTitle(props.row))
const fieldType = computed<SettingFieldType>(() =>
resolveSettingFieldType({ key: props.row.key, type: props.row.type })
)
const help = computed(() => helpText(props.row))
const switchHelp = computed(() =>
fieldType.value === 'switch' ? parseSwitchHelp(help.value) : null
)
</script>
<style scoped>
.setting-card {
border: 1px solid var(--admin-border);
border-radius: 10px;
padding: 14px 16px;
background: var(--admin-bg-elevated);
min-height: 108px;
transition: border-color 0.2s, box-shadow 0.2s;
height: 100%;
display: flex;
flex-direction: column;
}
.setting-card:hover:not(.setting-card--readonly) {
border-color: #91caff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.08);
}
.setting-card--readonly {
background: var(--admin-bg-muted);
}
.setting-card--saving {
border-color: #91caff;
}
.setting-card__head {
flex-shrink: 0;
}
.setting-card__title-row {
display: flex;
align-items: flex-start;
gap: 6px;
}
.setting-card__lock {
flex-shrink: 0;
font-size: 13px;
line-height: 1.4;
}
.setting-card__title {
font-weight: 600;
font-size: 14px;
color: var(--admin-text);
line-height: 1.45;
}
.setting-card__key {
margin-top: 6px;
font-size: 12px;
color: var(--admin-text-secondary);
direction: ltr;
text-align: right;
word-break: break-all;
}
.setting-card__help {
margin-top: 4px;
font-size: 11px;
color: var(--admin-text-secondary);
line-height: 1.35;
}
.setting-card__help--switch {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-card__help-note {
font-size: 11px;
line-height: 1.5;
color: var(--admin-text-secondary);
}
.setting-card__help-state {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11px;
line-height: 1.6;
}
.setting-card__help-badge {
flex-shrink: 0;
min-width: 42px;
text-align: center;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
}
.setting-card__help-badge--on {
color: #047857;
background: #d1fae5;
border: 1px solid #a7f3d0;
}
.setting-card__help-badge--off {
color: #b91c1c;
background: #fee2e2;
border: 1px solid #fecaca;
}
.setting-card__help-text {
color: var(--admin-text-secondary);
padding-top: 1px;
}
.setting-card__body {
margin-top: 12px;
flex: 1;
min-width: 0;
}
.setting-card__body--switch {
display: flex;
align-items: center;
}
.setting-card__status {
margin-top: 8px;
font-size: 11px;
color: var(--admin-text-secondary);
display: flex;
align-items: center;
gap: 6px;
min-height: 18px;
}
.setting-card__status--ok {
color: #52c41a;
}
</style>
@@ -0,0 +1,113 @@
<template>
<div
class="setting-toggle"
:class="{
'setting-toggle--on': checked,
'setting-toggle--off': !checked,
'setting-toggle--disabled': disabled,
}"
role="switch"
:aria-checked="checked"
>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': !checked }"
:disabled="disabled"
@click="setChecked(false)"
>
<CloseCircleOutlined />
<span>{{ labels.off }}</span>
</button>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': checked }"
:disabled="disabled"
@click="setChecked(true)"
>
<CheckCircleOutlined />
<span>{{ labels.on }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
const props = withDefaults(
defineProps<{
checked: boolean
labels: { on: string; off: string }
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
change: [checked: boolean]
}>()
function setChecked(next: boolean) {
if (props.disabled || next === props.checked) return
emit('change', next)
}
</script>
<style scoped>
.setting-toggle {
display: inline-flex;
align-items: stretch;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: var(--admin-bg-muted, #f1f5f9);
border: 1px solid var(--admin-border, #e2e8f0);
user-select: none;
}
.setting-toggle--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.setting-toggle__seg {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 84px;
padding: 6px 14px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--admin-text-secondary, #64748b);
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.18s ease;
}
.setting-toggle__seg :deep(svg) {
opacity: 0.75;
}
.setting-toggle__seg:disabled {
cursor: not-allowed;
}
.setting-toggle__seg--active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.setting-toggle--on .setting-toggle__seg--active {
background: #10b981;
color: #fff;
}
.setting-toggle--on .setting-toggle__seg--active :deep(svg) {
opacity: 1;
}
.setting-toggle--off .setting-toggle__seg--active {
background: #fff;
color: #ef4444;
}
.setting-toggle--off .setting-toggle__seg--active :deep(svg) {
opacity: 1;
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="setting-value-field">
<SettingToggle
v-if="fieldType === 'switch'"
:checked="switchChecked"
:labels="labels"
:disabled="disabled"
@change="onSwitchChange"
/>
<a-select
v-else-if="fieldType === 'select'"
:value="modelValue || undefined"
:disabled="disabled"
allow-clear
placeholder="انتخاب کنید"
class="setting-control"
@update:value="onSelectChange"
>
<a-select-option v-for="opt in selectItems" :key="opt.key" :value="opt.key">
{{ opt.value }}
</a-select-option>
</a-select>
<a-select
v-else-if="fieldType === 'multiselect'"
:value="multiSelectValue"
:disabled="disabled"
mode="multiple"
allow-clear
placeholder="انتخاب کنید"
class="setting-control"
@update:value="onMultiSelectChange"
>
<a-select-option v-for="opt in selectItems" :key="opt.key" :value="opt.key">
{{ opt.value }}
</a-select-option>
</a-select>
<a-time-picker
v-else-if="fieldType === 'time'"
:value="timePickerValue"
:disabled="disabled"
class="setting-control"
format="HH:mm"
:minute-step="5"
placeholder="انتخاب ساعت"
@update:value="onTimeChange"
/>
<a-input-number
v-else-if="fieldType === 'price'"
:value="priceValue"
:disabled="disabled"
class="setting-control"
:formatter="(v: string) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(v: string) => sanitizeNumericString(v, { allowDecimal: false })"
@update:value="onPriceChange"
/>
<a-input
v-else-if="fieldType === 'number'"
:value="modelValue"
:disabled="disabled"
inputmode="numeric"
allow-clear
class="setting-control"
@update:value="onPlainNumberChange"
@blur="emit('blur')"
/>
<a-textarea
v-else-if="fieldType === 'longtext'"
:value="modelValue"
:disabled="disabled"
:rows="rows"
class="setting-control setting-control--mono"
@update:value="emit('update:value', $event)"
@blur="emit('blur')"
/>
<a-input
v-else
:value="modelValue"
:disabled="disabled"
allow-clear
class="setting-control"
@update:value="emit('update:value', $event)"
@blur="emit('blur')"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SettingToggle from './SettingToggle.vue'
import {
normalizeTimeValue,
parseSelectOptions,
resolveSettingFieldType,
resolveSwitchChecked,
switchLabels,
switchValueFromChecked,
type SettingFieldType
} from '@/config/khanegiSettingCatalog'
import { parseNumericValue, sanitizeNumericString } from '@/config/numeric'
import dayjs, { type Dayjs } from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
const props = withDefaults(
defineProps<{
modelValue: string
settingKey?: string
settingType?: string
description?: string
disabled?: boolean
rows?: number
}>(),
{ disabled: false, rows: 10 }
)
const emit = defineEmits<{
'update:value': [value: string]
blur: []
}>()
const fieldType = computed<SettingFieldType>(() =>
resolveSettingFieldType({
key: props.settingKey,
type: props.settingType
})
)
const selectItems = computed(() =>
parseSelectOptions({
key: props.settingKey,
description: props.description,
type: props.settingType
})
)
const labels = computed(() => switchLabels(props.settingKey))
const switchChecked = computed(() => resolveSwitchChecked(props.settingKey, props.modelValue))
function onSwitchChange(checked: boolean) {
emit('update:value', switchValueFromChecked(props.settingKey, checked))
}
function onSelectChange(val: string | undefined) {
emit('update:value', val ?? '')
}
const multiSelectValue = computed<string[]>(() =>
String(props.modelValue || '')
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
)
function onMultiSelectChange(val: string[] | undefined) {
const list = Array.isArray(val) ? val.map((s) => s.trim()).filter((s) => s !== '') : []
emit('update:value', list.join(','))
}
function parseTimePickerValue(raw?: string | null): Dayjs | undefined {
const normalized = normalizeTimeValue(raw)
if (!normalized) return undefined
const parsed = dayjs(normalized, 'HH:mm:ss', true)
return parsed.isValid() ? parsed : undefined
}
const timePickerValue = computed(() => parseTimePickerValue(props.modelValue))
function onTimeChange(val: Dayjs | string | null | undefined) {
if (val == null || val === '') {
emit('update:value', '')
emit('blur')
return
}
const parsed = dayjs.isDayjs(val) ? val : dayjs(String(val), 'HH:mm:ss', true)
emit('update:value', parsed.isValid() ? normalizeTimeValue(parsed.format('HH:mm:ss')) : '')
emit('blur')
}
const priceValue = computed(() => {
const n = parseNumericValue(props.modelValue || '')
return Number.isFinite(n) ? n : undefined
})
function onPriceChange(val: number | string | null) {
if (val === null || val === undefined || val === '') {
emit('update:value', '')
return
}
emit('update:value', sanitizeNumericString(val, { allowDecimal: false }))
}
function onPlainNumberChange(val: string) {
emit('update:value', sanitizeNumericString(val ?? '', { allowDecimal: true }))
}
</script>
<style scoped>
.setting-value-field,
.setting-control {
width: 100%;
}
.setting-value-field :deep(.ant-input-number),
.setting-value-field :deep(.ant-select),
.setting-value-field :deep(.ant-picker) {
width: 100%;
}
.setting-control--mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
export type KhanegiSettingRow = {
id?: number
key?: string
value?: string
description?: string
title_fa?: string
type?: string
is_active?: boolean | number | string
can_edit?: number | boolean | string
}
+165
View File
@@ -0,0 +1,165 @@
export type SettingSubsectionDef = {
id: string
title: string
match: (key: string) => boolean
}
export type SettingGroupDef = {
id: string
title: string
icon: string
match: (key: string) => boolean
subsections?: SettingSubsectionDef[]
}
/** گروه‌های تنظیمات دیپلوی خانگی (can_edit=0) — هم‌تراز پنل ادمین.
* کلید جدید برای همهٔ نسخه‌ها: اول `controlpanel_server/.../program-setting-catalog.ts`
* بعد این‌جا گروه را مشخص کن تا در «متفرقه» نماند.
*/
export const KHANEGI_SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
{
id: 'customer_features',
title: 'دسترسی مشتری به قابلیت‌ها',
icon: 'TeamOutlined',
match: (k) =>
/^(enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|customer_special_pricing|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test(
k,
),
},
{
id: 'support_chat',
title: 'چت پشتیبانی',
icon: 'MessageOutlined',
match: (k) => /^(chat_api|chat_api_key|chat_upload_url|chat_download_url)$/.test(k),
},
{
id: 'general',
title: 'عمومی و برند',
icon: 'HomeOutlined',
match: (k) =>
/^(name_fa|name_en|app_url|admin_url|apk_download_link|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|sms_panel|main_wallet_products)/.test(
k,
),
},
{
id: 'chart',
title: 'نمودار قیمت',
icon: 'LineChartOutlined',
match: (k) => /^chart_/.test(k),
},
{
id: 'pricing',
title: 'منبع قیمت و تابلو',
icon: 'DollarOutlined',
match: (k) => /^(price_provider|zaarbox_|main_product$)/.test(k) || k.includes('charge_percentage'),
},
{
id: 'montzar',
title: 'Montzar',
icon: 'LinkOutlined',
match: (k) => /^montzar_/.test(k) || k === 'display_montzar',
},
{
id: 'store',
title: 'اتصال فروشگاه',
icon: 'ShopOutlined',
match: (k) => /^node_/.test(k) || k === 'montify_name' || k === 'external_order_api_enabled',
},
{
id: 'deposit_methods',
title: 'روش‌های واریز',
icon: 'CreditCardOutlined',
match: (k) =>
k.startsWith('external_payment_') || k === 'wallet_payment_provider',
subsections: [
{
id: 'gateway',
title: 'درگاه فروشگاه / پرداخت آنلاین',
match: (k) => k.startsWith('external_payment_') || k === 'wallet_payment_provider',
},
],
},
{
id: 'payping',
title: 'درگاه Payping',
icon: 'WalletOutlined',
match: (k) => k.startsWith('payping_'),
},
{
id: 'accounting_provider',
title: 'انتخاب سیستم حسابداری',
icon: 'AccountBookOutlined',
match: (k) => k === 'accounting_provider',
},
{
id: 'accounting_zarin',
title: 'حسابداری زرین',
icon: 'BankOutlined',
match: (k) => k.startsWith('zarin_'),
},
{
id: 'accounting_tahesab',
title: 'حسابداری ته‌حساب',
icon: 'CalculatorOutlined',
match: (k) => k.startsWith('tahesab_') || k === 'send_to_tahesab',
},
{
id: 'accounting_pargar',
title: 'حسابداری پرگار',
icon: 'FileTextOutlined',
match: (k) => k.startsWith('pargar_') || k === 'send_to_pargar',
},
{
id: 'price_channel',
title: 'ارسال قیمت به کانال',
icon: 'SendOutlined',
match: (k) =>
/^price_channel_/.test(k) || /^(telegram_bot|bale_bot|rubika_bot)/.test(k) || k === 'change_mesghal',
},
{
id: 'infra',
title: 'زیرساخت',
icon: 'ClusterOutlined',
match: (k) => /^(wss_enabled|sse_path|profit_gold_api_enabled)$/.test(k),
},
{
id: 'misc',
title: 'سایر',
icon: 'EllipsisOutlined',
match: () => true,
},
]
export function getKhanegiSystemGroupId(key: string): string {
for (const group of KHANEGI_SYSTEM_SETTING_GROUPS) {
if (group.id === 'misc') continue
if (group.match(key)) return group.id
}
return 'misc'
}
export function getKhanegiSubsectionId(key: string, group: SettingGroupDef | undefined): string | null {
if (!group?.subsections?.length) return null
for (const sub of group.subsections) {
if (sub.match(key)) return sub.id
}
return null
}
export function isSecretSettingKey(key: string): boolean {
return /(password|secret|token|api_key|apikey|private_key|shared_secret)/i.test(key)
}
export function isSwitchSetting(type: string | null | undefined, value: unknown): boolean {
const t = String(type || '').toLowerCase()
if (t === 'switch' || t === 'boolean' || t === 'bool') return true
if (typeof value === 'boolean') return true
const v = String(value ?? '').trim().toLowerCase()
return v === 'true' || v === 'false'
}
export function isSettingOn(value: unknown): boolean {
if (value === true || value === 1) return true
const v = String(value ?? '').trim().toLowerCase()
return v === 'true' || v === '1' || v === 'on'
}
+45
View File
@@ -0,0 +1,45 @@
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'
export function persian2english(s: string): string {
return String(s ?? '')
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)))
}
export function sanitizeNumericString(
raw: unknown,
options: { allowDecimal?: boolean } = {},
): string {
const allowDecimal = options.allowDecimal !== false
let s = persian2english(String(raw ?? '').trim())
.replace(/[٬,\s]/g, '')
.replace(/[−–—]/g, '-')
.replace(/٫/g, '.')
.replace(/\//g, '.')
if (!allowDecimal) {
return s.replace(/[^\d-]/g, '').replace(/(?!^)-/g, '')
}
let out = ''
let hasDot = false
let hasSign = false
for (const ch of s) {
if (ch === '-' && !hasSign && out === '') {
hasSign = true
out += ch
} else if (ch === '.' && !hasDot) {
hasDot = true
out += ch
} else if (ch >= '0' && ch <= '9') {
out += ch
}
}
return out
}
export function parseNumericValue(raw: unknown): number {
const n = Number(sanitizeNumericString(raw, { allowDecimal: true }))
return Number.isFinite(n) ? n : NaN
}
+19
View File
@@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, SearchOutlined } from '@ant-design/ic
import api from '@/api/http' import api from '@/api/http'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatIranDateTime } from '@/utils/datetime' import { formatIranDateTime } from '@/utils/datetime'
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
type Tenant = { type Tenant = {
id: string id: string
@@ -20,6 +21,7 @@ type Tenant = {
nodeServer: string nodeServer: string
notes?: string | null notes?: string | null
hasAppUiConfig?: boolean hasAppUiConfig?: boolean
hasProgramSettings?: boolean
uiVariant?: 'modular' | 'newTheme' uiVariant?: 'modular' | 'newTheme'
createdAt?: string createdAt?: string
} }
@@ -123,6 +125,8 @@ onMounted(load)
</a-button> </a-button>
</div> </div>
<SettingsCatalogPanel v-if="product === 'khanegi'" :can-write="canWrite" />
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<div v-if="filtered.length" class="version-list"> <div v-if="filtered.length" class="version-list">
<article v-for="item in filtered" :key="item.id" class="version-card"> <article v-for="item in filtered" :key="item.id" class="version-card">
@@ -154,6 +158,7 @@ onMounted(load)
بررسی محصول بررسی محصول
</a-tag> </a-tag>
<a-tag v-if="item.hasAppUiConfig" color="purple">ظاهر اپ</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'"> <a-tag v-if="product === 'khanegi'" :color="item.uiVariant === 'newTheme' ? 'geekblue' : 'cyan'">
{{ item.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }} {{ item.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }}
</a-tag> </a-tag>
@@ -164,6 +169,20 @@ onMounted(load)
<a-button type="primary" ghost block style="flex: 1" @click="edit(item.id)"> <a-button type="primary" ghost block style="flex: 1" @click="edit(item.id)">
مدیریت مدیریت
</a-button> </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 <a-button
type="default" type="default"
block 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>
+57 -2
View File
@@ -23,6 +23,7 @@ const form = reactive({
nameFa: '', nameFa: '',
nameEn: '', nameEn: '',
domain: '', domain: '',
domains: [] as string[],
avatarUrl: '', avatarUrl: '',
appVersion: '1', appVersion: '1',
uiVariant: 'modular' as 'modular' | 'newTheme', uiVariant: 'modular' as 'modular' | 'newTheme',
@@ -51,6 +52,7 @@ async function load() {
if (!isEdit.value) { if (!isEdit.value) {
const q = String(route.query.product || '') const q = String(route.query.product || '')
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar' form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
form.domains = []
createdAt.value = null createdAt.value = null
return return
} }
@@ -65,6 +67,7 @@ async function load() {
nameFa: data.nameFa, nameFa: data.nameFa,
nameEn: data.nameEn || '', nameEn: data.nameEn || '',
domain: data.domain, domain: data.domain,
domains: Array.isArray(data.domains) ? data.domains.map(String) : [],
avatarUrl: data.avatarUrl || '', avatarUrl: data.avatarUrl || '',
appVersion: data.appVersion || '1', appVersion: data.appVersion || '1',
uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular', uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular',
@@ -119,7 +122,10 @@ async function save() {
} }
saving.value = true saving.value = true
try { try {
const payload = { ...form } const payload = {
...form,
domains: form.domains.map((item) => item.trim()).filter(Boolean),
}
if (!payload.remoteApiToken) { if (!payload.remoteApiToken) {
delete (payload as any).remoteApiToken delete (payload as any).remoteApiToken
} }
@@ -153,8 +159,18 @@ onMounted(load)
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1> <h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p> <p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
</div> </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 <a-button
v-if="isEdit && form.product === 'khanegi'"
type="primary" type="primary"
ghost ghost
@click=" @click="
@@ -166,6 +182,7 @@ onMounted(load)
> >
ظاهر اپ ظاهر اپ
</a-button> </a-button>
</a-space>
</div> </div>
<a-form :model="form" layout="vertical" @finish="save"> <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-form-item label="دامنه فروشگاه" name="domain" :rules="[{ required: true, message: 'لازم است' }]">
<a-input v-model:value="form.domain" size="large" placeholder="example.com" dir="ltr" /> <a-input v-model:value="form.domain" size="large" placeholder="example.com" dir="ltr" />
</a-form-item> </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-form-item v-if="isEdit" label="تاریخ ایجاد">
<a-input :value="formatIranDateTime(createdAt)" size="large" disabled /> <a-input :value="formatIranDateTime(createdAt)" size="large" disabled />
</a-form-item> </a-form-item>
@@ -322,4 +362,19 @@ onMounted(load)
max-height: 100%; max-height: 100%;
object-fit: contain; 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> </style>
+6
View File
@@ -113,6 +113,12 @@ const router = createRouter({
component: () => import('@/pages/KhanegiModularUiConfigPage.vue'), component: () => import('@/pages/KhanegiModularUiConfigPage.vue'),
meta: { permissions: ['khanegi:read'] }, meta: { permissions: ['khanegi:read'] },
}, },
{
path: 'versions/:id/settings',
name: 'version-program-settings',
component: () => import('@/pages/ProgramSettingsPage.vue'),
meta: { permissions: ['khanegi:read'] },
},
{ {
path: 'versions/:id/ui-hamkar', path: 'versions/:id/ui-hamkar',
name: 'version-ui-hamkar', name: 'version-ui-hamkar',
+20 -6
View File
@@ -1,8 +1,12 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'https://adm.montzar.ir'
return {
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
@@ -13,21 +17,31 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: proxyTarget,
changeOrigin: true, changeOrigin: true,
secure: true,
}, },
'/get_user': { '/get_user': {
target: 'http://localhost:3001', target: proxyTarget,
changeOrigin: true, changeOrigin: true,
secure: true,
},
'/get_settings': {
target: proxyTarget,
changeOrigin: true,
secure: true,
}, },
'/get_version': { '/get_version': {
target: 'http://localhost:3001', target: proxyTarget,
changeOrigin: true, changeOrigin: true,
secure: true,
}, },
'/uploads': { '/uploads': {
target: 'http://localhost:3001', target: proxyTarget,
changeOrigin: true, changeOrigin: true,
secure: true,
}, },
}, },
}, },
}
}) })