Files
WonderQ-Project/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue
T
duanshuwen b322c930fa refactor(admin-ui): 统一重构抽屉编辑器布局,修复废弃API用法
为所有抽屉式编辑器组件添加统一的样式规范,实现内容区域可滚动、操作栏固定于底部的布局,适配移动端安全区域间距。替换布局设置抽屉中el-radio-button的废弃label属性为value属性以消除组件警告。重构首页配置页面的编辑器关闭逻辑,拆分状态重置与关闭操作优化代码复用。新增单元测试用例验证抽屉编辑器的布局一致性。
2026-08-27 09:31:49 +08:00

264 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>