added main page config
This commit is contained in:
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 را مستقیم ویرایش کنید.
|
||||
|
||||
Reference in New Issue
Block a user