refactor(admin-ui): 统一重构抽屉编辑器布局,修复废弃API用法

为所有抽屉式编辑器组件添加统一的样式规范,实现内容区域可滚动、操作栏固定于底部的布局,适配移动端安全区域间距。替换布局设置抽屉中el-radio-button的废弃label属性为value属性以消除组件警告。重构首页配置页面的编辑器关闭逻辑,拆分状态重置与关闭操作优化代码复用。新增单元测试用例验证抽屉编辑器的布局一致性。
This commit is contained in:
duanshuwen
2026-08-27 09:31:49 +08:00
parent e74ecea6aa
commit b322c930fa
11 changed files with 186 additions and 109 deletions

View File

@@ -37,14 +37,16 @@ function toDraft(advisor?: ConciergeAdvisor): ConciergeAdvisorCreate {
</script>
<template>
<el-form class="concierge-editor" label-position="top" @submit.prevent="save">
<div class="editor-heading"><div><p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / CONCIERGE</p><h3>{{ editing ? "编辑管家顾问" : "新增管家顾问" }}</h3><p>维护顾问卡片服务详情和二维码保存后直接同步前台展示</p></div></div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-form-item label="顾问姓名" required><el-input :model-value="draft.name" :disabled="saving" maxlength="80" show-word-limit @update:model-value="update('name', $event)" /></el-form-item>
<el-form-item label="顾问职位" required><el-input :model-value="draft.role" :disabled="saving" maxlength="120" show-word-limit @update:model-value="update('role', $event)" /></el-form-item>
<div class="editor-options"><SingleMediaEditor :model-value="draft.avatar" group="concierge" label="顾问头像" :disabled="saving" @update:model-value="update('avatar', $event)" /><SingleMediaEditor :model-value="draft.qrImage" group="concierge" label="添加管家二维码" :disabled="saving" @update:model-value="update('qrImage', $event)" /></div>
<ConciergeDetailEditor :model-value="draft.details as ConciergeDetail[]" :disabled="saving" @update:model-value="update('details', $event)" />
<el-form-item label="启用状态"><el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item>
<el-form class="concierge-editor drawer-editor-form" label-position="top" @submit.prevent="save">
<div class="drawer-editor-form__content">
<div class="editor-heading"><div><p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / CONCIERGE</p><h3>{{ editing ? "编辑管家顾问" : "新增管家顾问" }}</h3><p>维护顾问卡片服务详情和二维码保存后直接同步前台展示</p></div></div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-form-item label="顾问姓名" required><el-input :model-value="draft.name" :disabled="saving" maxlength="80" show-word-limit @update:model-value="update('name', $event)" /></el-form-item>
<el-form-item label="顾问职位" required><el-input :model-value="draft.role" :disabled="saving" maxlength="120" show-word-limit @update:model-value="update('role', $event)" /></el-form-item>
<div class="editor-options"><SingleMediaEditor :model-value="draft.avatar" group="concierge" label="顾问头像" :disabled="saving" @update:model-value="update('avatar', $event)" /><SingleMediaEditor :model-value="draft.qrImage" group="concierge" label="添加管家二维码" :disabled="saving" @update:model-value="update('qrImage', $event)" /></div>
<ConciergeDetailEditor :model-value="draft.details as ConciergeDetail[]" :disabled="saving" @update:model-value="update('details', $event)" />
<el-form-item label="启用状态"><el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item>
</div>
<div class="editor-actions"><el-button :disabled="saving" @click="emit('cancel')">取消</el-button><el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建顾问" }}</el-button></div>
</el-form>
</template>

View File

@@ -45,31 +45,33 @@ function updateLines(field: "detailParagraphs", value: string) {
</script>
<template>
<el-form class="home-content-editor" label-position="top" @submit.prevent="emit('save')">
<div class="editor-heading"><div><p class="eyebrow">{{ creating ? "CREATE" : "EDIT" }} / HOME CONTENT</p><h3>{{ creating ? "新增" : "编辑" }}{{ title[kind] }}</h3><p>{{ subtitle[kind] }}</p></div></div>
<el-form class="home-content-editor drawer-editor-form" label-position="top" @submit.prevent="emit('save')">
<div class="drawer-editor-form__content">
<div class="editor-heading"><div><p class="eyebrow">{{ creating ? "CREATE" : "EDIT" }} / HOME CONTENT</p><h3>{{ creating ? "新增" : "编辑" }}{{ title[kind] }}</h3><p>{{ subtitle[kind] }}</p></div></div>
<template v-if="kind === 'recommendation'">
<el-form-item label="玩法分类" required><el-select :model-value="draft.categoryId" class="full-width" filterable placeholder="请选择玩法分类" :disabled="saving" @update:model-value="update('categoryId', $event)"><el-option v-for="category in categories" :key="category.id" :label="`${category.label}${category.routes.length} 条路线)`" :value="category.id" /></el-select></el-form-item>
</template>
<template v-if="kind === 'recommendation'">
<el-form-item label="玩法分类" required><el-select :model-value="draft.categoryId" class="full-width" filterable placeholder="请选择玩法分类" :disabled="saving" @update:model-value="update('categoryId', $event)"><el-option v-for="category in categories" :key="category.id" :label="`${category.label}${category.routes.length} 条路线)`" :value="category.id" /></el-select></el-form-item>
</template>
<template v-else-if="kind === 'team'">
<div class="editor-options"><el-form-item label="标签" required><el-input :model-value="draft.tag ?? ''" maxlength="40" :disabled="saving" @update:model-value="update('tag', $event)" /></el-form-item><el-form-item label="需求关键词" required><el-input :model-value="draft.demandKeyword ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('demandKeyword', $event)" /></el-form-item></div>
<el-form-item label="标题" required><el-input :model-value="draft.title ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('title', $event)" /></el-form-item>
<el-form-item label="卡片描述" required><el-input :model-value="draft.description ?? ''" type="textarea" :rows="3" maxlength="240" :disabled="saving" @update:model-value="update('description', $event)" /></el-form-item>
<SingleMediaEditor :model-value="draft.image ?? ''" group="home-team-building" label="团队共创图片" :disabled="saving" @update:model-value="update('image', $event)" />
<el-form-item label="详情副标题"><el-input :model-value="draft.detailSubtitle ?? ''" maxlength="160" :disabled="saving" @update:model-value="update('detailSubtitle', $event)" /></el-form-item>
<el-form-item label="详情正文(每行一段)"><el-input :model-value="(draft.detailParagraphs ?? []).join('\n')" type="textarea" :rows="5" :disabled="saving" @update:model-value="updateLines('detailParagraphs', $event)" /></el-form-item>
</template>
<template v-else-if="kind === 'team'">
<div class="editor-options"><el-form-item label="标签" required><el-input :model-value="draft.tag ?? ''" maxlength="40" :disabled="saving" @update:model-value="update('tag', $event)" /></el-form-item><el-form-item label="需求关键词" required><el-input :model-value="draft.demandKeyword ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('demandKeyword', $event)" /></el-form-item></div>
<el-form-item label="标题" required><el-input :model-value="draft.title ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('title', $event)" /></el-form-item>
<el-form-item label="卡片描述" required><el-input :model-value="draft.description ?? ''" type="textarea" :rows="3" maxlength="240" :disabled="saving" @update:model-value="update('description', $event)" /></el-form-item>
<SingleMediaEditor :model-value="draft.image ?? ''" group="home-team-building" label="团队共创图片" :disabled="saving" @update:model-value="update('image', $event)" />
<el-form-item label="详情副标题"><el-input :model-value="draft.detailSubtitle ?? ''" maxlength="160" :disabled="saving" @update:model-value="update('detailSubtitle', $event)" /></el-form-item>
<el-form-item label="详情正文(每行一段)"><el-input :model-value="(draft.detailParagraphs ?? []).join('\n')" type="textarea" :rows="5" :disabled="saving" @update:model-value="updateLines('detailParagraphs', $event)" /></el-form-item>
</template>
<template v-else>
<el-form-item label="需求关键词" required><el-input :model-value="draft.demandKeyword ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('demandKeyword', $event)" /></el-form-item>
<el-form-item label="标题" required><el-input :model-value="draft.title ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('title', $event)" /></el-form-item>
<SingleMediaEditor :model-value="draft.image ?? ''" group="home-wild-archive" label="极境视界封面" :disabled="saving" @update:model-value="update('image', $event)" />
<ImageGalleryEditor :model-value="draft.images ?? []" :disabled="saving" @update:model-value="update('images', $event)" />
</template>
<template v-else>
<el-form-item label="需求关键词" required><el-input :model-value="draft.demandKeyword ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('demandKeyword', $event)" /></el-form-item>
<el-form-item label="标题" required><el-input :model-value="draft.title ?? ''" maxlength="120" :disabled="saving" @update:model-value="update('title', $event)" /></el-form-item>
<SingleMediaEditor :model-value="draft.image ?? ''" group="home-wild-archive" label="极境视界封面" :disabled="saving" @update:model-value="update('image', $event)" />
<ImageGalleryEditor :model-value="draft.images ?? []" :disabled="saving" @update:model-value="update('images', $event)" />
</template>
<el-form-item v-if="kind !== 'recommendation'" class="editor-sort-item" label="排序值"><el-input-number :model-value="draft.sortOrder ?? 0" :min="0" :disabled="saving" controls-position="right" @update:model-value="update('sortOrder', $event ?? 0)" /></el-form-item>
<div class="home-content-options"><el-form-item label="启用状态"><el-switch :model-value="draft.isActive" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item></div>
<el-form-item v-if="kind !== 'recommendation'" class="editor-sort-item" label="排序值"><el-input-number :model-value="draft.sortOrder ?? 0" :min="0" :disabled="saving" controls-position="right" @update:model-value="update('sortOrder', $event ?? 0)" /></el-form-item>
<div class="home-content-options"><el-form-item label="启用状态"><el-switch :model-value="draft.isActive" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item></div>
</div>
<div class="editor-actions"><el-button :disabled="saving" @click="emit('cancel')">取消</el-button><el-button type="primary" native-type="submit" :loading="saving">保存{{ title[kind] }}</el-button></div>
</el-form>
</template>

View File

@@ -18,6 +18,7 @@ function reset() {
<template>
<el-drawer
:model-value="layout.settingsDrawerOpen"
class="settings-drawer"
title="布局设置"
size="320px"
append-to-body
@@ -26,15 +27,15 @@ function reset() {
<el-form label-position="top" class="settings-form">
<el-form-item label="导航模式">
<el-radio-group :model-value="layout.settings.navType" @update:model-value="update('navType', $event as NavigationType)">
<el-radio-button label="left">左侧菜单</el-radio-button>
<el-radio-button label="mix">混合菜单</el-radio-button>
<el-radio-button label="top">顶部菜单</el-radio-button>
<el-radio-button value="left">左侧菜单</el-radio-button>
<el-radio-button value="mix">混合菜单</el-radio-button>
<el-radio-button value="top">顶部菜单</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="侧栏主题">
<el-radio-group :model-value="layout.settings.sideTheme" @update:model-value="update('sideTheme', $event as SideTheme)">
<el-radio-button label="theme-dark">深色</el-radio-button>
<el-radio-button label="theme-light">浅色</el-radio-button>
<el-radio-button value="theme-dark">深色</el-radio-button>
<el-radio-button value="theme-light">浅色</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="主题色">

View File

@@ -28,48 +28,50 @@ function updateField<K extends keyof SiteItemPatch>(field: K, value: SiteItemPat
</script>
<template>
<el-form class="site-config-editor" :model="draft" label-position="top" @submit.prevent="emit('save')">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ creating ? "CREATE" : "EDIT" }} / SITE CONFIG</p>
<h3>{{ editorTitle }}</h3>
<p>{{ definition.description }}</p>
<el-form class="site-config-editor drawer-editor-form" :model="draft" label-position="top" @submit.prevent="emit('save')">
<div class="drawer-editor-form__content">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ creating ? "CREATE" : "EDIT" }} / SITE CONFIG</p>
<h3>{{ editorTitle }}</h3>
<p>{{ definition.description }}</p>
</div>
</div>
<el-form-item label="标题" required>
<el-input
:model-value="String(primaryValue)"
:disabled="saving"
maxlength="80"
show-word-limit
@update:model-value="updateField(definition.primaryField, $event)"
/>
</el-form-item>
<template v-if="module === 'heroSlides'">
<el-form-item label="副标题">
<el-input :model-value="draft.kicker ?? ''" :disabled="saving" maxlength="80" @update:model-value="updateField('kicker', $event)" />
</el-form-item>
</template>
<template v-if="module === 'vehicleOptions'">
<el-form-item label="卡片描述">
<el-input :model-value="draft.description ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="240" show-word-limit @update:model-value="updateField('description', $event)" />
</el-form-item>
</template>
<template v-if="imageModule">
<SingleMediaEditor :model-value="draft.image ?? ''" group="site-config" label="资源图片" :disabled="saving" @update:model-value="updateField('image', $event)" />
</template>
<el-form-item v-if="!definition.singleton" class="editor-sort-item" label="排序值">
<el-input-number :model-value="draft.sortOrder ?? 0" :disabled="saving" :min="0" :step="1" controls-position="right" @update:model-value="updateField('sortOrder', $event ?? 0)" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="updateField('isActive', $event)" />
</el-form-item>
</div>
<el-form-item label="标题" required>
<el-input
:model-value="String(primaryValue)"
:disabled="saving"
maxlength="80"
show-word-limit
@update:model-value="updateField(definition.primaryField, $event)"
/>
</el-form-item>
<template v-if="module === 'heroSlides'">
<el-form-item label="副标题">
<el-input :model-value="draft.kicker ?? ''" :disabled="saving" maxlength="80" @update:model-value="updateField('kicker', $event)" />
</el-form-item>
</template>
<template v-if="module === 'vehicleOptions'">
<el-form-item label="卡片描述">
<el-input :model-value="draft.description ?? ''" :disabled="saving" type="textarea" :rows="4" maxlength="240" show-word-limit @update:model-value="updateField('description', $event)" />
</el-form-item>
</template>
<template v-if="imageModule">
<SingleMediaEditor :model-value="draft.image ?? ''" group="site-config" label="资源图片" :disabled="saving" @update:model-value="updateField('image', $event)" />
</template>
<el-form-item v-if="!definition.singleton" class="editor-sort-item" label="排序值">
<el-input-number :model-value="draft.sortOrder ?? 0" :disabled="saving" :min="0" :step="1" controls-position="right" @update:model-value="updateField('sortOrder', $event ?? 0)" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch :model-value="draft.isActive ?? true" :disabled="saving" active-text="启用" inactive-text="停用" @update:model-value="updateField('isActive', $event)" />
</el-form-item>
<div class="editor-actions">
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
<el-button type="primary" native-type="submit" :loading="saving">保存配置</el-button>

View File

@@ -20,18 +20,20 @@ function submit() {
</script>
<template>
<el-form class="wanfa-editor" label-position="top" @submit.prevent="submit">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY CATEGORY</p>
<h3>{{ editing ? "编辑玩法分类" : "新增玩法分类" }}</h3>
<p>{{ editing ? "修改后会同步更新前台玩法入口。" : "创建后即可在分类下维护路线。" }}</p>
<el-form class="wanfa-editor drawer-editor-form" label-position="top" @submit.prevent="submit">
<div class="drawer-editor-form__content">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY CATEGORY</p>
<h3>{{ editing ? "编辑玩法分类" : "新增玩法分类" }}</h3>
<p>{{ editing ? "修改后会同步更新前台玩法入口。" : "创建后即可在分类下维护路线。" }}</p>
</div>
</div>
<el-form-item label="分类名称" required :error="error">
<el-input v-model="value" maxlength="40" show-word-limit :disabled="saving" placeholder="例如:亲子路线" autofocus />
</el-form-item>
<p class="field-hint">保存前会自动去除首尾空格</p>
</div>
<el-form-item label="分类名称" required :error="error">
<el-input v-model="value" maxlength="40" show-word-limit :disabled="saving" placeholder="例如:亲子路线" autofocus />
</el-form-item>
<p class="field-hint">保存前会自动去除首尾空格</p>
<div class="editor-actions">
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
<el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建分类" }}</el-button>

View File

@@ -62,25 +62,27 @@ function submit() {
</script>
<template>
<el-form class="wanfa-editor route-editor" label-position="top" @submit.prevent="submit">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY ROUTE</p>
<h3>{{ editing ? "编辑分类路线" : "新增分类路线" }}</h3>
<p>{{ editing ? `修改「${categoryLabel}」下的路线卡片和详情。` : `添加到「${categoryLabel}」,保存后会排在当前路线末尾。` }}</p>
<el-form class="wanfa-editor route-editor drawer-editor-form" label-position="top" @submit.prevent="submit">
<div class="drawer-editor-form__content">
<div class="editor-heading">
<div>
<p class="eyebrow">{{ editing ? "EDIT" : "CREATE" }} / PLAY ROUTE</p>
<h3>{{ editing ? "编辑分类路线" : "新增分类路线" }}</h3>
<p>{{ editing ? `修改「${categoryLabel}」下的路线卡片和详情。` : `添加到「${categoryLabel}」,保存后会排在当前路线末尾。` }}</p>
</div>
</div>
</div>
<el-form-item label="路线标题" required><el-input v-model="routeDraft.title" maxlength="80" :disabled="saving" placeholder="例如:亲子玩水" autofocus /></el-form-item>
<el-form-item label="副标题/目的地说明" required><el-input v-model="routeDraft.subtitle" type="textarea" :rows="3" maxlength="160" :disabled="saving" /></el-form-item>
<div class="editor-options">
<el-form-item label="路线数量" required><el-input-number v-model="routeDraft.routeCount" :disabled="saving" :min="0" :step="1" controls-position="right" /></el-form-item>
<el-form-item label="需求关键词" required><el-input v-model="routeDraft.demandKeyword" maxlength="60" :disabled="saving" placeholder="例如:亲子玩水" /></el-form-item>
</div>
<SingleMediaEditor :model-value="routeDraft.image" group="wanfa-route" label="路线封面" :disabled="saving" @update:model-value="routeDraft.image = $event" />
<el-form-item label="路线标题" required><el-input v-model="routeDraft.title" maxlength="80" :disabled="saving" placeholder="例如:亲子玩水" autofocus /></el-form-item>
<el-form-item label="副标题/目的地说明" required><el-input v-model="routeDraft.subtitle" type="textarea" :rows="3" maxlength="160" :disabled="saving" /></el-form-item>
<div class="editor-options">
<el-form-item label="路线数量" required><el-input-number v-model="routeDraft.routeCount" :disabled="saving" :min="0" :step="1" controls-position="right" /></el-form-item>
<el-form-item label="需求关键词" required><el-input v-model="routeDraft.demandKeyword" maxlength="60" :disabled="saving" placeholder="例如:亲子玩水" /></el-form-item>
</div>
<SingleMediaEditor :model-value="routeDraft.image" group="wanfa-route" label="路线封面" :disabled="saving" @update:model-value="routeDraft.image = $event" />
<DetailFields v-model="detailDraft" :advisors="advisors" :disabled="saving" />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<DetailFields v-model="detailDraft" :advisors="advisors" :disabled="saving" />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
</div>
<div class="editor-actions">
<el-button :disabled="saving" @click="emit('cancel')">取消</el-button>
<el-button type="primary" native-type="submit" :loading="saving">{{ editing ? "保存修改" : "创建路线" }}</el-button>

View File

@@ -116,6 +116,6 @@ onMounted(load);
<el-skeleton v-if="loading" :rows="7" animated />
<el-empty v-else-if="!advisors.length" description="暂无管家顾问"><el-button v-permission="'admin:concierge:read'" type="primary" @click="openCreate">创建第一位顾问</el-button></el-empty>
<el-card v-else shadow="never" class="panel-card"><div class="concierge-list"><div v-for="(advisor, index) in advisors" :key="advisor.id" class="concierge-row"><div class="concierge-summary"><el-avatar :size="48" :src="resolveAdminMediaUrl(advisor.avatar)">{{ advisor.name.slice(0, 1) }}</el-avatar><div><strong>{{ advisor.name }}</strong><span>{{ advisor.role }} · {{ advisor.details?.length ?? 0 }} 条服务详情</span></div></div><div class="concierge-actions"><el-tag :type="advisor.isActive ? 'success' : 'info'" effect="plain">{{ advisor.isActive ? "启用" : "停用" }}</el-tag><el-button v-permission="'admin:concierge:read'" link :disabled="index === 0 || Boolean(savingKey)" @click="move(advisor, -1)">上移</el-button><el-button v-permission="'admin:concierge:read'" link :disabled="index === advisors.length - 1 || Boolean(savingKey)" @click="move(advisor, 1)">下移</el-button><el-button v-permission="'admin:concierge:read'" link type="primary" @click="openEdit(advisor)">编辑</el-button><el-button v-permission="'admin:concierge:read'" link type="danger" :disabled="Boolean(savingKey)" @click="remove(advisor)">删除</el-button></div></div></div></el-card>
<el-drawer v-model="editorOpen" size="min(720px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeEditor"><ConciergeAdvisorEditor v-if="editorOpen" :advisor="editing" :editing="Boolean(editing)" :saving="savingKey === 'advisor'" @save="save" @cancel="closeEditor" /></el-drawer>
<el-drawer v-model="editorOpen" class="editor-drawer" size="min(720px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeEditor"><ConciergeAdvisorEditor v-if="editorOpen" :advisor="editing" :editing="Boolean(editing)" :saving="savingKey === 'advisor'" @save="save" @cancel="closeEditor" /></el-drawer>
</div>
</template>

View File

@@ -81,8 +81,7 @@ async function loadConfig() {
}
}
function closeEditor() {
if (saving.value) return;
function resetEditorState() {
editorOpen.value = false;
siteEditorOpen.value = false;
editingId.value = null;
@@ -90,6 +89,15 @@ function closeEditor() {
siteDraft.value = {};
}
function closeEditor() {
if (saving.value) return;
resetEditorState();
}
function closeEditorAfterSave() {
resetEditorState();
}
function startCreate() {
if (!auth.hasPermission("admin:site-config")) return;
editingId.value = null;
@@ -135,7 +143,7 @@ async function saveSiteItem() {
const next = siteItems.value[activeSiteModule.value].filter((item) => item.id !== saved.id);
siteItems.value[activeSiteModule.value] = [...next, saved].sort((left, right) => ("sortOrder" in left ? left.sortOrder : 0) - ("sortOrder" in right ? right.sortOrder : 0));
ElMessage.success(editingId.value ? "配置已保存" : "配置已新增");
closeEditor();
closeEditorAfterSave();
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "配置保存失败"); } finally { saving.value = false; }
}
@@ -149,7 +157,7 @@ async function saveHomeItem() {
const saved = await saveHomeByKind(kind);
homeItems.value[kind] = [...activeHomeItems.value.filter((item) => item.id !== saved.id), saved].sort((left, right) => left.sortOrder - right.sortOrder) as typeof activeHomeItems.value;
ElMessage.success(editingId.value ? "首页配置已保存" : "首页配置已新增");
closeEditor();
closeEditorAfterSave();
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "首页配置保存失败"); } finally { saving.value = false; }
}
@@ -248,8 +256,8 @@ onMounted(loadConfig);
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row, $index }"><el-button v-permission="'admin:site-config'" link type="primary" @click="startEdit(row)">编辑</el-button><el-button v-permission="'admin:site-config'" link :disabled="$index === 0 || reorderingId === row.id" @click="moveItem(row, -1)">上移</el-button><el-button v-permission="'admin:site-config'" link :disabled="$index === activeItems.length - 1 || reorderingId === row.id" @click="moveItem(row, 1)">下移</el-button><el-button v-permission="'admin:site-config'" link type="danger" :loading="deletingId === row.id" @click="removeItem(row)">删除</el-button></template></el-table-column>
</el-table>
</el-card>
<el-drawer v-model="siteEditorOpen" class="site-config-drawer" size="min(560px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!saving"><SiteConfigEditor v-if="siteEditorOpen" :module="activeSiteModule" :draft="siteDraft" :creating="creating" :saving="saving" @update:draft="siteDraft = $event" @save="saveSiteItem" @cancel="closeEditor" /></el-drawer>
<el-drawer v-model="editorOpen" class="site-config-drawer" size="min(560px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!saving"><HomeContentEditor v-if="editorOpen" :kind="activeHomeKind" :draft="homeDraft" :categories="categories" :creating="creating" :saving="saving" @update:draft="homeDraft = $event" @save="saveHomeItem" @cancel="closeEditor" /></el-drawer>
<el-drawer v-model="siteEditorOpen" class="site-config-drawer editor-drawer" size="min(560px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!saving"><SiteConfigEditor v-if="siteEditorOpen" :module="activeSiteModule" :draft="siteDraft" :creating="creating" :saving="saving" @update:draft="siteDraft = $event" @save="saveSiteItem" @cancel="closeEditor" /></el-drawer>
<el-drawer v-model="editorOpen" class="site-config-drawer editor-drawer" size="min(560px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!saving"><HomeContentEditor v-if="editorOpen" :kind="activeHomeKind" :draft="homeDraft" :categories="categories" :creating="creating" :saving="saving" @update:draft="homeDraft = $event" @save="saveHomeItem" @cancel="closeEditor" /></el-drawer>
</template>
</div>
</template>

