修复Vue Admin布局中RouterView未绑定唯一key的问题,解决RBAC系统资源页面切换后内容不刷新的ISSUE-001故障,新增routeViewKey工具函数区分路由实例 完善路由配置,新增admin空路径路由,修复路由命名空路径告警 新增媒体管理、运维发布/重置页面,补全全站路由注册与动态路由过滤逻辑 新增玩法、线索、管家、系统资源管理的全套工具函数、业务组件及单元测试用例 修复后端app/seed.py中媒体资源重复检测未考虑会话内pending资源的bug,新增对应测试验证修复逻辑 修正alembic迁移版本0015的依赖关系,删除废弃的旧迁移文件 优化后端系统接口的字典序列化逻辑,简化重复代码 添加QA验收报告与相关测试截图,完善项目测试覆盖
271 lines
11 KiB
Vue
271 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import {
|
||
createSiteConfigItem,
|
||
deleteSiteConfigItem,
|
||
getSiteConfig,
|
||
reorderSiteConfigItems,
|
||
updateSiteConfigItem,
|
||
} from "@/api/client";
|
||
import SiteConfigEditor from "@/components/site-config/SiteConfigEditor.vue";
|
||
import {
|
||
compactSiteItemPayload,
|
||
createEmptySiteItemDraft,
|
||
getSiteModuleDefinition,
|
||
siteItemDraft,
|
||
siteModuleDefinitions,
|
||
validateSiteItemPayload,
|
||
} from "@/lib/site-config";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import type { SiteConfigItem, SiteItemPatch, SiteModule } from "@/types";
|
||
|
||
const auth = useAuthStore();
|
||
const activeModule = ref<SiteModule>("heroSlides");
|
||
const moduleItems = ref<Record<SiteModule, SiteConfigItem[]>>({
|
||
heroSlides: [],
|
||
destinationHero: [],
|
||
demandHero: [],
|
||
demandFeatureCards: [],
|
||
demandForm: [],
|
||
vehicleOptions: [],
|
||
vehicleService: [],
|
||
});
|
||
const loading = ref(true);
|
||
const loadError = ref<string | null>(null);
|
||
const editorOpen = ref(false);
|
||
const editingId = ref<string | null>(null);
|
||
const draft = ref<SiteItemPatch>({});
|
||
const saving = ref(false);
|
||
const deletingId = ref<string | null>(null);
|
||
const reorderingId = ref<string | null>(null);
|
||
|
||
const activeDefinition = computed(() => getSiteModuleDefinition(activeModule.value));
|
||
const activeItems = computed(() => moduleItems.value[activeModule.value]);
|
||
const isCreating = computed(() => editingId.value === null);
|
||
const canCreate = computed(() => activeDefinition.value.singleton ? activeItems.value.length === 0 : true);
|
||
|
||
async function loadConfig() {
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const config = await getSiteConfig();
|
||
moduleItems.value = {
|
||
heroSlides: config.heroSlides,
|
||
destinationHero: config.destinationHero,
|
||
demandHero: config.demandHero,
|
||
demandFeatureCards: config.demandFeatureCards,
|
||
demandForm: config.demandForm,
|
||
vehicleOptions: config.vehicleOptions,
|
||
vehicleService: config.vehicleService,
|
||
};
|
||
} catch (error) {
|
||
loadError.value = error instanceof Error ? error.message : "首页配置加载失败";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function startCreate() {
|
||
if (!auth.hasPermission("admin:site-config") || !canCreate.value) return;
|
||
editingId.value = null;
|
||
draft.value = createEmptySiteItemDraft(activeModule.value, activeItems.value.length);
|
||
editorOpen.value = true;
|
||
}
|
||
|
||
function startEdit(item: SiteConfigItem) {
|
||
if (!auth.hasPermission("admin:site-config")) return;
|
||
editingId.value = item.id;
|
||
draft.value = siteItemDraft(item);
|
||
editorOpen.value = true;
|
||
}
|
||
|
||
function closeEditor() {
|
||
if (saving.value) return;
|
||
editorOpen.value = false;
|
||
editingId.value = null;
|
||
draft.value = {};
|
||
}
|
||
|
||
async function saveItem() {
|
||
if (saving.value || !auth.hasPermission("admin:site-config")) return;
|
||
const payload = compactSiteItemPayload(activeModule.value, draft.value);
|
||
const validationError = validateSiteItemPayload(activeModule.value, payload);
|
||
if (validationError) {
|
||
ElMessage.warning(validationError);
|
||
return;
|
||
}
|
||
const primaryValue = activeDefinition.value.primaryField === "introTitle" ? payload.introTitle : payload.title;
|
||
if (!String(primaryValue ?? "").trim()) {
|
||
ElMessage.warning(`请先填写${activeDefinition.value.primaryField === "introTitle" ? "服务标题" : "标题"}`);
|
||
return;
|
||
}
|
||
|
||
saving.value = true;
|
||
try {
|
||
const saved = editingId.value
|
||
? await updateSiteConfigItem(activeModule.value, editingId.value, payload)
|
||
: await createSiteConfigItem(activeModule.value, payload);
|
||
replaceItem(saved);
|
||
ElMessage.success(editingId.value ? "配置已保存" : "配置已新增");
|
||
closeEditor();
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeItem(item: SiteConfigItem) {
|
||
if (deletingId.value || !auth.hasPermission("admin:site-config")) return;
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除「${itemTitle(item)}」吗?删除后会同步影响前台展示。`, "删除站点配置", { type: "warning" });
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
deletingId.value = item.id;
|
||
try {
|
||
await deleteSiteConfigItem(activeModule.value, item.id);
|
||
moduleItems.value[activeModule.value] = activeItems.value.filter((candidate) => candidate.id !== item.id);
|
||
if (editingId.value === item.id) closeEditor();
|
||
ElMessage.success("配置已删除");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "配置删除失败");
|
||
} finally {
|
||
deletingId.value = null;
|
||
}
|
||
}
|
||
|
||
async function moveItem(item: SiteConfigItem, direction: -1 | 1) {
|
||
if (reorderingId.value || !activeDefinition.value.ordered || !auth.hasPermission("admin:site-config")) return;
|
||
const items = activeItems.value;
|
||
const currentIndex = items.findIndex((candidate) => candidate.id === item.id);
|
||
const nextIndex = currentIndex + direction;
|
||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= items.length) return;
|
||
|
||
const nextIds = items.map((candidate) => candidate.id);
|
||
[nextIds[currentIndex], nextIds[nextIndex]] = [nextIds[nextIndex], nextIds[currentIndex]];
|
||
reorderingId.value = item.id;
|
||
try {
|
||
const result = await reorderSiteConfigItems(activeModule.value, nextIds);
|
||
moduleItems.value[activeModule.value] = result.items;
|
||
ElMessage.success("展示顺序已更新");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "排序更新失败");
|
||
} finally {
|
||
reorderingId.value = null;
|
||
}
|
||
}
|
||
|
||
function replaceItem(saved: SiteConfigItem) {
|
||
const items = moduleItems.value[activeModule.value];
|
||
const index = items.findIndex((item) => item.id === saved.id);
|
||
moduleItems.value[activeModule.value] = index < 0
|
||
? [...items, saved].sort(sortByOrder)
|
||
: items.map((item) => item.id === saved.id ? saved : item).sort(sortByOrder);
|
||
}
|
||
|
||
function sortByOrder(left: SiteConfigItem, right: SiteConfigItem) {
|
||
return ("sortOrder" in left ? left.sortOrder : 0) - ("sortOrder" in right ? right.sortOrder : 0);
|
||
}
|
||
|
||
function itemTitle(item: SiteConfigItem) {
|
||
if ("introTitle" in item) return item.introTitle;
|
||
if ("submitLabel" in item) return item.submitLabel;
|
||
return item.title;
|
||
}
|
||
|
||
function itemDescription(item: SiteConfigItem) {
|
||
if ("introTitle" in item) return item.intro;
|
||
if ("submitLabel" in item) return item.chips.join("、") || "需求表单字段配置";
|
||
return "description" in item ? item.description : item.kicker;
|
||
}
|
||
|
||
function itemImage(item: SiteConfigItem) {
|
||
return "image" in item ? item.image : null;
|
||
}
|
||
|
||
function itemStatus(item: SiteConfigItem) {
|
||
return item.isActive ? "启用中" : "已停用";
|
||
}
|
||
|
||
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 class="page-intro-actions"><el-button v-permission="'admin:media:read'" plain @click="$router.push('/media')">媒体素材</el-button><el-button v-permission="'admin:site-config'" plain @click="$router.push('/tools')">发布与重置</el-button><el-button v-permission="'admin:site-config'" type="primary" :disabled="!canCreate" @click="startCreate">新增{{ activeDefinition.label }}</el-button></div>
|
||
</div>
|
||
|
||
<el-alert v-if="!loading && !loadError" title="站点配置模块均已接入统一写入入口;玩法路线与详情内容在“玩法与路线”页面单独维护。" type="info" :closable="false" show-icon />
|
||
<el-skeleton v-if="loading" :rows="10" animated />
|
||
<el-result v-else-if="loadError" icon="error" title="首页配置加载失败" :sub-title="loadError">
|
||
<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 v-for="definition in siteModuleDefinitions" :key="definition.id" :name="definition.id" :label="definition.label">
|
||
<div class="site-config-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<strong>{{ definition.label }}</strong>
|
||
<span>{{ definition.description }}</span>
|
||
</div>
|
||
<el-button v-permission="'admin:site-config'" plain type="primary" :disabled="definition.singleton && activeItems.length > 0" @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="260">
|
||
<template #default="{ row }">
|
||
<div class="site-item-content">
|
||
<el-image v-if="itemImage(row)" class="site-item-thumb" :src="itemImage(row) ?? undefined" fit="cover" />
|
||
<div>
|
||
<strong>{{ itemTitle(row) || "未命名配置" }}</strong>
|
||
<small>{{ itemDescription(row) || "暂无补充说明" }}</small>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ itemStatus(row) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column v-if="definition.ordered" prop="sortOrder" label="排序" width="80" />
|
||
<el-table-column label="操作" width="260" fixed="right">
|
||
<template #default="{ row, $index }">
|
||
<el-button v-permission="'admin:site-config'" link type="primary" @click="startEdit(row)">编辑</el-button>
|
||
<template v-if="definition.ordered"><el-button v-permission="'admin:site-config'" link :disabled="$index === 0 || reorderingId === row.id" :loading="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></template>
|
||
<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>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
<el-drawer v-model="editorOpen" class="site-config-drawer" size="min(560px, 100%)" :with-header="false" :close-on-press-escape="!saving" :show-close="false">
|
||
<SiteConfigEditor
|
||
v-if="editorOpen"
|
||
:module="activeModule"
|
||
:draft="draft"
|
||
:creating="isCreating"
|
||
:saving="saving"
|
||
@update:draft="draft = $event"
|
||
@save="saveItem"
|
||
@cancel="closeEditor"
|
||
/>
|
||
</el-drawer>
|
||
</template>
|
||
</div>
|
||
</template>
|