feat(admin, miniapp): 优化数据库迁移逻辑与小程序图片资源处理
- 为后端Admin新增migration_compat工具,支持离线迁移时的基线资源检查,重构全部alembic迁移脚本适配离线模式,避免重复创建已存在的数据库表和字段 - 完善初始数据库迁移脚本,添加默认部门种子数据,新增ConciergeAdvisor详情字段从JSON到JSONB的类型转换迁移 - 小程序端新增图片资源解析工具resolveNetworkImage,实现本地静态资源到CDN的自动映射,添加图片加载容错降级机制 - 完善HeroSlide类型定义,新增actionLabel字段,优化首页轮播组件的文本渲染与错误处理逻辑 - 新增对应测试用例验证迁移路径正确性与前端数据处理逻辑 - 新增临时 brainstorm UI对比草稿与开发临时状态文件
This commit is contained in:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user