added main page config

This commit is contained in:
2026-10-13 19:28:08 +03:30
parent 5f3102f875
commit f99af90328
12 changed files with 3020 additions and 1 deletions
+329
View File
@@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import {
LAYOUT_OPTION_KEY,
nextLayoutThemeId,
type KhanegiLayoutVariants,
type LayoutElementConfig,
} from '@/config/khanegiLayoutVariants'
const props = defineProps<{
layouts: KhanegiLayoutVariants
canWrite: boolean
}>()
const emit = defineEmits<{ change: [] }>()
type OptionKind = 'boolean' | 'text' | 'number' | 'json'
type DraftKind = 'boolean' | 'text' | 'number'
const groups = [
{
id: 'shell',
title: 'هدر و فوتر',
hint: 'رنگ و منوی قبلی دست نمی‌خورد. تنظیم هر المان جدا ذخیره می‌شود.',
items: [
{ path: 'header', title: 'هدر', hint: 'هدر کل اپ. ظاهر از تمی می‌آید که اینجا انتخاب می‌کنید.' },
{ path: 'footer', title: 'فوتر و دکمه‌های ناوبری', hint: 'نوار پایین اپ. با زیاد شدن تم‌ها از همین سلکت عوض می‌شود.' },
],
},
{
id: 'home',
title: 'صفحهٔ اصلی',
hint: 'فقط خانه. کلید تنظیم را اضافه یا حذف کنید؛ مثلاً showChart برای نمودار کادر قیمت. رسم این کلیدها در اپ مرحلهٔ بعد است.',
items: [
{ path: 'home.assets', title: 'دارایی', hint: 'کارت دارایی صفحهٔ اصلی.' },
{ path: 'home.livePrice', title: 'قیمت لحظه‌ای', hint: 'کادر قیمت. جزئیات مثل نمودار را پایین همین کارت اضافه یا حذف کنید.' },
{ path: 'home.quickAccess', title: 'دسترسی سریع', hint: 'بلوک دسترسی سریع خانه.' },
{ path: 'home.recentTransactions', title: 'تراکنش‌های اخیر', hint: 'لیست تراکنش خانه.' },
],
},
]
const kindOptions = [
{ value: 'boolean', label: 'روشن / خاموش' },
{ value: 'text', label: 'متن' },
{ value: 'number', label: 'عدد' },
]
const drafts = reactive<Record<string, { key: string; kind: DraftKind }>>({})
const themeOptions = computed(() =>
props.layouts.themes.map((theme) => ({
value: theme.id,
label: `${theme.label} (${theme.id})`,
})),
)
function touch() {
emit('change')
}
function elementAt(path: string): LayoutElementConfig {
if (path === 'header') return props.layouts.header
if (path === 'footer') return props.layouts.footer
const key = path.slice('home.'.length)
return props.layouts.home[key]
}
function draftOf(path: string) {
if (!drafts[path]) drafts[path] = { key: '', kind: 'boolean' }
return drafts[path]
}
function optionKind(value: unknown): OptionKind {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
if (typeof value === 'string') return 'text'
return 'json'
}
function optionRows(element: LayoutElementConfig) {
return Object.keys(element.options).map((key) => ({
key,
kind: optionKind(element.options[key]),
}))
}
function setOption(element: LayoutElementConfig, key: string, value: unknown) {
element.options[key] = value
touch()
}
function addOption(path: string) {
const element = elementAt(path)
const draft = draftOf(path)
const key = draft.key.trim()
if (!LAYOUT_OPTION_KEY.test(key)) {
message.warning('کلید انگلیسی باشد و با حرف شروع شود')
return
}
if (Object.prototype.hasOwnProperty.call(element.options, key)) {
message.warning('این کلید روی همین المان هست')
return
}
element.options[key] = draft.kind === 'boolean' ? false : draft.kind === 'number' ? 0 : ''
draft.key = ''
touch()
}
function removeOption(path: string, key: string) {
delete elementAt(path).options[key]
touch()
}
function addTheme() {
const id = nextLayoutThemeId(props.layouts.themes.map((theme) => theme.id))
props.layouts.themes.push({ id, label: `تم ${id.replace('theme', '')}` })
touch()
}
function removeTheme(id: string) {
if (props.layouts.themes.length <= 1) return
const fallback = props.layouts.themes.find((theme) => theme.id !== id)?.id || 'theme1'
const elements = [props.layouts.header, props.layouts.footer, ...Object.values(props.layouts.home)]
for (const element of elements) {
if (element.variant === id) element.variant = fallback
}
const index = props.layouts.themes.findIndex((theme) => theme.id === id)
if (index >= 0) props.layouts.themes.splice(index, 1)
touch()
}
</script>
<template>
<section class="form-section">
<h3>فهرست تم‌ها</h3>
<p class="page-sub layout-lead">
تم تازه را اینجا اضافه کنید. هر المان با سلکت یکی را برمی‌دارد. حذف تم، المان‌های همان تم را به تم باقی‌مانده برمی‌گرداند.
</p>
<div class="theme-list">
<div v-for="theme in layouts.themes" :key="theme.id" class="theme-row">
<span class="theme-id" dir="ltr">{{ theme.id }}</span>
<a-input v-model:value="theme.label" :disabled="!canWrite" @change="touch" />
<a-button :disabled="!canWrite || layouts.themes.length <= 1" @click="removeTheme(theme.id)">
حذف
</a-button>
</div>
</div>
<a-button type="dashed" :disabled="!canWrite" @click="addTheme">افزودن تم</a-button>
</section>
<section v-for="group in groups" :key="group.id" class="form-section">
<h3>{{ group.title }}</h3>
<p class="page-sub layout-lead">{{ group.hint }}</p>
<div class="layout-stack">
<article v-for="item in group.items" :key="item.path" class="layout-card">
<div class="layout-card-top">
<div class="layout-card-head">
<div class="layout-title">{{ item.title }}</div>
<p class="page-sub">{{ item.hint }}</p>
</div>
<a-select
v-model:value="elementAt(item.path).variant"
class="layout-theme-select"
:options="themeOptions"
:disabled="!canWrite"
@change="touch"
/>
</div>
<div class="layout-options">
<div class="layout-options-title">تنظیمات همین المان</div>
<p v-if="!optionRows(elementAt(item.path)).length" class="page-sub">
تنظیمی نیست. کلید انگلیسی اضافه کنید؛ مثلاً showChart.
</p>
<div v-for="row in optionRows(elementAt(item.path))" :key="row.key" class="option-row">
<code class="option-key" dir="ltr">{{ row.key }}</code>
<a-switch
v-if="row.kind === 'boolean'"
:checked="elementAt(item.path).options[row.key] === true"
:disabled="!canWrite"
checked-children="روشن"
un-checked-children="خاموش"
@change="(value: boolean) => setOption(elementAt(item.path), row.key, value)"
/>
<a-input-number
v-else-if="row.kind === 'number'"
:value="elementAt(item.path).options[row.key]"
:disabled="!canWrite"
class="option-value"
@change="(value: number | null) => setOption(elementAt(item.path), row.key, value == null ? 0 : value)"
/>
<a-input
v-else-if="row.kind === 'text'"
:value="elementAt(item.path).options[row.key]"
:disabled="!canWrite"
class="option-value"
@update:value="(value: string) => setOption(elementAt(item.path), row.key, value)"
/>
<span v-else class="page-sub">مقدار پیچیده است. از تب JSON عوضش کنید.</span>
<a-button type="text" danger :disabled="!canWrite" @click="removeOption(item.path, row.key)">
حذف
</a-button>
</div>
<div class="option-add">
<a-input
v-model:value="draftOf(item.path).key"
:disabled="!canWrite"
placeholder="showChart"
dir="ltr"
@press-enter="addOption(item.path)"
/>
<a-select
v-model:value="draftOf(item.path).kind"
:options="kindOptions"
:disabled="!canWrite"
/>
<a-button :disabled="!canWrite" @click="addOption(item.path)">افزودن</a-button>
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.layout-lead {
margin-bottom: 12px;
}
.theme-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.theme-row,
.option-row,
.option-add {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
align-items: center;
}
.theme-id,
.option-key {
font-size: 12px;
color: var(--ink-soft);
}
.layout-stack {
display: grid;
gap: 10px;
}
.layout-card {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper, #fff);
}
.layout-card-top {
display: flex;
flex-direction: column;
gap: 8px;
}
.layout-card-head {
min-width: 0;
flex: 1;
}
.layout-title,
.layout-options-title {
font-weight: 700;
color: var(--ink);
}
.layout-options-title {
font-size: 0.85rem;
}
.layout-card .page-sub {
margin: 4px 0 0;
}
.layout-theme-select,
.option-value {
width: 100%;
}
.layout-options {
display: grid;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
@media (min-width: 768px) {
.theme-row {
grid-template-columns: 7rem minmax(0, 1fr) auto;
}
.layout-card-top {
flex-direction: row;
align-items: center;
}
.layout-theme-select {
width: 220px;
flex: 0 0 220px;
}
.option-row {
grid-template-columns: 9rem minmax(0, 1fr) auto;
}
.option-add {
grid-template-columns: minmax(0, 1fr) 9rem auto;
}
}
</style>
+4
View File
@@ -1,3 +1,5 @@
import { applyKhanegiLayoutVariants, createDefaultKhanegiLayoutVariants } from '@/config/khanegiLayoutVariants'
export type ThemeColors = {
primary: string
secondary: string
@@ -112,6 +114,7 @@ export function createDefaultKhanegiUiConfig() {
showProfile: true,
brandSubtitle: 'پنل کاربری',
},
layoutVariants: createDefaultKhanegiLayoutVariants(),
},
pages: {} as Record<string, unknown>,
}
@@ -303,6 +306,7 @@ export function normalizeKhanegiUiConfigForClient<T extends Record<string, any>>
if (app.header && typeof app.header === 'object') {
app.header.variant = 'default'
}
applyKhanegiLayoutVariants(app)
if (app.navigation && typeof app.navigation === 'object') {
if (app.navigation.type === 'sidebar+bottom') app.navigation.type = 'both'
app.navigation.sidebar = mapLegacyNavItems(app.navigation.sidebar)
+145
View File
@@ -0,0 +1,145 @@
/**
* چیدمان المان‌های تم جدید.
* با controlpanel_server/src/get-user/khanegi-layout-variants.ts یکی بماند.
* variant شناسهٔ themeN است. جزئیات المان داخل options می‌ماند.
*/
export const LAYOUT_VARIANT_ID = /^theme[1-9]\d*$/
export const LAYOUT_OPTION_KEY = /^[a-zA-Z][a-zA-Z0-9_]*$/
export type LayoutThemeDef = {
id: string
label: string
}
export type LayoutElementConfig = {
id: string
variant: string
options: Record<string, unknown>
}
export type KhanegiLayoutVariants = {
schemaId: 'layout-variants'
version: '1.0.0'
themes: LayoutThemeDef[]
header: LayoutElementConfig
footer: LayoutElementConfig
home: {
assets: LayoutElementConfig
livePrice: LayoutElementConfig
quickAccess: LayoutElementConfig
recentTransactions: LayoutElementConfig
[elementId: string]: LayoutElementConfig
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function isLayoutVariantId(value: unknown): value is string {
return typeof value === 'string' && LAYOUT_VARIANT_ID.test(value)
}
export function nextLayoutThemeId(ids: string[]): string {
let n = 1
const used = new Set(ids)
while (used.has(`theme${n}`)) n += 1
return `theme${n}`
}
function defaultThemes(): LayoutThemeDef[] {
return [
{ id: 'theme1', label: 'تم ۱' },
{ id: 'theme2', label: 'تم ۲' },
]
}
function element(id: string): LayoutElementConfig {
return { id, variant: 'theme1', options: {} }
}
export function mergeLayoutThemes(stored: unknown, usedIds: string[] = []): LayoutThemeDef[] {
const parsed: LayoutThemeDef[] = []
const seen = new Set<string>()
if (Array.isArray(stored)) {
for (const row of stored) {
if (!isRecord(row) || !isLayoutVariantId(row.id) || seen.has(row.id)) continue
seen.add(row.id)
const label = typeof row.label === 'string' && row.label.trim()
? row.label.trim().slice(0, 40)
: row.id
parsed.push({ id: row.id, label })
}
}
const list = parsed.length ? parsed : defaultThemes()
for (const id of usedIds) {
if (!isLayoutVariantId(id) || list.some((theme) => theme.id === id)) continue
list.push({ id, label: id })
}
return list
}
export function createDefaultKhanegiLayoutVariants(): KhanegiLayoutVariants {
return {
schemaId: 'layout-variants',
version: '1.0.0',
themes: defaultThemes(),
header: element('header'),
footer: element('footer'),
home: {
assets: element('assets'),
livePrice: element('livePrice'),
quickAccess: element('quickAccess'),
recentTransactions: element('recentTransactions'),
},
}
}
export function mergeLayoutElement(id: string, stored: unknown): LayoutElementConfig {
const base = element(id)
if (!isRecord(stored)) return base
const options = isRecord(stored.options) ? { ...stored.options } : {}
return {
...stored,
id,
variant: isLayoutVariantId(stored.variant) ? stored.variant : 'theme1',
options,
}
}
/** تم انتخاب‌شده و کلید options پاک نمی‌شود. فهرست تم خالی به تم ۱ و ۲ برمی‌گردد. */
export function mergeKhanegiLayoutVariants(stored: unknown): KhanegiLayoutVariants {
const defaults = createDefaultKhanegiLayoutVariants()
const src = isRecord(stored) ? stored : {}
const homeSrc = isRecord(src.home) ? src.home : {}
const knownHome = Object.keys(defaults.home)
const home: KhanegiLayoutVariants['home'] = { ...defaults.home }
for (const key of Object.keys(homeSrc)) {
if (knownHome.includes(key)) continue
home[key] = mergeLayoutElement(key, homeSrc[key])
}
for (const key of knownHome) {
home[key] = mergeLayoutElement(key, homeSrc[key])
}
const header = mergeLayoutElement('header', src.header)
const footer = mergeLayoutElement('footer', src.footer)
const usedIds = [header.variant, footer.variant, ...Object.values(home).map((item) => item.variant)]
return {
...src,
schemaId: 'layout-variants',
version: '1.0.0',
themes: mergeLayoutThemes(src.themes, usedIds),
header,
footer,
home,
}
}
export function applyKhanegiLayoutVariants(app: { layoutVariants?: unknown } | null | undefined) {
if (!app || typeof app !== 'object') return
app.layoutVariants = mergeKhanegiLayoutVariants(app.layoutVariants)
}
+14
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined, HolderOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import LayoutVariantsEditor from '@/components/LayoutVariantsEditor.vue'
import VersionPageTabs from '@/components/VersionPageTabs.vue'
import {
applyKhanegiNavSample,
@@ -14,6 +15,7 @@ import {
normalizeKhanegiUiConfigForClient,
type KhanegiNavItem,
} from '@/config/defaultKhanegiUiConfig'
import { applyKhanegiLayoutVariants } from '@/config/khanegiLayoutVariants'
import {
applyKhanegiPreset,
khanegiThemePresets,
@@ -260,6 +262,7 @@ function applyAdvancedJson() {
config.defaultSnapshot = keepSnapshot
}
applySharedNavSample()
applyKhanegiLayoutVariants(config.app)
ensureSchemes()
selectedPreset.value = resolvePresetId(config)
jsonError.value = ''
@@ -312,6 +315,8 @@ async function load() {
}
}
applySharedNavSample()
applyKhanegiLayoutVariants(config.app)
applyKhanegiLayoutVariants(config.defaultSnapshot?.app)
ensureSchemes()
if (!config.defaultSnapshot) {
config.defaultSnapshot = cloneKhanegiAppSnapshot(config)
@@ -418,6 +423,7 @@ function resetToDefault() {
config.app = JSON.parse(JSON.stringify(snap.app))
config.pages = JSON.parse(JSON.stringify(snap.pages || {}))
applySharedNavSample()
applyKhanegiLayoutVariants(config.app)
ensureSchemes()
selectedPreset.value = resolvePresetId(config)
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
@@ -740,6 +746,14 @@ onMounted(load)
</a-form-item>
</a-tab-pane>
<a-tab-pane key="layouts" tab="چیدمان">
<LayoutVariantsEditor
:layouts="config.app.layoutVariants"
:can-write="canWrite"
@change="syncAdvancedJson"
/>
</a-tab-pane>
<a-tab-pane key="json" tab="JSON پیشرفته">
<p class="page-sub" style="margin-bottom: 12px">
برای pages و جزئیات کامل می‌توانید JSON را مستقیم ویرایش کنید.