refactor(aigc): update carousel and video components

- rewrite template carousel from custom scroll to uni swiper
- add video loading, error, and replay states to result preview
- update template card styles and video/poster handling logic
- clean up unused CSS vars and remove unnecessary min-height on detail page
- fix video file regex and add video error recovery
This commit is contained in:
duanshuwen
2026-07-18 15:30:06 +08:00
parent 05edc5666c
commit 2407a9d941
7 changed files with 361 additions and 126 deletions

View File

@@ -2,30 +2,167 @@
<view class="result-preview" :style="{ '--result-accent': result.accent }">
<video
v-if="result.mediaType === 'video'"
:id="videoId"
:key="videoRenderKey"
class="result-preview-image"
:src="result.videoResultUrl"
:poster="result.imageResultUrl"
:title="result.title"
controls
show-progress
show-play-btn
show-fullscreen-btn
show-center-play-btn
enable-progress-gesture
enable-play-gesture
auto-pause-if-navigate
auto-pause-if-open-native
play-btn-position="center"
object-fit="cover"
@play="handleVideoPlay"
@waiting="handleVideoWaiting"
@timeupdate="handleVideoTimeUpdate"
@progress="handleVideoProgress"
@loadedmetadata="handleVideoLoaded"
@ended="handleVideoEnded"
@error="handleVideoError"
/>
<image v-else class="result-preview-image" :src="result.imageResultUrl" mode="aspectFill" />
<view class="result-preview-shade" />
<view v-if="showCaptionOverlay" class="result-preview-shade" />
<view class="result-preview-caption">
<view v-if="showCaptionOverlay" class="result-preview-caption">
<text class="result-preview-badge">{{ result.variantLabel }}</text>
<text class="result-preview-title">{{ result.title }}</text>
<text class="result-preview-desc">{{ result.desc }}</text>
</view>
<view v-if="isVideo && isVideoLoading && !isVideoError" class="result-video-state is-loading">
<view class="result-video-loading-icon">
<uni-icons type="spinner-cycle" size="22" color="#ffffff" />
</view>
<text class="result-video-state-text">{{ loadingText }}</text>
</view>
<view v-if="isVideo && isVideoEnded" class="result-video-state is-ended">
<view class="result-video-state-action" hover-class="is-pressed" @tap.stop="handleReplay">
<uni-icons type="reload" size="22" color="#17324d" />
<text>重新播放</text>
</view>
</view>
<view v-if="isVideo && isVideoError" class="result-video-state is-error">
<text class="result-video-error-title">视频加载失败</text>
<view class="result-video-state-action" hover-class="is-pressed" @tap.stop="handleVideoRetry">
<uni-icons type="refresh" size="21" color="#17324d" />
<text>重新加载</text>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
import { computed, getCurrentInstance, nextTick, ref, watch } from "vue";
const props = defineProps({
result: {
type: Object,
default: () => ({}),
},
});
const instance = getCurrentInstance();
const videoId = `aigc-result-video-${Math.random().toString(36).slice(2, 10)}`;
const videoLoadVersion = ref(0);
const isVideoLoading = ref(true);
const isVideoEnded = ref(false);
const isVideoError = ref(false);
const hasVideoStarted = ref(false);
const bufferedProgress = ref(0);
const isVideo = computed(() => props.result.mediaType === "video");
const videoRenderKey = computed(() => `${props.result.videoResultUrl || "video"}-${videoLoadVersion.value}`);
const showCaptionOverlay = computed(
() => !isVideo.value || (!hasVideoStarted.value && !isVideoEnded.value && !isVideoError.value)
);
const loadingText = computed(() =>
bufferedProgress.value > 0 ? `视频加载中 ${bufferedProgress.value}%` : "视频加载中"
);
const getVideoContext = () => uni.createVideoContext(videoId, instance?.proxy || instance);
const resetVideoState = () => {
isVideoLoading.value = isVideo.value;
isVideoEnded.value = false;
isVideoError.value = false;
hasVideoStarted.value = false;
bufferedProgress.value = 0;
};
const handleVideoLoaded = () => {
isVideoLoading.value = false;
isVideoError.value = false;
};
const handleVideoPlay = () => {
hasVideoStarted.value = true;
isVideoLoading.value = false;
isVideoEnded.value = false;
isVideoError.value = false;
};
const handleVideoWaiting = () => {
if (hasVideoStarted.value) {
isVideoLoading.value = true;
}
};
const handleVideoTimeUpdate = () => {
if (isVideoLoading.value) {
isVideoLoading.value = false;
}
};
const handleVideoProgress = (event) => {
const buffered = Number(event?.detail?.buffered);
if (Number.isFinite(buffered)) {
bufferedProgress.value = Math.max(0, Math.min(100, Math.round(buffered)));
}
};
const handleVideoEnded = () => {
isVideoLoading.value = false;
isVideoEnded.value = true;
};
const handleVideoError = (event) => {
isVideoLoading.value = false;
isVideoEnded.value = false;
isVideoError.value = true;
console.error("AIGC视频加载失败", event?.detail?.errMsg || event?.detail || "unknown error");
};
const handleReplay = () => {
isVideoEnded.value = false;
isVideoLoading.value = true;
const videoContext = getVideoContext();
videoContext?.seek?.(0);
videoContext?.play?.();
};
const handleVideoRetry = () => {
videoLoadVersion.value += 1;
resetVideoState();
nextTick(() => {
getVideoContext()?.play?.();
});
};
watch(
() => props.result.videoResultUrl,
() => {
resetVideoState();
}
);
</script>
<style scoped lang="scss">