View File

@@ -278,7 +278,7 @@ onMounted(loadData);
</el-card>
</div>
<el-drawer v-model="categoryDrawer" size="min(480px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeCategoryDrawer"><WanfaCategoryEditor :label="categoryEditing?.label ?? ''" :editing="Boolean(categoryEditing)" :saving="savingKey === 'category'" @save="saveCategory" @cancel="closeCategoryDrawer" /></el-drawer>
<el-drawer v-model="routeDrawer" size="min(700px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeRouteDrawer"><WanfaRouteEditor v-if="routeDrawer && selectedCategory" :category-label="selectedCategory.label" :route="routeEditing ?? undefined" :detail="selectedDetail" :advisors="advisors" :saving="savingKey === 'route'" @save="saveRoute" @cancel="closeRouteDrawer" /></el-drawer>
<el-drawer v-model="categoryDrawer" class="editor-drawer" size="min(480px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeCategoryDrawer"><WanfaCategoryEditor :label="categoryEditing?.label ?? ''" :editing="Boolean(categoryEditing)" :saving="savingKey === 'category'" @save="saveCategory" @cancel="closeCategoryDrawer" /></el-drawer>
<el-drawer v-model="routeDrawer" class="editor-drawer" size="min(700px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeRouteDrawer"><WanfaRouteEditor v-if="routeDrawer && selectedCategory" :category-label="selectedCategory.label" :route="routeEditing ?? undefined" :detail="selectedDetail" :advisors="advisors" :saving="savingKey === 'route'" @save="saveRoute" @cancel="closeRouteDrawer" /></el-drawer>
</div>
</template>

