feat(admin): 新增全套运营管理功能,修复核心路由bug及后端seed问题

修复Vue Admin布局中RouterView未绑定唯一key的问题,解决RBAC系统资源页面切换后内容不刷新的ISSUE-001故障,新增routeViewKey工具函数区分路由实例
完善路由配置,新增admin空路径路由,修复路由命名空路径告警
新增媒体管理、运维发布/重置页面,补全全站路由注册与动态路由过滤逻辑
新增玩法、线索、管家、系统资源管理的全套工具函数、业务组件及单元测试用例
修复后端app/seed.py中媒体资源重复检测未考虑会话内pending资源的bug,新增对应测试验证修复逻辑
修正alembic迁移版本0015的依赖关系,删除废弃的旧迁移文件
优化后端系统接口的字典序列化逻辑,简化重复代码
添加QA验收报告与相关测试截图,完善项目测试覆盖
This commit is contained in:
duanshuwen committed 2026-08-26 18:48:48 +08:00
1 parent fa6fd2a8eb
commit efab6d1398
73 files changed
+3963 -788

No files matched your search

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { DetailCreate, ConciergeAdvisor } from "@/types";
import ImageGalleryEditor from "@/components/wanfa/ImageGalleryEditor.vue";
const props = defineProps<{ modelValue: DetailCreate; advisors: ConciergeAdvisor[]; disabled: boolean }>();
const emit = defineEmits<{ (event: "update:modelValue", value: DetailCreate): void }>();
const listFields = [
["highlights", "行程亮点", "每行填写一项亮点"],
["included", "费用包含", "每行填写一项包含内容"],
["excluded", "费用不含", "每行填写一项不包含内容"],
["notes", "注意事项", "每行填写一项注意事项"],
] as const;
function update<K extends keyof DetailCreate>(field: K, value: DetailCreate[K]) {
emit("update:modelValue", { ...props.modelValue, [field]: value });
}
function updateLines(field: "highlights" | "included" | "excluded" | "notes" | "gallery", value: string) {
update(field, value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean));
}
</script>
<template>
<section class="detail-fields">
<div class="detail-fields-heading">
<strong>路线详情</strong>
<span>详情通过独立接口保存,数组字段按每行一项提交。</span>
</div>
<el-form-item label="详情眉标" required><el-input :model-value="modelValue.eyebrow" maxlength="80" :disabled="disabled" @update:model-value="update('eyebrow', $event)" /></el-form-item>
<el-form-item label="出行时长" required><el-input :model-value="modelValue.duration" maxlength="80" :disabled="disabled" placeholder="例如:5天4晚" @update:model-value="update('duration', $event)" /></el-form-item>
<el-form-item label="详情标题" required><el-input :model-value="modelValue.title" maxlength="160" :disabled="disabled" @update:model-value="update('title', $event)" /></el-form-item>
<el-form-item label="详情副标题" required><el-input :model-value="modelValue.subtitle" type="textarea" :rows="2" maxlength="240" :disabled="disabled" @update:model-value="update('subtitle', $event)" /></el-form-item>
<el-form-item label="详情介绍" required><el-input :model-value="modelValue.intro" type="textarea" :rows="5" maxlength="4000" :disabled="disabled" @update:model-value="update('intro', $event)" /></el-form-item>
<el-form-item v-for="[field, label, placeholder] in listFields" :key="field" :label="label">
<el-input :model-value="modelValue[field].join('\n')" type="textarea" :rows="4" :disabled="disabled" :placeholder="placeholder" @update:model-value="updateLines(field, $event)" />
</el-form-item>
<el-form-item label="详情图片 URL(每行一张)">
<el-input :model-value="modelValue.gallery.join('\n')" type="textarea" :rows="4" :disabled="disabled" placeholder="https://..." @update:model-value="updateLines('gallery', $event)" />
<p class="field-hint">可直接填写 HTTP(S) 地址,也可以使用下方上传入口。</p>
</el-form-item>
<ImageGalleryEditor :model-value="modelValue.gallery" :disabled="disabled" @update:model-value="update('gallery', $event)" />
<el-form-item label="联系管家">
<el-select :model-value="modelValue.conciergeAdvisorId ?? '__none__'" :disabled="disabled" placeholder="不配置管家" @update:model-value="update('conciergeAdvisorId', $event === '__none__' ? null : $event)">
<el-option value="__none__" label="不配置管家" />
<el-option v-for="advisor in advisors" :key="advisor.id" :value="advisor.id" :label="`${advisor.name} · ${advisor.role}${advisor.isActive ? '' : '(已停用)'}`" />
</el-select>
</el-form-item>
<el-form-item label="详情状态"><el-switch :model-value="modelValue.isActive" :disabled="disabled" active-text="启用" inactive-text="停用" @update:model-value="update('isActive', $event)" /></el-form-item>
</section>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { uploadMediaAsset } from "@/api/client";
const props = defineProps<{ modelValue: string[]; disabled: boolean }>();
const emit = defineEmits<{ (event: "update:modelValue", value: string[]): void }>();
const input = ref<HTMLInputElement | null>(null);
const uploading = ref(false);
const error = ref("");
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
function openPicker() { input.value?.click(); }
async function uploadFiles(event: Event) {
const target = event.target as HTMLInputElement;
const files = Array.from(target.files ?? []);
if (!files.length) return;
error.value = "";
uploading.value = true;
try {
let next = [...props.modelValue];
for (const file of files) {
if (!allowedTypes.has(file.type)) throw new Error("仅支持 JPG、PNG、WEBP 或 GIF 图片");
if (file.size > 5 * 1024 * 1024) throw new Error("单张图片不能超过 5MB");
const asset = await uploadMediaAsset(file, "wanfa-detail");
next = [...next, asset.url];
emit("update:modelValue", next);
}
ElMessage.success(`已上传 ${files.length} 张详情图片`);
} catch (uploadError) {
error.value = uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试";
} finally {
uploading.value = false;
target.value = "";
}
}
function remove(index: number) { emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index)); }
function move(index: number, direction: -1 | 1) {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= props.modelValue.length) return;
const next = [...props.modelValue];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
emit("update:modelValue", next);
}
</script>
<template>
<div class="gallery-editor">
<div class="gallery-editor-heading"><div><strong>详情图片</strong><span>上传后按当前顺序保存到详情记录。</span></div><el-button type="primary" plain :disabled="disabled || uploading" :loading="uploading" @click="openPicker">上传图片</el-button></div>
<input ref="input" class="media-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple :disabled="disabled || uploading" @change="uploadFiles" />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-empty v-if="!modelValue.length" :image-size="56" description="暂无详情图片" />
<div v-else class="gallery-grid">
<div v-for="(image, index) in modelValue" :key="`${image}-${index}`" class="gallery-item">
<el-image :src="image" fit="cover" :preview-src-list="modelValue" />
<small>{{ index + 1 }}. {{ image }}</small>
<div class="gallery-actions"><el-button link size="small" :disabled="disabled || uploading || index === 0" @click="move(index, -1)">上移</el-button><el-button link size="small" :disabled="disabled || uploading || index === modelValue.length - 1" @click="move(index, 1)">下移</el-button><el-button link size="small" type="danger" :disabled="disabled || uploading" @click="remove(index)">移除</el-button></div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{ label: string; editing: boolean; saving: boolean }>();
const emit = defineEmits<{ (event: "save", label: string): void; (event: "cancel"): void }>();
const value = ref(props.label);
const error = ref("");
watch(() => props.label, (next) => { value.value = next; error.value = ""; });
function submit() {
const next = value.value.trim();
if (!next) {
error.value = "请填写分类名称";
return;
}
error.value = "";
emit("save", next);
}
</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>
</div>
<el-button text :disabled="saving" @click="emit('cancel')">取消</el-button>
</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>
</div>
</el-form>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { compactDetail, compactWanfaRoute, createEmptyDetail, createEmptyWanfaRoute, validateWanfaWrite } from "@/lib/wanfa";
import type { ConciergeAdvisor, DetailCreate, DetailRecord, WanfaRoute, WanfaRouteCreate } from "@/types";
import DetailFields from "@/components/wanfa/DetailFields.vue";
const props = defineProps<{
categoryLabel: string;
route?: WanfaRoute;
detail?: DetailRecord;
advisors: ConciergeAdvisor[];
saving: boolean;
}>();
const emit = defineEmits<{
(event: "save", value: { route: WanfaRouteCreate; detail: DetailCreate }): void;
(event: "cancel"): void;
}>();
const routeDraft = ref<WanfaRouteCreate>(createEmptyWanfaRoute());
const detailDraft = ref<DetailCreate>(createEmptyDetail());
const error = ref("");
const editing = ref(Boolean(props.route));
watch(() => [props.route?.id, props.detail?.id], () => {
editing.value = Boolean(props.route);
routeDraft.value = props.route
? { title: props.route.title, subtitle: props.route.subtitle, image: props.route.image, routeCount: props.route.routeCount, demandKeyword: props.route.demandKeyword }
: createEmptyWanfaRoute();
detailDraft.value = props.detail
? {
key: props.detail.key,
eyebrow: props.detail.eyebrow,
duration: props.detail.duration,
title: props.detail.title,
subtitle: props.detail.subtitle,
intro: props.detail.intro,
highlights: [...props.detail.highlights],
included: [...props.detail.included],
excluded: [...props.detail.excluded],
notes: [...props.detail.notes],
gallery: [...props.detail.gallery],
conciergeAdvisorId: props.detail.conciergeAdvisorId,
isActive: props.detail.isActive,
sortOrder: props.detail.sortOrder,
}
: createEmptyDetail();
error.value = "";
}, { immediate: true });
function submit() {
const route = compactWanfaRoute(routeDraft.value);
const detail = compactDetail(detailDraft.value);
const validationError = validateWanfaWrite(route, detail);
if (validationError) {
error.value = validationError;
return;
}
error.value = "";
emit("save", { route, detail });
}
</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>
</div>
<el-button text :disabled="saving" @click="emit('cancel')">取消</el-button>
</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>
<el-form-item label="路线封面 URL" required>
<el-input v-model="routeDraft.image" :disabled="saving" placeholder="https://..." />
<el-image v-if="routeDraft.image" class="site-image-preview" :src="routeDraft.image" fit="cover" :preview-src-list="[routeDraft.image]" />
</el-form-item>
<DetailFields v-model="detailDraft" :advisors="advisors" :disabled="saving" />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<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>