View File

@@ -32,6 +32,7 @@
rgba(0, 0, 0, 0.74) 100%
);
pointer-events: none;
z-index: 1;
}
.result-preview-caption {
@@ -40,6 +41,86 @@
bottom: 18px;
left: 18px;
pointer-events: none;
z-index: 2;
}
.result-video-state {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.result-video-state.is-loading {
pointer-events: none;
background: rgba(9, 25, 39, 0.2);
}
.result-video-state.is-ended,
.result-video-state.is-error {
background: rgba(9, 25, 39, 0.52);
}
.result-video-loading-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(9, 25, 39, 0.48);
animation: result-video-spin 1s linear infinite;
}
.result-video-state-text,
.result-video-error-title {
color: #ffffff;
font-size: 13px;
line-height: 20px;
font-weight: 700;
}
.result-video-error-title {
font-size: 15px;
}
.result-video-state-action {
min-width: 132px;
height: 48px;
padding: 0 20px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
color: #17324d;
font-size: 14px;
line-height: 20px;
font-weight: 800;
box-shadow: 0 8px 22px rgba(9, 25, 39, 0.22);
}
.result-video-state-action.is-pressed {
transform: scale(0.97);
opacity: 0.9;
}
@keyframes result-video-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.result-preview-badge {

View File

@@ -4,7 +4,6 @@
flex-direction: column;
width: 100vw;
height: 100vh;
min-height: 812px;
overflow: hidden;
color: #172033;
background:

View File

@@ -4,19 +4,22 @@
:class="{ 'is-active': active }"
>
<view class="template-media">
<image v-if="templatePosterUrl" class="template-image" :src="templatePosterUrl" mode="aspectFill" />
<video
v-if="templateMediaUrl && isVideoTemplate"
v-else-if="templateVideoUrl && playing && !videoLoadFailed"
class="template-video"
:src="templateMediaUrl"
:src="templateVideoUrl"
autoplay
muted
loop
:controls="false"
:show-play-btn="false"
:show-center-play-btn="false"
:show-fullscreen-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
@error="handleVideoError"
/>
<image v-else-if="templateMediaUrl" class="template-image" :src="templateMediaUrl" mode="aspectFill" />
<view class="template-media-shade" />
</view>
@@ -33,7 +36,7 @@
</template>
<script setup>
import { computed } from "vue";
import { computed, ref, watch } from "vue";
const props = defineProps({
template: {
@@ -44,11 +47,16 @@ const props = defineProps({
type: Boolean,
default: false,
},
playing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select"]);
const VIDEO_EXT_REGEXP = /\.(mp4|mov|webm|m4v)(\?.*)?$/i;
const VIDEO_EXT_REGEXP = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i;
const videoLoadFailed = ref(false);
const templateMediaUrl = computed(() => {
return props.template.templateContentUrl || props.template.coverPhotoUrl || "";
@@ -57,6 +65,23 @@ const templateMediaUrl = computed(() => {
const isVideoTemplate = computed(() => {
return VIDEO_EXT_REGEXP.test(templateMediaUrl.value);
});
const templateVideoUrl = computed(() => (isVideoTemplate.value ? templateMediaUrl.value : ""));
const templatePosterUrl = computed(() => {
const coverUrl = props.template.coverPhotoUrl || "";
if (isVideoTemplate.value) {
return coverUrl && !VIDEO_EXT_REGEXP.test(coverUrl) ? coverUrl : "";
}
return templateMediaUrl.value;
});
const handleVideoError = () => {
videoLoadFailed.value = true;
};
watch(templateVideoUrl, () => {
videoLoadFailed.value = false;
});
</script>
<style scoped lang="scss">

View File

@@ -6,24 +6,30 @@
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
border-radius: var(--template-card-radius, 28px);
background: linear-gradient(180deg, #0f7069, #082226);
color: #ffffff;
text-align: left;
box-shadow: 0 3px 8px rgba(24, 37, 50, 0.12);
transform: scale(0.96);
transform-origin: center;
transition: box-shadow 0.18s ease, transform 0.18s ease;
opacity: 0.82;
transition:
box-shadow 0.2s ease,
opacity 0.2s ease;
}
.template-card.is-active {
border-color: rgba(255, 255, 255, 0.34);
box-shadow: 0 5px 12px rgba(24, 37, 50, 0.16);
transform: scale(1);
opacity: 1;
}
.template-card.is-postcard .template-copy {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 34%),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0) 34%
),
linear-gradient(180deg, #0a4d46, #082226);
}
@@ -46,6 +52,10 @@
display: block;
}
.template-video {
pointer-events: none;
}
.template-media-shade {
position: absolute;
top: 0;
@@ -53,8 +63,18 @@
bottom: 0;
left: 0;
background:
radial-gradient(92% 45% at 50% 28%, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0) 62%),
linear-gradient(180deg, rgba(4, 22, 24, 0), rgba(4, 22, 24, 0.02) 58%, rgba(4, 22, 24, 0.24));
radial-gradient(
92% 45% at 50% 28%,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0) 62%
),
linear-gradient(
180deg,
rgba(4, 22, 24, 0),
rgba(4, 22, 24, 0.02) 58%,
rgba(4, 22, 24, 0.24)
);
pointer-events: none;
}
.template-copy {
@@ -66,7 +86,11 @@
align-items: flex-start;
padding: 15px 18px 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 34%),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0) 34%
),
linear-gradient(180deg, #0f7069, #092128);
box-sizing: border-box;
}

View File

@@ -7,18 +7,24 @@
</view>
</view>
<scroll-view class="template-rail" scroll-x show-scrollbar="false" scroll-with-animation
:scroll-left="railScrollLeft" @scroll="handleScroll" @touchstart="handleTouchStart" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd">
<view class="template-track">
<TemplateCard v-for="(template, index) in templates" :key="template.templateId || index" :template="template"
:active="index === modelValue" @select="handleSelect(template, index)" />
</view>
</scroll-view>
<swiper class="template-rail" :current="currentIndex" :duration="280" :previous-margin="swiperMargin"
:next-margin="swiperMargin" :skip-hidden-item-layout="true" easing-function="easeOutCubic"
@change="handleSwiperChange" @transition="handleSwiperTransition"
@animationfinish="handleSwiperAnimationFinish">
<swiper-item v-for="(template, index) in templates" :key="template.templateId || index">
<view class="template-slide">
<view class="template-slide-card" :class="{ 'is-active': index === currentIndex }">
<TemplateCard :template="template" :active="index === currentIndex"
:playing="!isSwiperMoving && index === settledIndex"
@select="handleSelect(template, index)" />
</view>
</view>
</swiper-item>
</swiper>
<view class="template-dots">
<view v-for="(template, index) in templates" :key="`${template.templateId || index}-dot`" class="template-dot"
:class="{ 'is-active': index === modelValue }" />
:class="{ 'is-active': index === currentIndex }" />
</view>
</view>
</template>
@@ -46,9 +52,7 @@ const BASE_MEDIA_HEIGHT = 358;
const BASE_RAIL_EXTRA_HEIGHT = 18;
const BASE_RAIL_HEIGHT = BASE_CARD_HEIGHT + BASE_RAIL_EXTRA_HEIGHT;
const BASE_TRACK_GAP = 12;
const BASE_TRACK_END_PADDING = 41;
const BASE_SIDE_PADDING = 16;
const BASE_SWITCH_THRESHOLD = 88;
const HEADING_TOTAL_HEIGHT = 54;
const DOTS_TOTAL_HEIGHT = 19;
const FLOW_STEPS_TOTAL_HEIGHT = 67;
@@ -59,14 +63,11 @@ const MAX_CARD_SCALE = 1;
const screenWidth = ref(375);
const screenHeight = ref(812);
const cardScale = ref(1);
const railScrollLeft = ref(0);
const currentScrollLeft = ref(0);
const settledIndex = ref(0);
const isSwiperMoving = ref(false);
const instance = getCurrentInstance();
let touchStartLeft = 0;
let touchStartIndex = 0;
let isTouching = false;
let settleTimer = null;
let isSwiperTransitioning = false;
const getMaxIndex = () => Math.max(props.templates.length - 1, 0);
@@ -78,9 +79,12 @@ const mediaHeight = computed(() => Math.round(BASE_MEDIA_HEIGHT * cardScale.valu
const copyHeight = computed(() => Math.max(cardHeight.value - mediaHeight.value, 96));
const railHeight = computed(() => Math.round(BASE_RAIL_HEIGHT * cardScale.value));
const trackGap = computed(() => Math.round(BASE_TRACK_GAP * cardScale.value));
const trackEndPadding = computed(() => Math.round(BASE_TRACK_END_PADDING * cardScale.value));
const cardStep = computed(() => cardWidth.value + trackGap.value);
const switchThreshold = computed(() => Math.round(BASE_SWITCH_THRESHOLD * cardScale.value));
const currentIndex = computed(() => clampIndex(props.modelValue));
const swiperMarginValue = computed(() => {
const margin = (screenWidth.value - cardWidth.value - trackGap.value) / 2;
return Math.max(0, Math.round(margin));
});
const swiperMargin = computed(() => `${swiperMarginValue.value}px`);
const carouselStyle = computed(() => ({
"--template-card-width": `${cardWidth.value}px`,
@@ -90,8 +94,6 @@ const carouselStyle = computed(() => ({
"--template-card-radius": `${Math.round(28 * cardScale.value)}px`,
"--template-rail-height": `${railHeight.value}px`,
"--template-track-gap": `${trackGap.value}px`,
"--template-track-end-padding": `${trackEndPadding.value}px`,
"--template-side-padding": `${BASE_SIDE_PADDING}px`,
}));
const getScreenSize = () => {
@@ -143,17 +145,6 @@ const getNextScale = (carouselTop = 0) => {
return Math.max(MIN_CARD_SCALE, nextScale);
};
const getCenterOffset = () => (screenWidth.value - cardWidth.value) / 2 - BASE_SIDE_PADDING;
const getTargetScrollLeft = (index) => {
const targetIndex = clampIndex(index);
if (targetIndex === 0) return 0;
return Math.round(targetIndex * cardStep.value - getCenterOffset());
};
const getNearestIndex = (left) =>
clampIndex(Math.round((left + getCenterOffset()) / cardStep.value));
const updateLayoutMetrics = async (size = {}) => {
const screenSize = getScreenSize();
screenWidth.value = Number(size.windowWidth) || screenSize.width;
@@ -163,88 +154,61 @@ const updateLayoutMetrics = async (size = {}) => {
const carouselTop = await getCarouselTop();
cardScale.value = getNextScale(carouselTop);
scrollToIndex(props.modelValue);
};
const handleWindowResize = (event) => {
updateLayoutMetrics(event?.size);
};
const setRailScrollLeft = async (targetLeft, force = false) => {
await nextTick();
if (force && railScrollLeft.value === targetLeft) {
railScrollLeft.value = targetLeft > 0 ? targetLeft - 1 : 1;
setTimeout(() => {
railScrollLeft.value = targetLeft;
currentScrollLeft.value = targetLeft;
}, 0);
return;
}
railScrollLeft.value = targetLeft;
currentScrollLeft.value = targetLeft;
};
const scrollToIndex = (index, force = false) => {
setRailScrollLeft(getTargetScrollLeft(index), force);
};
const settleToIndex = (index, force = true) => {
const targetIndex = clampIndex(index);
if (targetIndex !== props.modelValue) {
emit("update:modelValue", targetIndex);
}
scrollToIndex(targetIndex, force);
};
const handleScroll = (event) => {
currentScrollLeft.value = event.detail?.scrollLeft || 0;
if (!isTouching) {
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleToIndex(getNearestIndex(currentScrollLeft.value));
}, 140);
const handleSwiperChange = (event) => {
const nextIndex = clampIndex(Number(event.detail?.current) || 0);
isSwiperTransitioning = true;
if (nextIndex !== props.modelValue) {
emit("update:modelValue", nextIndex);
}
};
const handleTouchStart = () => {
clearTimeout(settleTimer);
isTouching = true;
touchStartLeft = currentScrollLeft.value;
touchStartIndex = clampIndex(props.modelValue);
const handleSwiperTransition = (event) => {
const offset = Number(event.detail?.dx) || 0;
if (Math.abs(offset) > 1) {
if (!isSwiperMoving.value) {
isSwiperMoving.value = true;
}
}
};
const handleTouchEnd = () => {
if (!isTouching) return;
isTouching = false;
clearTimeout(settleTimer);
const offset = currentScrollLeft.value - touchStartLeft;
const direction = offset > 0 ? 1 : -1;
const targetIndex =
Math.abs(offset) >= switchThreshold.value
? touchStartIndex + direction
: touchStartIndex;
settleToIndex(targetIndex);
const handleSwiperAnimationFinish = (event) => {
isSwiperTransitioning = false;
settledIndex.value = clampIndex(Number(event.detail?.current) || 0);
isSwiperMoving.value = false;
};
const handleSelect = (template, index) => {
settleToIndex(index);
const nextIndex = clampIndex(index);
if (nextIndex !== props.modelValue) {
emit("update:modelValue", nextIndex);
}
emit("select", template, index);
};
watch(
() => [props.modelValue, props.templates.length],
() => {
scrollToIndex(props.modelValue);
() => props.modelValue,
(value) => {
if (!isSwiperTransitioning) {
settledIndex.value = clampIndex(value);
isSwiperMoving.value = false;
}
},
{ immediate: true }
);
watch(
() => props.templates.length,
() => {
settledIndex.value = currentIndex.value;
}
);
onMounted(() => {
updateLayoutMetrics();
if (typeof uni.onWindowResize === "function") {
@@ -253,7 +217,6 @@ onMounted(() => {
});
onUnmounted(() => {
clearTimeout(settleTimer);
if (typeof uni.offWindowResize === "function") {
uni.offWindowResize(handleWindowResize);
}

View File

@@ -32,27 +32,33 @@
}
.template-rail {
max-width: 100vw;
width: 100%;
height: var(--template-rail-height, 560px);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.template-rail::-webkit-scrollbar {
display: none;
}
.template-track {
display: inline-flex;
.template-slide {
width: 100%;
height: var(--template-rail-height, 560px);
display: flex;
align-items: flex-start;
gap: var(--template-track-gap, 12px);
height: var(--template-rail-height, 560px);
padding: 0 var(--template-track-end-padding, 41px) 0
var(--template-side-padding, 16px);
justify-content: center;
padding: 0 calc(var(--template-track-gap, 12px) / 2);
box-sizing: border-box;
}
.template-slide-card {
width: var(--template-card-width, 318px);
height: var(--template-card-height, 494px);
flex: 0 0 var(--template-card-width, 318px);
transform-origin: center;
transform: scale(0.94);
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.template-slide-card.is-active {
transform: scale(1);
}
.template-dots {
height: 14px;
display: flex;