feat(admin, miniapp): 优化数据库迁移逻辑与小程序图片资源处理

- 为后端Admin新增migration_compat工具,支持离线迁移时的基线资源检查,重构全部alembic迁移脚本适配离线模式,避免重复创建已存在的数据库表和字段
- 完善初始数据库迁移脚本,添加默认部门种子数据,新增ConciergeAdvisor详情字段从JSON到JSONB的类型转换迁移
- 小程序端新增图片资源解析工具resolveNetworkImage,实现本地静态资源到CDN的自动映射,添加图片加载容错降级机制
- 完善HeroSlide类型定义,新增actionLabel字段,优化首页轮播组件的文本渲染与错误处理逻辑
- 新增对应测试用例验证迁移路径正确性与前端数据处理逻辑
- 新增临时 brainstorm UI对比草稿与开发临时状态文件
This commit is contained in:
duanshuwen
2026-08-27 11:37:03 +08:00
parent 96bcc7c032
commit 4e04feda5b
27 changed files with 2695 additions and 377 deletions

View File

@@ -1,4 +1,4 @@
const remoteGuizhouAssets: Record<string, string> = {
export const remoteGuizhouAssets: Record<string, string> = {
"jiaxiu-tower":
"https://dimg04.c-ctrip.com/images/1me6m12000rntioi4FFB2.jpg?proc=source%2Ftripcommunity",
"xijiang-miao-village":
@@ -9,8 +9,20 @@ const remoteGuizhouAssets: Record<string, string> = {
"https://news.cgtn.com/news/2023-07-02/Picturesque-landscapes-draw-tourists-to-Guizhou--1l6RJMNVStq/img/f0d32df3426e4b70bf7a8b00a3404ef9/f0d32df3426e4b70bf7a8b00a3404ef9.jpeg",
};
export function resolveNetworkImage(image: string | null | undefined, fallbackImage: string) {
const normalized = typeof image === "string" ? image.trim() : "";
if (!normalized) return fallbackImage;
const localAssetMatch = normalized.match(/^\/?assets\/guizhou\/([^/?#]+?)(?:\.[^/?#]+)?$/i);
if (localAssetMatch) {
return remoteGuizhouAssets[localAssetMatch[1]] ?? fallbackImage;
}
return normalized.startsWith("/assets/") ? fallbackImage : normalized;
}
const guizhouAsset = (name: string) =>
remoteGuizhouAssets[name] ?? `/assets/guizhou/${name}.jpg`;
remoteGuizhouAssets[name] ?? remoteGuizhouAssets["huangguoshu-waterfall"];
export const heroSlides = [
{

View File

@@ -1,5 +1,6 @@
import {
heroSlides as fallbackHeroSlides,
resolveNetworkImage,
vehicleOptions as fallbackVehicleOptions,
} from "@/content";
import type {
@@ -26,7 +27,7 @@ export function normalizeHomeCards<T extends { id?: string; title: string; image
id: item.id,
title: item.title.trim(),
description: item.description?.trim() || fallbackItem.description,
image: item.image?.trim() || fallbackItem.image,
image: resolveNetworkImage(item.image, fallbackItem.image),
} as T;
});
}
@@ -34,11 +35,16 @@ export function normalizeHomeCards<T extends { id?: string; title: string; image
export function normalizeSiteConfig(siteConfig: PublicSiteConfig, fallback: AppData): AppData {
return {
heroSlides: siteConfig.heroSlides?.length
? siteConfig.heroSlides.map((slide) => ({
? siteConfig.heroSlides.map((slide, index) => ({
...slide,
image: resolveNetworkImage(
slide.image,
fallback.heroSlides[index]?.image ?? fallback.heroSlides[0]?.image ?? "",
),
id: slide.id,
image: slide.image,
kicker: slide.kicker,
title: slide.title,
actionLabel: slide.actionLabel,
targetType: slide.targetType,
targetValue: slide.targetValue,
}))

View File

@@ -28,6 +28,7 @@ export type PublicHeroSlide = {
id: string;
title: string;
kicker?: string | null;
actionLabel?: string | null;
image: string;
targetType?: string | null;
targetValue?: string | null;
@@ -181,7 +182,16 @@ export type VehicleLeadPayload = Omit<PublicLeadPayload, "leadType" | "vehicleDe
vehicleDemand: VehicleDemandPayload;
};
export type HeroSlide = { id?: string; image: string; kicker?: string | null; title: string; action?: string | null; targetType?: string | null; targetValue?: string | null };
export type HeroSlide = {
id?: string;
image: string;
kicker?: string | null;
title: string;
actionLabel?: string | null;
action?: string | null;
targetType?: string | null;
targetValue?: string | null;
};
export type VehicleOption = { id?: string; title: string; image: string; description: string };
export type AppData = {

View File

@@ -8,19 +8,40 @@
:current="heroIndex"
@change="onSlideChange"
>
<swiper-item v-for="slide in slides" :key="slide.id || slide.image">
<swiper-item v-for="(slide, index) in props.slides" :key="slide.id || slide.image">
<view
class="relative m-0 h-full w-full overflow-hidden rounded-none p-0 text-left"
@click="$emit('select', slide.title)"
>
<image class="absolute inset-0 h-full w-full" :src="slide.image" mode="aspectFill" />
<image
class="absolute inset-0 h-full w-full"
:src="getSlideImage(slide, index)"
mode="aspectFill"
@error="onImageError(slide, index)"
/>
<view class="hero-scrim absolute inset-0 flex items-end px-6 pb-[68px]">
<view class="min-w-0 text-white">
<text v-if="slide.kicker" class="block text-[12px] font-medium leading-5 tracking-[0.12em] text-white/80">
{{ slide.kicker }}
</text>
<text v-if="slide.title" class="mt-1 block text-[25px] font-semibold leading-8 tracking-[0.04em]">
{{ slide.title }}
</text>
<text
v-if="slide.actionLabel || slide.action"
class="mt-3 inline-flex rounded-[8px] border border-white/55 px-3 py-1 text-[12px] leading-5 text-white/95"
>
{{ slide.actionLabel || slide.action }}
</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
<view class="absolute bottom-8 left-0 right-0 flex justify-center gap-2">
<text
v-for="(_, index) in slides"
v-for="(_, index) in props.slides"
:key="index"
class="h-1.5 rounded-full transition-all"
:class="heroIndex === index ? 'w-6 bg-white' : 'w-1.5 bg-white/50'"
@@ -31,17 +52,43 @@
<script setup lang="ts">
import { ref } from "vue";
import { fallbackData } from "@/lib/data";
import { resolveNetworkImage } from "@/content";
import type { HeroSlide } from "@/lib/types";
defineProps<{
const props = withDefaults(defineProps<{
slides: HeroSlide[];
}>();
fallbackSlides?: HeroSlide[];
}>(), {
fallbackSlides: () => fallbackData.heroSlides,
});
defineEmits<{
select: [title: string];
}>();
const heroIndex = ref(0);
const failedSlideImages = ref<Record<string, boolean>>({});
function getSlideKey(slide: HeroSlide, index: number) {
return slide.id || `${slide.image}-${index}`;
}
function getFallbackImage(slide: HeroSlide, index: number) {
const fallbackSlide = props.fallbackSlides.find((item) => item.title === slide.title) ?? props.fallbackSlides[index];
return fallbackSlide?.image ?? "";
}
function getSlideImage(slide: HeroSlide, index: number) {
const fallbackImage = getFallbackImage(slide, index);
return failedSlideImages.value[getSlideKey(slide, index)] ? fallbackImage : resolveNetworkImage(slide.image, fallbackImage);
}
function onImageError(slide: HeroSlide, index: number) {
const fallbackImage = getFallbackImage(slide, index);
if (!fallbackImage || fallbackImage === slide.image) return;
failedSlideImages.value[getSlideKey(slide, index)] = true;
}
function onSlideChange(event: { detail: { current: number } }) {
heroIndex.value = Number(event.detail.current);

View File

@@ -50,6 +50,61 @@ describe("WonderQ shared data helpers", () => {
expect(data.vehicleService.introTitle).toBe("旅游出行专属用车服务");
});
it("keeps the backend hero actionLabel when normalizing site config", () => {
const config: PublicSiteConfig = {
heroSlides: [
{
id: "hero-api",
title: "经典人文打卡线路",
kicker: "经典人文",
actionLabel: "查看线路",
image: "https://cdn.example.test/hero.webp",
targetType: null,
targetValue: null,
},
],
};
const data = normalizeSiteConfig(config, fallbackData);
expect(data.heroSlides[0]).toMatchObject({
id: "hero-api",
title: "经典人文打卡线路",
kicker: "经典人文",
actionLabel: "查看线路",
image: "https://cdn.example.test/hero.webp",
});
});
it("replaces root-relative API images with network fallbacks", () => {
const config: PublicSiteConfig = {
heroSlides: [
{
id: "hero-local-path",
title: "山野户外",
image: "/assets/guizhou/wanfenglin.jpg",
},
],
vehicleOptions: [
{
id: "vehicle-local-path",
title: "舒适型用车",
description: "适合家庭出行",
image: "/assets/guizhou/jiaxiu-tower.jpg",
},
],
};
const data = normalizeSiteConfig(config, fallbackData);
expect(data.heroSlides[0]?.image).toBe(
"https://wanfenglin-guanwang.oss-cn-chengdu.aliyuncs.com/uploads/20240718/23ea4291a9570b94a1f8df9a012c18ec.jpg",
);
expect(data.vehicleOptions[0]?.image).toBe(
"https://dimg04.c-ctrip.com/images/1me6m12000rntioi4FFB2.jpg?proc=source%2Ftripcommunity",
);
});
it("returns a stable submit error message", () => {
expect(getSubmitErrorMessage(new Error("网络异常"))).toBe("网络异常");
expect(getSubmitErrorMessage("unknown")).toBe("提交失败,请稍后再试。");