413 lines
13 KiB
Vue
413 lines
13 KiB
Vue
<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>
|