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:
@@ -0,0 +1,3 @@
|
|||||||
|
# لوکال: درخواستها مستقیم به دامنه سوپرادمین میروند (نه localhost).
|
||||||
|
# پروداکشن/بیلد روی سرور: این خط را خالی بگذار تا same-origin یعنی /api باشد.
|
||||||
|
VITE_API_BASE=https://adm.montzar.ir/api
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '',
|
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,19 +159,30 @@ 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-button
|
<a-space v-if="isEdit && form.product === 'khanegi'" wrap>
|
||||||
v-if="isEdit && form.product === 'khanegi'"
|
<a-button
|
||||||
type="primary"
|
@click="
|
||||||
ghost
|
router.push({
|
||||||
@click="
|
name: 'version-program-settings',
|
||||||
router.push({
|
params: { id: route.params.id },
|
||||||
name: form.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular',
|
})
|
||||||
params: { id: route.params.id },
|
"
|
||||||
})
|
>
|
||||||
"
|
تنظیمات برنامه
|
||||||
>
|
</a-button>
|
||||||
ظاهر اپ
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+41
-27
@@ -1,33 +1,47 @@
|
|||||||
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 }) => {
|
||||||
plugins: [vue()],
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
resolve: {
|
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'https://adm.montzar.ir'
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
return {
|
||||||
},
|
plugins: [vue()],
|
||||||
},
|
resolve: {
|
||||||
server: {
|
alias: {
|
||||||
port: 5173,
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/get_user': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/get_version': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/uploads': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/get_user': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/get_settings': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/get_version': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user