Files
web/src/pages/KhanegiModularUiConfigPage.vue
T

403 lines
11 KiB
Vue
Raw Normal View History

<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'
2026-08-10 15:30:26 +03:30
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
2026-08-10 15:30:26 +03:30
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>