Files
web/src/pages/UiConfigPage.vue
T

413 lines
13 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 { createDefaultKhanegiUiConfig } from '@/config/defaultKhanegiUiConfig'
import { colorFieldLabels, colorPalettes } from '@/theme/tokens'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const tenantName = ref('')
const product = ref<'hamkar' | 'khanegi'>('khanegi')
const activeTab = ref('theme')
const advancedJson = ref('')
const jsonError = ref('')
const config = reactive(createDefaultKhanegiUiConfig())
const colorKeys = computed(() => Object.keys(config.app.theme.colors))
const previewStyle = computed(() => ({
background: config.app.theme.colors.pageBg,
color: config.app.theme.colors.textPrimary,
borderRadius: `${config.app.theme.borderRadius.lg}px`,
}))
const accentStyle = computed(() => ({
background: config.app.theme.colors.secondary,
color: config.app.theme.colors.onAccent,
borderRadius: `${config.app.theme.borderRadius.md}px`,
}))
function applyPalette(id: string) {
const palette = colorPalettes.find((p) => p.id === id)
if (!palette) return
Object.assign(config.app.theme.colors, palette.colors)
message.success(`پالت «${palette.name}» اعمال شد`)
}
function syncAdvancedJson() {
advancedJson.value = JSON.stringify(config, null, 2)
jsonError.value = ''
}
function applyAdvancedJson() {
try {
const parsed = JSON.parse(advancedJson.value)
Object.assign(config, parsed)
jsonError.value = ''
message.success('JSON اعمال شد')
} catch (e: any) {
jsonError.value = e?.message || 'JSON نامعتبر'
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
tenantName.value = data.nameFa
product.value = data.product
if (data.product !== 'khanegi') {
message.warning('ظاهر اپ فعلاً فقط برای خانگی فعال است')
router.replace({ name: 'version-edit', params: { id: String(route.params.id) } })
return
}
if (data.appUiConfig && typeof data.appUiConfig === 'object') {
Object.assign(config, createDefaultKhanegiUiConfig(), data.appUiConfig)
if (!config.app?.theme?.colors) {
Object.assign(config, createDefaultKhanegiUiConfig())
}
}
syncAdvancedJson()
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const colors = config.app.theme.colors
await api.patch(`/tenants/${route.params.id}`, {
appUiConfig: JSON.parse(JSON.stringify(config)),
primaryColor: colors.primary,
secondaryColor: colors.secondary,
backgroundColor: colors.background,
})
message.success('ظاهر اپ ذخیره شد')
syncAdvancedJson()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading" class="page-shell ui-editor">
<div class="ui-editor-top">
<a-button type="text" class="back-btn" @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">{{ tenantName }} · app_ui_config</p>
</div>
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره ظاهر</a-button>
</div>
<div class="preview-phone" :style="previewStyle">
<div class="preview-bar">
<span>{{ tenantName || 'پیش‌نمایش' }}</span>
<span class="preview-chip" :style="accentStyle">اکسنت</span>
</div>
<div class="preview-card" :style="{ background: config.app.theme.colors.surface, borderRadius: config.app.theme.borderRadius.md + 'px' }">
<div style="opacity: 0.7; font-size: 12px">surface</div>
<div style="margin-top: 8px; font-weight: 700">نمونه کارت اپ</div>
<div style="margin-top: 6px; font-size: 12px" :style="{ color: config.app.theme.colors.textSecondary }">
متن فرعی با رنگ theme
</div>
</div>
</div>
<a-tabs v-model:activeKey="activeTab" class="ui-tabs">
<a-tab-pane key="theme" tab="تم و رنگ‌ها">
<section class="form-section">
<h3>پالت‌های آماده</h3>
<div class="palette-grid">
<button
v-for="p in colorPalettes"
:key="p.id"
type="button"
class="palette-card"
@click="applyPalette(p.id)"
>
<div class="palette-swatches">
<span :style="{ background: p.colors.primary }" />
<span :style="{ background: p.colors.secondary }" />
<span :style="{ background: p.colors.pageBg }" />
<span :style="{ background: p.colors.surface }" />
</div>
<div class="palette-name">{{ p.name }}</div>
</button>
</div>
</section>
<section class="form-section">
<h3>حالت تم</h3>
<a-radio-group v-model:value="config.app.theme.mode" button-style="solid">
<a-radio-button value="dark">تیره</a-radio-button>
<a-radio-button value="light">روشن</a-radio-button>
</a-radio-group>
</section>
<section class="form-section">
<h3>رنگ‌ها</h3>
<div class="color-grid">
<label v-for="key in colorKeys" :key="key" class="color-field">
<span>{{ colorFieldLabels[key] || key }}</span>
<div class="color-row">
<span
class="color-swatch"
:style="{ background: (config.app.theme.colors as any)[key] }"
/>
<input
v-if="String((config.app.theme.colors as any)[key] || '').startsWith('#')"
v-model="(config.app.theme.colors as any)[key]"
type="color"
class="color-native"
/>
<a-input v-model:value="(config.app.theme.colors as any)[key]" dir="ltr" />
</div>
</label>
</div>
</section>
<section class="form-section">
<h3>گوشه‌ها (border radius)</h3>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item label="sm">
<a-input-number v-model:value="config.app.theme.borderRadius.sm" :min="0" :max="40" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="md">
<a-input-number v-model:value="config.app.theme.borderRadius.md" :min="0" :max="40" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="lg">
<a-input-number v-model:value="config.app.theme.borderRadius.lg" :min="0" :max="48" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</section>
</a-tab-pane>
<a-tab-pane key="nav" tab="منوها">
<section class="form-section">
<h3>منوی کناری</h3>
<div v-for="item in config.app.navigation.sidebar" :key="item.id" class="nav-row">
<a-switch v-model:checked="item.visible" />
<a-input v-model:value="item.label" />
<span class="nav-route" dir="ltr">{{ item.route }}</span>
</div>
</section>
<section class="form-section">
<h3>منوی پایین موبایل</h3>
<div v-for="item in config.app.navigation.bottom" :key="item.id" class="nav-row">
<a-switch v-model:checked="item.visible" />
<a-input v-model:value="item.label" />
<span class="nav-route" dir="ltr">{{ item.route }}</span>
</div>
</section>
<section class="form-section">
<h3>کارت دعوت (سایدبار)</h3>
<a-form-item label="نمایش">
<a-switch v-model:checked="config.app.navigation.sidebarReferral.visible" />
</a-form-item>
<a-form-item label="عنوان">
<a-input v-model:value="config.app.navigation.sidebarReferral.title" />
</a-form-item>
<a-form-item label="زیرعنوان">
<a-input v-model:value="config.app.navigation.sidebarReferral.subtitle" />
</a-form-item>
</section>
</a-tab-pane>
<a-tab-pane key="header" tab="هدر">
<a-form-item label="نمایش هدر">
<a-switch v-model:checked="config.app.header.visible" />
</a-form-item>
<a-form-item label="لوگو">
<a-switch v-model:checked="config.app.header.showLogo" />
</a-form-item>
<a-form-item label="اعلان‌ها">
<a-switch v-model:checked="config.app.header.showNotifications" />
</a-form-item>
<a-form-item label="پروفایل">
<a-switch v-model:checked="config.app.header.showProfile" />
</a-form-item>
<a-form-item label="زیرعنوان برند">
<a-input v-model:value="config.app.header.brandSubtitle" />
</a-form-item>
</a-tab-pane>
<a-tab-pane key="json" tab="JSON پیشرفته">
<p class="page-sub" style="margin-bottom: 12px">
برای pages و جزئیات کامل می‌توانید JSON را مستقیم ویرایش کنید. اگر از قبل در دیتابیس مشتری pages دارید، اینجا پیست کنید.
</p>
<a-textarea v-model:value="advancedJson" :rows="18" dir="ltr" class="json-box" />
<div v-if="jsonError" style="color: #dc2626; margin-top: 8px">{{ jsonError }}</div>
<div style="display: flex; gap: 8px; margin-top: 12px">
<a-button @click="syncAdvancedJson">بازنشانی از فرم</a-button>
<a-button type="primary" ghost @click="applyAdvancedJson">اعمال JSON روی فرم</a-button>
</div>
</a-tab-pane>
</a-tabs>
<div class="sticky-actions">
<a-button type="primary" size="large" block :loading="saving" @click="save">ذخیره ظاهر</a-button>
</div>
</div>
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 240px">
<a-spin size="large" />
</div>
</template>
<style scoped>
.ui-editor-top {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
}
.preview-phone {
padding: 16px;
margin-bottom: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.preview-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 700;
}
.preview-chip {
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
}
.preview-card {
padding: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.palette-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px;
background: #fff;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.palette-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.palette-swatches {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.palette-swatches span {
height: 22px;
border-radius: 6px;
}
.palette-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink);
}
.color-grid {
display: grid;
gap: 12px;
}
@media (min-width: 768px) {
.color-grid {
grid-template-columns: 1fr 1fr;
}
}
.color-field {
display: grid;
gap: 6px;
font-size: 0.85rem;
color: var(--ink-soft);
}
.color-row {
display: flex;
gap: 8px;
align-items: center;
}
.color-native {
width: 44px;
height: 44px;
border: none;
padding: 0;
background: transparent;
cursor: pointer;
}
.color-swatch {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid var(--line);
flex-shrink: 0;
}
.nav-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.nav-route {
font-size: 12px;
color: var(--ink-soft);
min-width: 72px;
}
.json-box {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
font-size: 12px;
}
</style>