Files
web/src/pages/HamkarUiConfigPage.vue
T

584 lines
17 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Modal, message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import {
cloneHamkarSnapshot,
createDefaultHamkarUiConfig,
HAMKAR_ORDER_UI_OPTIONS,
HAMKAR_PRICE_FONT_SIZE_OPTIONS,
HAMKAR_PRICING_BOX_THEME_OPTIONS,
hamkarUiToApiPayload,
normalizeHamkarUiConfig,
registryModeFromNotes,
type HamkarUiConfig,
} from '@/config/defaultHamkarUiConfig'
import { resolveHamkarEffectiveTheme } from '@/config/hamkarEffectiveTheme'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const resetting = ref(false)
const tenantName = ref('')
const domain = ref('')
const config = reactive<HamkarUiConfig>(createDefaultHamkarUiConfig())
const settingsManaged = computed({
get: () => config.settingsManaged,
set: (v: boolean) => {
config.settingsManaged = v
},
})
const themeOptions = computed(() =>
HAMKAR_PRICING_BOX_THEME_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const orderOptions = computed(() =>
HAMKAR_ORDER_UI_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const fontOptions = computed(() =>
HAMKAR_PRICE_FONT_SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const hasDefaultSnapshot = computed(() => Boolean(config.defaultSnapshot))
const defaultModeLabel = computed(() => config.defaultSnapshot?.registryMode || '—')
const NAV_ITEMS = ['معامله', 'دارایی', 'سفارشات', 'گزارش‌دهی', 'مشتریان']
function swatchHex(value: string, fallback = '#cbd5e1') {
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim()) ? 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(config.brand.primaryColor, '#ffffff'),
accent: swatchHex(config.brand.secondaryColor, '#2563EB'),
page: swatchHex(config.brand.backgroundColor, '#fafafa'),
}))
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',
}
})
function seedEditableColors(normalized: HamkarUiConfig) {
const effective = resolveHamkarEffectiveTheme({
registryMode: normalized.registryMode,
primaryColor: normalized.brand.primaryColor,
secondaryColor: normalized.brand.secondaryColor,
backgroundColor: normalized.brand.backgroundColor,
})
if (!effective.isDynamic) {
normalized.brand.primaryColor = effective.headerBg
normalized.brand.secondaryColor = effective.accent
normalized.brand.backgroundColor = effective.pageBg
} else {
if (!normalized.brand.primaryColor) normalized.brand.primaryColor = effective.headerBg
if (!normalized.brand.secondaryColor) normalized.brand.secondaryColor = effective.accent
if (!normalized.brand.backgroundColor) normalized.brand.backgroundColor = effective.pageBg
}
}
function ensureDefaultSnapshot(
normalized: HamkarUiConfig,
opts: {
notes?: string | null
primaryColor?: string | null
secondaryColor?: string | null
backgroundColor?: string | null
},
) {
if (normalized.defaultSnapshot) return
const noteMode = registryModeFromNotes(opts.notes)
const currentMode = String(normalized.registryMode || '').trim()
const registryMode =
(noteMode && noteMode !== 'dynamicMode' ? noteMode : null) ||
(currentMode && currentMode !== 'dynamicMode' ? currentMode : null) ||
noteMode ||
currentMode ||
'dynamicMode'
normalized.defaultSnapshot = cloneHamkarSnapshot({
registryMode,
brand: {
primaryColor: String(opts.primaryColor || normalized.brand.primaryColor || ''),
secondaryColor: String(opts.secondaryColor || normalized.brand.secondaryColor || ''),
backgroundColor: String(opts.backgroundColor || normalized.brand.backgroundColor || ''),
},
settings: { ...normalized.settings },
})
}
async function persist(payload: ReturnType<typeof hamkarUiToApiPayload>) {
await api.patch(`/tenants/${route.params.id}`, {
appUiConfig: payload,
primaryColor: payload.brand.primaryColor,
secondaryColor: payload.brand.secondaryColor,
backgroundColor: payload.brand.backgroundColor,
})
Object.assign(config, normalizeHamkarUiConfig(payload))
}
async function load() {
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
if (data.product !== 'hamkar') {
message.warning('این صفحه فقط برای نسخه‌های همکار است')
router.replace({ name: 'version-ui', params: { id: String(route.params.id) } })
return
}
tenantName.value = data.nameFa || ''
domain.value = data.domain || ''
const rawUi =
data.appUiConfig && data.appUiConfig.kind === 'hamkar'
? data.appUiConfig
: {
kind: 'hamkar',
settingsManaged: false,
registryMode:
data.appUiConfig?.registryMode ||
registryModeFromNotes(data.notes) ||
'dynamicMode',
brand: {
primaryColor: data.primaryColor,
secondaryColor: data.secondaryColor,
backgroundColor: data.backgroundColor,
},
settings: {},
}
const normalized = normalizeHamkarUiConfig(rawUi)
if (!normalized.brand.primaryColor && data.primaryColor) {
normalized.brand.primaryColor = String(data.primaryColor)
}
if (!normalized.brand.secondaryColor && data.secondaryColor) {
normalized.brand.secondaryColor = String(data.secondaryColor)
}
if (!normalized.brand.backgroundColor && data.backgroundColor) {
normalized.brand.backgroundColor = String(data.backgroundColor)
}
ensureDefaultSnapshot(normalized, {
notes: data.notes,
primaryColor: data.primaryColor,
secondaryColor: data.secondaryColor,
backgroundColor: data.backgroundColor,
})
seedEditableColors(normalized)
Object.assign(config, normalized)
if (!rawUi.defaultSnapshot && normalized.defaultSnapshot) {
await persist(hamkarUiToApiPayload(normalized))
}
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری ظاهر همکار')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
config.registryMode = 'dynamicMode'
if (!config.defaultSnapshot) {
ensureDefaultSnapshot(config, {
notes: null,
primaryColor: config.brand.primaryColor,
secondaryColor: config.brand.secondaryColor,
backgroundColor: config.brand.backgroundColor,
})
}
await persist(hamkarUiToApiPayload(config))
message.success('ظاهر همکار ذخیره شد')
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
function resetToDefault() {
if (!config.defaultSnapshot) {
message.warning('ظاهر پیش‌فرض برای این نسخه ثبت نشده است')
return
}
Modal.confirm({
title: 'بازگشت به ظاهر اولیه؟',
content: `حالت پیش‌فرض «${config.defaultSnapshot.registryMode}» اعمال و ذخیره می‌شود.`,
okText: 'بازگشت به پیش‌فرض',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
resetting.value = true
try {
const snap = config.defaultSnapshot!
config.registryMode = snap.registryMode
config.brand.primaryColor = snap.brand.primaryColor
config.brand.secondaryColor = snap.brand.secondaryColor
config.brand.backgroundColor = snap.brand.backgroundColor
config.settings = { ...snap.settings }
await persist(hamkarUiToApiPayload(config))
seedEditableColors(config)
message.success('ظاهر اولیه بازیابی شد')
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'بازیابی ناموفق')
throw e
} finally {
resetting.value = false
}
},
})
}
onMounted(load)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'product-versions', params: { product: 'hamkar' } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">ظاهر اپ همکار</h1>
<p class="page-sub">
{{ tenantName || 'نسخه' }}
<template v-if="domain"> · {{ domain }}</template>
</p>
</div>
</div>
<a-spin :spinning="loading">
<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>
<p v-if="hasDefaultSnapshot" class="hint">
ظاهر پیش‌فرض ثبت‌شده: <strong>{{ defaultModeLabel }}</strong>
</p>
<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="config.brand.primaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.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="config.brand.secondaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.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="config.brand.backgroundColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.brand.backgroundColor"
dir="ltr"
size="large"
class="palette-hex"
/>
</label>
</div>
</section>
<section class="form-section">
<h3>تنظیمات UI نسخه</h3>
<a-form-item label="کنترل از سوپرادمین">
<a-switch v-model:checked="settingsManaged" />
</a-form-item>
<a-form layout="vertical" :disabled="!config.settingsManaged">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تم جعبه قیمت">
<a-select
v-model:value="config.settings.pricing_box_theme"
size="large"
:options="themeOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="سایز فونت قیمت">
<a-select
v-model:value="config.settings.price_font_size"
size="large"
:options="fontOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نمایش سفارشات">
<a-select
v-model:value="config.settings.orderUI"
size="large"
:options="orderOptions"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</section>
<div class="sticky-actions">
<a-button
size="large"
block
:loading="resetting"
:disabled="!hasDefaultSnapshot"
@click="resetToDefault"
>
بازگشت به ظاهر اولیه
</a-button>
<a-button type="primary" size="large" block :loading="saving" @click="save">
ذخیره ظاهر همکار
</a-button>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped>
.layout {
display: grid;
gap: 14px;
}
.hint {
margin: 0 0 10px;
color: #64748b;
font-size: 13px;
}
.preview {
padding: 16px;
}
.app-preview {
margin-top: 8px;
}
.app-preview-header {
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 4px;
padding: 10px 8px;
min-height: 56px;
}
.app-preview-nav {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
}
.app-preview-dot {
width: 18px;
height: 18px;
border-radius: 6px;
opacity: 0.9;
}
.app-preview-body {
padding: 14px;
display: grid;
gap: 10px;
}
.app-preview-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
}
.app-preview-card-title {
font-weight: 800;
margin-bottom: 8px;
color: #0f172a;
}
.app-preview-card-line {
height: 8px;
border-radius: 999px;
background: #e2e8f0;
margin-bottom: 6px;
}
.app-preview-card-line.short {
width: 55%;
}
.app-preview-price {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border: 1px solid #e2e8f0;
border-right-width: 4px;
border-radius: 12px;
padding: 12px 14px;
color: #334155;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 768px) {
.palette-grid {
grid-template-columns: 1fr;
}
}
.palette-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
cursor: pointer;
}
.palette-title {
font-size: 14px;
font-weight: 800;
color: #0f172a;
}
.palette-swatch {
position: relative;
height: 72px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
overflow: hidden;
}
.palette-swatch input[type='color'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
opacity: 0;
cursor: pointer;
}
.palette-hex {
font-family: ui-monospace, monospace;
}
.sticky-actions {
display: grid;
gap: 8px;
}
</style>