Files
web/src/pages/KhanegiModularUiConfigPage.vue
T
mohmdsdgandCursor 6dbb15c6e1 feat(versions): shared identity/UI/settings tabs and denser desktop form
Keep the same pill nav on all three Khanegi version pages, tighten the identity form on desktop, and split catalog add/delete into separate modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 21:39:50 +03:30

403 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const canWrite = computed(() => auth.can('khanegi:write'))
const loading = ref(true)
const saving = ref(false)
const switching = ref(false)
const tenantName = ref('')
const domain = ref('')
const uiVariant = ref<'modular' | 'newTheme'>('modular')
const brand = reactive({
primaryColor: '#0a1b37',
secondaryColor: '#e0be7f',
backgroundColor: '#ffffff',
})
const NAV_ITEMS = ['خانه', 'خرید و فروش', 'کیف پول', 'تراکنش‌ها', 'پروفایل']
function swatchHex(value: string, fallback = '#cbd5e1') {
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim())
? String(value).trim()
: fallback
}
function textOn(bg: string) {
const hex = swatchHex(bg, '#ffffff').replace('#', '')
const full =
hex.length === 3
? hex
.split('')
.map((c) => c + c)
.join('')
: hex
const n = parseInt(full, 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#0f172a' : '#ffffff'
}
const palette = computed(() => ({
header: swatchHex(brand.primaryColor, '#0a1b37'),
accent: swatchHex(brand.secondaryColor, '#e0be7f'),
page: swatchHex(brand.backgroundColor, '#ffffff'),
}))
const previewChrome = computed(() => {
const p = palette.value
const onHeader = textOn(p.header)
return {
shell: {
background: p.page,
borderRadius: '18px',
border: '1px solid #e2e8f0',
overflow: 'hidden' as const,
},
header: {
background: p.header,
color: onHeader,
borderBottom:
onHeader === '#ffffff' ? '1px solid rgba(255,255,255,0.12)' : '1px solid #e2e8f0',
},
accent: p.accent,
muted: onHeader === '#ffffff' ? 'rgba(255,255,255,0.72)' : '#616161',
}
})
async function load() {
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
if (data.product !== 'khanegi') {
if (data.product === 'hamkar') {
router.replace({ name: 'version-ui-hamkar', params: { id: String(route.params.id) } })
return
}
message.warning('ظاهر اپ برای این محصول فعال نیست')
router.replace({ name: 'version-edit', params: { id: String(route.params.id) } })
return
}
const variant = data.uiVariant === 'newTheme' ? 'newTheme' : 'modular'
uiVariant.value = variant
if (variant === 'newTheme') {
router.replace({ name: 'version-ui', params: { id: String(route.params.id) } })
return
}
tenantName.value = data.nameFa || ''
domain.value = data.domain || ''
brand.primaryColor = String(data.primaryColor || brand.primaryColor)
brand.secondaryColor = String(data.secondaryColor || brand.secondaryColor)
brand.backgroundColor = String(data.backgroundColor || brand.backgroundColor)
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری ظاهر ماژولار')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
await api.patch(`/tenants/${route.params.id}`, {
uiVariant: 'modular',
primaryColor: brand.primaryColor,
secondaryColor: brand.secondaryColor,
backgroundColor: brand.backgroundColor,
})
message.success('ظاهر ماژولار ذخیره شد')
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
async function switchToNewTheme() {
switching.value = true
try {
await api.patch(`/tenants/${route.params.id}`, {
uiVariant: 'newTheme',
primaryColor: brand.primaryColor,
secondaryColor: brand.secondaryColor,
backgroundColor: brand.backgroundColor,
})
message.success('حالت ظاهر به تم جدید تغییر کرد')
router.replace({ name: 'version-ui', params: { id: String(route.params.id) } })
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'تغییر حالت ناموفق')
} finally {
switching.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page-shell">
<div class="head-row">
<a-button type="text" @click="router.push({ name: 'product-versions', params: { product: 'khanegi' } })">
<ArrowRightOutlined />
</a-button>
<div class="head-text">
<h1 class="page-title">ظاهر اپ خانگی — ماژولار</h1>
<p class="page-sub">
{{ tenantName || 'نسخه' }}
<template v-if="domain"> · {{ domain }}</template>
</p>
</div>
<div class="head-aside">
<VersionPageTabs
active="ui"
:tenant-id="String(route.params.id)"
ui-route-name="version-ui-modular"
/>
<a-space wrap>
<a-select
:value="uiVariant"
style="width: 180px"
:options="[
{ value: 'modular', label: 'Modular' },
{ value: 'newTheme', label: 'New theme' },
]"
:disabled="!canWrite || switching || saving || loading"
@change="(v: string) => v === 'newTheme' && switchToNewTheme()"
/>
<a-button
v-if="canWrite"
type="primary"
size="large"
:loading="saving"
@click="save"
>
ذخیره
</a-button>
</a-space>
</div>
</div>
<a-spin :spinning="loading || switching">
<p class="hint">
لوگو را از صفحه مدیریت نسخه تغییر دهید. اینجا فقط سه رنگ اصلی مثل همکار تنظیم می‌شود.
</p>
<div class="layout">
<section class="form-section preview">
<h3>پیش‌نمایش</h3>
<div class="app-preview" :style="previewChrome.shell">
<div class="app-preview-header" :style="previewChrome.header">
<div
v-for="(item, i) in NAV_ITEMS"
:key="item"
class="app-preview-nav"
:style="{ color: i === 0 ? previewChrome.accent : previewChrome.muted }"
>
<span
class="app-preview-dot"
:style="{ background: i === 0 ? previewChrome.accent : previewChrome.muted }"
/>
{{ item }}
</div>
</div>
<div class="app-preview-body">
<div class="app-preview-card">
<div class="app-preview-card-title">خوش آمدگویی</div>
<div class="app-preview-card-line" />
<div class="app-preview-card-line short" />
</div>
<div class="app-preview-price" :style="{ borderColor: palette.accent }">
<span>نقد فردا</span>
<strong :style="{ color: palette.accent }">۸۱٬۴۵۰٬۰۰۰</strong>
</div>
</div>
</div>
</section>
<section class="form-section">
<h3>رنگ‌ها</h3>
<div class="palette-grid">
<label class="palette-card">
<span class="palette-title">هدر</span>
<span class="palette-swatch" :style="{ background: palette.header }">
<input
:value="palette.header"
type="color"
@input="brand.primaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input v-model:value="brand.primaryColor" dir="ltr" size="large" class="palette-hex" />
</label>
<label class="palette-card">
<span class="palette-title">اکسنت</span>
<span class="palette-swatch" :style="{ background: palette.accent }">
<input
:value="palette.accent"
type="color"
@input="brand.secondaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input v-model:value="brand.secondaryColor" dir="ltr" size="large" class="palette-hex" />
</label>
<label class="palette-card">
<span class="palette-title">پس‌زمینه</span>
<span class="palette-swatch" :style="{ background: palette.page }">
<input
:value="palette.page"
type="color"
@input="brand.backgroundColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input v-model:value="brand.backgroundColor" dir="ltr" size="large" class="palette-hex" />
</label>
</div>
</section>
</div>
</a-spin>
</div>
</template>
<style scoped>
.head-row {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.head-text {
flex: 1 1 180px;
min-width: 0;
}
.head-aside {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.hint {
color: #64748b;
margin: 0 0 16px;
}
.layout {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 16px;
}
.form-section {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
}
.form-section h3 {
margin: 0 0 12px;
}
.palette-grid {
display: grid;
gap: 12px;
}
.palette-card {
display: grid;
grid-template-columns: 72px 56px 1fr;
gap: 10px;
align-items: center;
}
.palette-title {
font-weight: 600;
}
.palette-swatch {
width: 56px;
height: 40px;
border-radius: 10px;
border: 1px solid #e2e8f0;
overflow: hidden;
position: relative;
}
.palette-swatch input[type='color'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.palette-hex {
font-family: ui-monospace, monospace;
}
.app-preview-header {
display: flex;
gap: 14px;
padding: 14px 16px;
flex-wrap: wrap;
}
.app-preview-nav {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.app-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.app-preview-body {
padding: 16px;
display: grid;
gap: 12px;
}
.app-preview-card {
background: #fff;
border-radius: 12px;
padding: 14px;
border: 1px solid #e2e8f0;
}
.app-preview-card-title {
font-weight: 700;
margin-bottom: 10px;
}
.app-preview-card-line {
height: 8px;
border-radius: 99px;
background: #e2e8f0;
margin-bottom: 8px;
}
.app-preview-card-line.short {
width: 55%;
}
.app-preview-price {
background: #fff;
border-radius: 12px;
padding: 14px;
border: 1px solid;
display: flex;
justify-content: space-between;
align-items: center;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.head-row {
flex-wrap: wrap;
}
}
</style>