为所有抽屉式编辑器组件添加统一的样式规范,实现内容区域可滚动、操作栏固定于底部的布局,适配移动端安全区域间距。替换布局设置抽屉中el-radio-button的废弃label属性为value属性以消除组件警告。重构首页配置页面的编辑器关闭逻辑,拆分状态重置与关闭操作优化代码复用。新增单元测试用例验证抽屉编辑器的布局一致性。
264 lines
17 KiB
Vue
264 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||
import {
|
||
createHomePlayRecommendation,
|
||
createHomeTeamBuilding,
|
||
createHomeWildArchive,
|
||
createSiteConfigItem,
|
||
deleteHomePlayRecommendation,
|
||
deleteHomeTeamBuilding,
|
||
deleteHomeWildArchive,
|
||
deleteSiteConfigItem,
|
||
getHomePlayRecommendations,
|
||
getHomeTeamBuildings,
|
||
getHomeWildArchives,
|
||
getSiteConfig,
|
||
getWanfa,
|
||
reorderHomePlayRecommendations,
|
||
reorderHomeTeamBuildings,
|
||
reorderHomeWildArchives,
|
||
reorderSiteConfigItems,
|
||
updateHomePlayRecommendation,
|
||
updateHomeTeamBuilding,
|
||
updateHomeWildArchive,
|
||
updateSiteConfigItem,
|
||
} from "@/api/client";
|
||
import HomeContentEditor, { type HomeContentDraft, type HomeContentKind } from "@/components/home/HomeContentEditor.vue";
|
||
import SiteConfigEditor from "@/components/site-config/SiteConfigEditor.vue";
|
||
import { compactSiteItemPayload, createEmptySiteItemDraft, getSiteModuleDefinition, siteItemDraft, validateSiteItemPayload } from "@/lib/site-config";
|
||
import { resolveAdminMediaUrl } from "@/lib/media-url";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import type { HomeTeamBuilding, HomeWanfaRecommendation, HomeWildArchive, SiteConfigItem, SiteItemPatch, WanfaCategory } from "@/types";
|
||
|
||
type SiteModule = "heroSlides" | "vehicleOptions";
|
||
type HomeModule = SiteModule | HomeContentKind;
|
||
|
||
const auth = useAuthStore();
|
||
const activeModule = ref<HomeModule>("heroSlides");
|
||
const siteItems = ref<Record<SiteModule, SiteConfigItem[]>>({ heroSlides: [], vehicleOptions: [] });
|
||
const homeItems = ref<Record<HomeContentKind, Array<HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive>>>({ recommendation: [], team: [], wild: [] });
|
||
const categories = ref<WanfaCategory[]>([]);
|
||
const loading = ref(true);
|
||
const loadError = ref<string | null>(null);
|
||
const editorOpen = ref(false);
|
||
const siteEditorOpen = ref(false);
|
||
const editingId = ref<string | null>(null);
|
||
const homeDraft = ref<HomeContentDraft>(emptyHomeDraft("recommendation"));
|
||
const siteDraft = ref<SiteItemPatch>({});
|
||
const saving = ref(false);
|
||
const deletingId = ref<string | null>(null);
|
||
const reorderingId = ref<string | null>(null);
|
||
|
||
const isSiteModule = computed(() => activeModule.value === "heroSlides" || activeModule.value === "vehicleOptions");
|
||
const activeSiteModule = computed(() => activeModule.value as SiteModule);
|
||
const activeHomeKind = computed(() => activeModule.value as HomeContentKind);
|
||
const activeSiteItems = computed(() => siteItems.value[activeSiteModule.value]);
|
||
const activeHomeItems = computed(() => homeItems.value[activeHomeKind.value]);
|
||
const activeItems = computed(() => isSiteModule.value ? activeSiteItems.value : activeHomeItems.value);
|
||
const activeDefinition = computed(() => getSiteModuleDefinition(activeSiteModule.value));
|
||
const creating = computed(() => editingId.value === null);
|
||
|
||
function emptyHomeDraft(kind: HomeContentKind): HomeContentDraft {
|
||
if (kind === "recommendation") return { categoryId: "", isActive: true, sortOrder: 0 };
|
||
if (kind === "team") return { tag: "", title: "", description: "", image: "", demandKeyword: "", detailSubtitle: "", detailParagraphs: [], isActive: true, sortOrder: 0 };
|
||
return { title: "", image: "", images: [], demandKeyword: "", isActive: true, sortOrder: 0 };
|
||
}
|
||
|
||
async function loadConfig() {
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const [config, wanfa, recommendations, teamBuildings, wildArchives] = await Promise.all([getSiteConfig(), getWanfa(), getHomePlayRecommendations(), getHomeTeamBuildings(), getHomeWildArchives()]);
|
||
siteItems.value = { heroSlides: config.heroSlides, vehicleOptions: config.vehicleOptions };
|
||
categories.value = wanfa;
|
||
homeItems.value = { recommendation: recommendations.items, team: teamBuildings.items, wild: wildArchives.items };
|
||
} catch (error) {
|
||
loadError.value = error instanceof Error ? error.message : "首页配置加载失败";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function resetEditorState() {
|
||
editorOpen.value = false;
|
||
siteEditorOpen.value = false;
|
||
editingId.value = null;
|
||
homeDraft.value = emptyHomeDraft(activeHomeKind.value);
|
||
siteDraft.value = {};
|
||
}
|
||
|
||
function closeEditor() {
|
||
if (saving.value) return;
|
||
resetEditorState();
|
||
}
|
||
|
||
function closeEditorAfterSave() {
|
||
resetEditorState();
|
||
}
|
||
|
||
function startCreate() {
|
||
if (!auth.hasPermission("admin:site-config")) return;
|
||
editingId.value = null;
|
||
if (isSiteModule.value) {
|
||
siteDraft.value = createEmptySiteItemDraft(activeSiteModule.value, activeSiteItems.value.length);
|
||
siteEditorOpen.value = true;
|
||
} else {
|
||
homeDraft.value = { ...emptyHomeDraft(activeHomeKind.value), sortOrder: activeHomeItems.value.length };
|
||
editorOpen.value = true;
|
||
}
|
||
}
|
||
|
||
function startEdit(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive) {
|
||
if (!auth.hasPermission("admin:site-config")) return;
|
||
editingId.value = item.id;
|
||
if (isSiteModule.value) {
|
||
siteDraft.value = siteItemDraft(item as SiteConfigItem);
|
||
siteEditorOpen.value = true;
|
||
} else {
|
||
const kind = activeHomeKind.value;
|
||
if (kind === "recommendation") {
|
||
const recommendation = item as HomeWanfaRecommendation;
|
||
homeDraft.value = { categoryId: recommendation.categoryId, isActive: recommendation.isActive, sortOrder: recommendation.sortOrder };
|
||
} else if (kind === "team") {
|
||
const team = item as HomeTeamBuilding;
|
||
homeDraft.value = { tag: team.tag, title: team.title, description: team.description, image: team.image, demandKeyword: team.demandKeyword, detailSubtitle: team.detailSubtitle ?? "", detailParagraphs: [...(team.detailParagraphs ?? [])], isActive: team.isActive, sortOrder: team.sortOrder };
|
||
} else {
|
||
const wild = item as HomeWildArchive;
|
||
homeDraft.value = { title: wild.title, image: wild.image, images: [...(wild.images ?? [])], demandKeyword: wild.demandKeyword, isActive: wild.isActive, sortOrder: wild.sortOrder };
|
||
}
|
||
editorOpen.value = true;
|
||
}
|
||
}
|
||
|
||
async function saveSiteItem() {
|
||
const payload = compactSiteItemPayload(activeSiteModule.value, siteDraft.value);
|
||
const validationError = validateSiteItemPayload(activeSiteModule.value, payload);
|
||
if (validationError) return ElMessage.warning(validationError);
|
||
if (!String(payload.title ?? "").trim()) return ElMessage.warning("请先填写标题");
|
||
saving.value = true;
|
||
try {
|
||
const saved = editingId.value ? await updateSiteConfigItem(activeSiteModule.value, editingId.value, payload) : await createSiteConfigItem(activeSiteModule.value, payload);
|
||
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 ? "配置已保存" : "配置已新增");
|
||
closeEditorAfterSave();
|
||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "配置保存失败"); } finally { saving.value = false; }
|
||
}
|
||
|
||
async function saveHomeItem() {
|
||
const kind = activeHomeKind.value;
|
||
if (kind === "recommendation" && !homeDraft.value.categoryId) return ElMessage.warning("请选择玩法分类");
|
||
if (kind !== "recommendation" && (!homeDraft.value.title?.trim() || !homeDraft.value.image?.trim() || !homeDraft.value.demandKeyword?.trim())) return ElMessage.warning("请填写标题、图片和需求关键词");
|
||
if (kind === "wild" && !homeDraft.value.images?.length) return ElMessage.warning("极境视界至少需要一张详情图片");
|
||
saving.value = true;
|
||
try {
|
||
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 ? "首页配置已保存" : "首页配置已新增");
|
||
closeEditorAfterSave();
|
||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "首页配置保存失败"); } finally { saving.value = false; }
|
||
}
|
||
|
||
async function saveHomeByKind() {
|
||
const kind = activeHomeKind.value;
|
||
if (kind === "recommendation") {
|
||
const input = { categoryId: homeDraft.value.categoryId ?? "", isActive: homeDraft.value.isActive, sortOrder: homeDraft.value.sortOrder };
|
||
return editingId.value ? updateHomePlayRecommendation(editingId.value, input) : createHomePlayRecommendation(input);
|
||
}
|
||
if (kind === "team") {
|
||
const input = { tag: homeDraft.value.tag ?? "", title: homeDraft.value.title ?? "", description: homeDraft.value.description ?? "", image: homeDraft.value.image ?? "", demandKeyword: homeDraft.value.demandKeyword ?? "", detailSubtitle: homeDraft.value.detailSubtitle || null, detailParagraphs: homeDraft.value.detailParagraphs ?? [], isActive: homeDraft.value.isActive, sortOrder: homeDraft.value.sortOrder };
|
||
return editingId.value ? updateHomeTeamBuilding(editingId.value, input) : createHomeTeamBuilding(input);
|
||
}
|
||
const input = { title: homeDraft.value.title ?? "", image: homeDraft.value.image ?? "", images: homeDraft.value.images ?? [], demandKeyword: homeDraft.value.demandKeyword ?? "", isActive: homeDraft.value.isActive, sortOrder: homeDraft.value.sortOrder };
|
||
return editingId.value ? updateHomeWildArchive(editingId.value, input) : createHomeWildArchive(input);
|
||
}
|
||
|
||
async function removeItem(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive) {
|
||
if (deletingId.value) return;
|
||
try { await ElMessageBox.confirm(`确认删除「${itemTitle(item)}」吗?删除后会同步影响首页展示。`, "删除首页配置", { type: "warning" }); } catch { return; }
|
||
deletingId.value = item.id;
|
||
try {
|
||
if (isSiteModule.value) await deleteSiteConfigItem(activeSiteModule.value, item.id);
|
||
else if (activeHomeKind.value === "recommendation") await deleteHomePlayRecommendation(item.id);
|
||
else if (activeHomeKind.value === "team") await deleteHomeTeamBuilding(item.id);
|
||
else await deleteHomeWildArchive(item.id);
|
||
if (isSiteModule.value) siteItems.value[activeSiteModule.value] = activeSiteItems.value.filter((candidate) => candidate.id !== item.id);
|
||
else homeItems.value[activeHomeKind.value] = activeHomeItems.value.filter((candidate) => candidate.id !== item.id) as typeof activeHomeItems.value;
|
||
ElMessage.success("配置已删除");
|
||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "配置删除失败"); } finally { deletingId.value = null; }
|
||
}
|
||
|
||
async function moveItem(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive, direction: -1 | 1) {
|
||
const items = activeItems.value;
|
||
const index = items.findIndex((candidate) => candidate.id === item.id);
|
||
const nextIndex = index + direction;
|
||
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || reorderingId.value) return;
|
||
const ids = items.map((candidate) => candidate.id);
|
||
[ids[index], ids[nextIndex]] = [ids[nextIndex], ids[index]];
|
||
reorderingId.value = item.id;
|
||
try {
|
||
const result = isSiteModule.value ? await reorderSiteConfigItems(activeSiteModule.value, ids) : await reorderHomeByKind(activeHomeKind.value, ids);
|
||
if (isSiteModule.value) siteItems.value[activeSiteModule.value] = result.items as SiteConfigItem[];
|
||
else homeItems.value[activeHomeKind.value] = result.items as typeof activeHomeItems.value;
|
||
ElMessage.success("展示顺序已更新");
|
||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "排序更新失败"); } finally { reorderingId.value = null; }
|
||
}
|
||
|
||
function reorderHomeByKind(kind: HomeContentKind, ids: string[]) {
|
||
if (kind === "recommendation") return reorderHomePlayRecommendations(ids);
|
||
if (kind === "team") return reorderHomeTeamBuildings(ids);
|
||
return reorderHomeWildArchives(ids);
|
||
}
|
||
|
||
function itemTitle(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive) {
|
||
if ("categoryLabel" in item) return item.categoryLabel;
|
||
if ("tag" in item) return item.title;
|
||
return item.title;
|
||
}
|
||
|
||
function itemDescription(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive) {
|
||
if ("categoryLabel" in item) return `${item.routeCount} 条路线`;
|
||
if ("tag" in item) return item.description;
|
||
if ("demandKeyword" in item) return item.demandKeyword;
|
||
return "";
|
||
}
|
||
|
||
function itemImage(item: SiteConfigItem | HomeWanfaRecommendation | HomeTeamBuilding | HomeWildArchive) {
|
||
return "image" in item ? item.image : null;
|
||
}
|
||
|
||
onMounted(loadConfig);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-stack">
|
||
<div class="page-intro site-config-intro"><div><p class="eyebrow">CONTENT / HOME</p><h2>首页配置</h2><p>统一维护顶部轮播、玩法推荐、万趣用车、团队共创和极境视界;保存后直接同步首页展示。</p></div></div>
|
||
<el-skeleton v-if="loading" :rows="10" animated />
|
||
<el-result v-else-if="loadError" title="首页配置加载失败" :sub-title="loadError"><template #icon><CircleCloseFilled /></template><template #extra><el-button type="primary" @click="loadConfig">重试</el-button></template></el-result>
|
||
<template v-else>
|
||
<el-tabs v-model="activeModule" class="site-config-tabs" @tab-change="closeEditor">
|
||
<el-tab-pane name="heroSlides" label="顶部轮播" />
|
||
<el-tab-pane name="recommendation" label="玩法推荐" />
|
||
<el-tab-pane name="vehicleOptions" label="万趣用车" />
|
||
<el-tab-pane name="team" label="团队共创" />
|
||
<el-tab-pane name="wild" label="极境视界" />
|
||
</el-tabs>
|
||
<el-card shadow="never" class="panel-card home-content-panel">
|
||
<div class="panel-heading"><div><strong>{{ isSiteModule ? activeDefinition.label : ({ recommendation: "玩法推荐", team: "团队共创", wild: "极境视界" } as Record<HomeContentKind, string>)[activeHomeKind] }}</strong><span>{{ activeItems.length }} 条配置</span></div><el-button v-permission="'admin:site-config'" type="primary" plain @click="startCreate">新增配置</el-button></div>
|
||
<el-empty v-if="!activeItems.length" description="暂无配置记录"><el-button v-permission="'admin:site-config'" type="primary" @click="startCreate">创建第一条配置</el-button></el-empty>
|
||
<el-table v-else :data="activeItems" stripe>
|
||
<el-table-column label="内容" min-width="300"><template #default="{ row }"><div class="site-item-content"><el-image v-if="itemImage(row)" class="site-item-thumb" :src="resolveAdminMediaUrl(itemImage(row))" fit="cover" /><div><strong>{{ itemTitle(row) }}</strong><small>{{ itemDescription(row) }}</small></div></div></template></el-table-column>
|
||
<el-table-column v-if="activeModule === 'recommendation'" prop="routeCount" label="路线数" width="90" />
|
||
<el-table-column v-if="activeModule !== 'recommendation'" prop="sortOrder" label="排序" width="80" />
|
||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用中" : "已停用" }}</el-tag></template></el-table-column>
|
||
<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 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>
|