470 lines
14 KiB
Vue
470 lines
14 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 {
|
|||
|
|
createDefaultHamkarUiConfig,
|
|||
|
|
HAMKAR_ORDER_UI_OPTIONS,
|
|||
|
|
HAMKAR_PRICE_FONT_SIZE_OPTIONS,
|
|||
|
|
HAMKAR_PRICING_BOX_THEME_OPTIONS,
|
|||
|
|
hamkarUiToApiPayload,
|
|||
|
|
normalizeHamkarUiConfig,
|
|||
|
|
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 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 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'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Live palette driving the preview (= what save will apply via dynamicMode) */
|
|||
|
|
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,
|
|||
|
|
})
|
|||
|
|
// Named CSS modes ignore registry colors — seed pickers with what the app actually paints
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 ||
|
|||
|
|
data.notes?.match(/registryMode:([^\s|]+)/)?.[1] ||
|
|||
|
|
'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)
|
|||
|
|
}
|
|||
|
|
seedEditableColors(normalized)
|
|||
|
|
Object.assign(config, normalized)
|
|||
|
|
} catch (e: any) {
|
|||
|
|
message.error(e?.response?.data?.message || 'خطا در بارگذاری ظاهر همکار')
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function save() {
|
|||
|
|
saving.value = true
|
|||
|
|
try {
|
|||
|
|
// Colors only paint the app in dynamicMode (Hamkar apply-theme)
|
|||
|
|
config.registryMode = 'dynamicMode'
|
|||
|
|
const payload = hamkarUiToApiPayload(config)
|
|||
|
|
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))
|
|||
|
|
message.success('ظاهر همکار ذخیره شد')
|
|||
|
|
} 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 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>
|
|||
|
|
<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 type="primary" size="large" block :loading="saving" @click="save">
|
|||
|
|
ذخیره ظاهر همکار
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-spin>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.layout {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
</style>
|