View File

@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const homePage = readSource("./HomePage.vue");
const wanfaPage = readSource("./WanfaPage.vue");
const conciergePage = readSource("./ConciergePage.vue");
const settingsDrawer = readSource("../components/layout/SettingsDrawer.vue");
const styles = readSource("../styles.css");
const editorSources = [
readSource("../components/site-config/SiteConfigEditor.vue"),
readSource("../components/home/HomeContentEditor.vue"),
readSource("../components/wanfa/WanfaCategoryEditor.vue"),
readSource("../components/wanfa/WanfaRouteEditor.vue"),
readSource("../components/concierge/ConciergeAdvisorEditor.vue"),
];
describe("side drawer editor contracts", () => {
it("keeps editor content scrollable and action buttons in a bottom action bar", () => {
for (const source of editorSources) {
expect(source).toMatch(/<el-form[^>]*class="[^"]*drawer-editor-form[^"]*"/);
expect(source).toContain('class="drawer-editor-form__content"');
expect(source).toContain('class="editor-actions"');
}
expect(styles).toMatch(/\.drawer-editor-form\s*\{[^}]*display:\s*flex/);
expect(styles).toMatch(/\.drawer-editor-form__content\s*\{[^}]*overflow-y:\s*auto/);
expect(styles).toMatch(/\.editor-actions\s*\{[^}]*margin-top:\s*0/);
});
it("closes both home configuration drawers after a successful save", () => {
expect(homePage).toMatch(/async function saveSiteItem\(\)[\s\S]*?closeEditorAfterSave\(\);/);
expect(homePage).toMatch(/async function saveHomeItem\(\)[\s\S]*?closeEditorAfterSave\(\);/);
});
it("keeps successful save closure wired for route and concierge drawers", () => {
expect(wanfaPage).toMatch(/async function saveCategory\(label: string\)[\s\S]*?categoryDrawer\.value = false;/);
expect(wanfaPage).toMatch(/async function saveRoute\([\s\S]*?details\.value = [\s\S]*?routeDrawer\.value = false;/);
expect(conciergePage).toMatch(/async function save\(input: ConciergeAdvisorCreate\)[\s\S]*?editorOpen\.value = false;/);
});
it("uses the non-deprecated value API in the settings drawer", () => {
expect(settingsDrawer).toContain('<el-radio-button value="left">');
expect(settingsDrawer).toContain('<el-radio-button value="mix">');
expect(settingsDrawer).toContain('<el-radio-button value="top">');
expect(settingsDrawer).toContain('<el-radio-button value="theme-dark">');
expect(settingsDrawer).toContain('<el-radio-button value="theme-light">');
expect(settingsDrawer).not.toContain('<el-radio-button label=');
});
});

View File

@@ -131,7 +131,13 @@ button, input { font: inherit; }
.site-image-preview { display: block; width: 100%; max-width: 260px; height: 132px; margin-top: 12px; border-radius: 9px; background: #f1f5f9; }
.editor-options { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.editor-sort-item { margin-top: 20px; }
.editor-actions { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; padding: 14px 0 4px; border-top: 1px solid var(--line); background: var(--surface); }
.editor-actions { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 10px; margin-top: 0; padding: 14px 8px calc(4px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: var(--surface); }
.editor-drawer .el-drawer__body { display: flex; min-height: 0; flex-direction: column; padding: 0; overflow: hidden; }
.drawer-editor-form { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; padding: 4px 4px 0; }
.drawer-editor-form__content { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 0 8px 20px; scrollbar-gutter: stable; }
.settings-drawer .el-drawer__body { display: flex; min-height: 0; flex-direction: column; }
.settings-drawer .settings-form { min-height: 0; flex: 1 1 auto; overflow-y: auto; }
.settings-drawer .el-drawer__footer { flex: 0 0 auto; }
.home-content-panel .panel-heading { margin-bottom: 18px; }
.home-content-panel .panel-heading > div { display: flex; align-items: baseline; gap: 10px; }
.home-content-panel .panel-heading span { color: var(--muted); font-size: 12px; font-weight: 400; }