Files
web/src/pages/HamkarUiConfigPage.vue
T

595 lines
17 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 { 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'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const canWrite = computed(() => auth.can('hamkar:write'))
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 class="page-head">
<a-button type="text" class="page-head-back" @click="router.push({ name: 'product-versions', params: { product: 'hamkar' } })">
<ArrowRightOutlined />
</a-button>
<div class="page-head-text">
<h1 class="page-title">ظاهر اپ همکار</h1>
<p class="page-sub">
{{ tenantName || 'نسخه' }}
<template v-if="domain"> · <span class="page-domain">{{ domain }}</span></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
v-if="canWrite"
size="large"
block
:loading="resetting"
:disabled="!hasDefaultSnapshot"
@click="resetToDefault"
>
بازگشت به ظاهر اولیه
</a-button>
<a-button
v-if="canWrite"
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>