fix pargar transaction 2

This commit is contained in:
2026-10-21 11:12:29 +03:30
parent 30dbb1892d
commit 1490adfb60
4 changed files with 8 additions and 252 deletions
+5 -1
View File
@@ -386,6 +386,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
support_messengers: 'longtext',
show_to_users_chat: 'switch',
has_landing: 'switch',
has_charts: 'switch',
has_chat: 'switch',
landing_site: 'longtext',
landing_header: 'longtext',
@@ -803,6 +804,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'روشن: قابلیت چت پشتیبانی برای این دیپلوی فعال است (منوی ادمین و پیش‌نیاز نمایش به کاربر). خاموش: چت در ادمین و اپ کاربر مخفی است و سوییچ «چت پشتیبانی» (has_chat) از تنظیمات عادی هم برداشته می‌شود.',
has_landing:
'روشن: منوی تنظیمات وبسایت در ادمین و تنظیمات لندینگ برای اپ کاربر فعال است. خاموش: منو مخفی است.',
has_charts:
'روشن: بخش نمودارها در اپلیکیشن فعال است. خاموش: نمودارها غیرفعال هستند.',
landing_site: 'محتوای تنظیمات کلی لندینگ وبسایت.',
landing_header: 'محتوای هدر و ناوبری لندینگ.',
landing_hero: 'محتوای سکشن هیرو لندینگ.',
@@ -1103,6 +1106,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
support_messengers: 'پیام‌رسان‌های پشتیبانی',
show_to_users_chat: 'نمایش چت به کاربران',
has_landing: 'لندینگ وبسایت',
has_charts: 'نمودارها',
has_chat: 'چت پشتیبانی',
landing_site: 'تنظیمات کلی سایت',
landing_header: 'هدر و ناوبری',
@@ -1206,7 +1210,7 @@ export const SETTING_LOCKED_KEYS = new Set([
'unit',
'wss_enabled',
'zaarbox_api_url',
'show_to_users_chat', 'has_landing', 'chat_api', 'chat_api_key', 'chat_upload_url', 'chat_download_url'
'show_to_users_chat', 'has_landing', 'has_charts', 'chat_api', 'chat_api_key', 'chat_upload_url', 'chat_download_url'
])
/** قابل ویرایش در پنل — هم‌تراز deploy_settings_const.sql */
+1 -1
View File
@@ -22,7 +22,7 @@ export const KHANEGI_SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'دسترسی مشتری به قابلیت‌ها',
icon: 'TeamOutlined',
match: (k) =>
/^(enable_automation|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|enable_gold_credit|tracking_systems_enabled|permision__enabled|show_to_users_chat|has_landing)$/.test(
/^(enable_automation|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|enable_gold_credit|tracking_systems_enabled|permision__enabled|show_to_users_chat|has_landing|has_charts)$/.test(
k,
),
},
+1
View File
@@ -11,6 +11,7 @@ export const SUPERADMIN_SYSTEM_SETTING_KEYS = new Set<string>([
"enable_gold_credit",
"enable_product_funds",
"group_sms",
"has_charts",
"has_landing",
"has_page_earn_money",
"has_page_gift_card",
+1 -250
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import {
ArrowRightOutlined,
DeleteOutlined,
GlobalOutlined,
SearchOutlined,
TeamOutlined,
@@ -38,12 +37,9 @@ const router = useRouter()
const auth = useAuthStore()
const canWrite = computed(() => auth.can('khanegi.settings:write'))
const loading = ref(true)
const importing = ref(false)
const purging = 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())
@@ -51,32 +47,11 @@ const savedFlashKeys = ref<Set<string>>(new Set())
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
const flashTimers = new Map<string, ReturnType<typeof setTimeout>>()
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 count = computed(() => items.value.length)
function asRow(raw: Record<string, unknown>): KhanegiSettingRow {
return normalizeSettingRow({
id: typeof raw.id === 'number' ? raw.id : undefined,
@@ -240,7 +215,6 @@ async function load() {
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) || 'خطا در بارگذاری تنظیمات برنامه')
@@ -252,99 +226,6 @@ async function load() {
}
}
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('مقدارهای can_edit=0 خانگی روی این پروژه نوشته شد.')
} catch (e: any) {
message.error(errText(e) || 'اعمال ایمپورت ناموفق بود')
} finally {
applying.value = false
}
}
/**
* بعد از restore لوکال روی سرور همان پروژه: بقیهٔ کلیدها (غیر دسترسی مشتری)
* را از جدول settings همین tenant در سوپرادمین حذف می‌کند.
*/
function confirmPurgeNonOwned() {
if (!canWrite.value) {
message.warning('دسترسی نوشتن ندارید')
return
}
Modal.confirm({
title: 'حذف تنظیمات غیر دسترسی مشتری؟',
content:
'فقط کلیدهای «دسترسی مشتری به قابلیت‌ها» روی این پروژه در سوپرادمین می‌ماند. ' +
'بقیه از جدول settings همین پروژه در سوپرادمین پاک می‌شود. ' +
'قبل از این کار باید روی سرور همان نصب، settings:restore-from-backup را اجرا کرده باشی.',
okText: 'حذف کن',
okType: 'danger',
cancelText: 'انصراف',
onOk: () => purgeNonOwned(),
})
}
async function purgeNonOwned() {
purging.value = true
try {
const { data } = await api.post(`/tenants/${route.params.id}/program-settings/purge-non-owned`)
tenant.value = data.tenant
applyItems(Array.isArray(data.items) ? data.items : [])
const removed = Number(data.removed || 0)
message.success(
removed > 0
? `${removed} کلید غیر دسترسی از این پروژه حذف شد.`
: 'چیزی برای حذف نبود؛ همین حالا فقط کلیدهای دسترسی مشتری روی این پروژه است.',
)
} catch (e: any) {
message.error(errText(e) || 'حذف ناموفق بود')
} finally {
purging.value = false
}
}
onMounted(load)
</script>
@@ -375,97 +256,6 @@ onMounted(load)
</div>
</div>
<section v-if="canWrite" class="form-section purge-section">
<h3>مهاجرت مالکیت به دیتابیس نصب</h3>
<p class="field-hint">
ترتیب: ۱) روی سرور همان پروژه
<code dir="ltr">php artisan settings:restore-from-backup --fill-from-superadmin</code>
۲) همین دکمه را بزن تا همهٔ کلیدها غیر از «دسترسی مشتری به قابلیت‌ها»
از جدول settings این پروژه در سوپرادمین حذف شوند.
</p>
<a-button danger type="primary" html-type="button" size="large" :loading="purging" @click="confirmPurgeNonOwned">
<DeleteOutlined />
حذف تنظیمات غیر دسترسی از این پروژه
</a-button>
</section>
<section class="form-section">
<h3>انتقال از اپ خانگی</h3>
<p class="field-hint">
مقدار کلیدهای <b>جدید</b> خانگی را می‌آورد — نه کلیدهایی که از قبل در قالب
<code dir="ltr">deploy_settings_const</code> هستند.
اگر سوپرادمین کلیدی داشته باشد که خانگی ندارد، پیش‌فرض قالب برای همین پروژه گذاشته می‌شود.
</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">
کلیدهای قالب
<code dir="ltr">deploy_settings_const</code>
بدون سؤال روی همین پروژه نوشته می‌شوند (مقدار
<code dir="ltr">can_edit=0</code>
خانگی). اینجا فقط کلیدهایی می‌آیند که در آن فایل نیستند.
بدون انتخاب، پیش‌فرض «فقط این پروژه» است
<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 v-if="row.superadmin_value != null" class="preview-vals" dir="ltr">
سوپرادمین: {{ row.superadmin_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="کلید جدیدی خارج از قالب نیست؛ اعمال همان can_edit=0 خانگی را طبق قالب روی این پروژه می‌نویسد"
/>
</a-modal>
<section class="form-section">
<div class="settings-toolbar">
@@ -578,20 +368,6 @@ onMounted(load)
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;
@@ -702,29 +478,4 @@ onMounted(load)
.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>