- Replace all static mock data with real backend API calls for templates, generation tasks, credit balance and recharge - Remove deprecated mock data files including templates.js, versionOptions.js, records.js and packages.js - Add useCurrentCredit composable for fetching and managing user credit balance - Update all components to align with new backend data shapes (e.g. templateItemId instead of id) - Add loading and disabled states for interactive UI elements - Implement full WeChat mini-program payment flow for credit recharge - Fix template and record card UI styling and media handling - Set developVersion flag to true for testing environment
284 lines
8.0 KiB
Vue
284 lines
8.0 KiB
Vue
<template>
|
|
<view class="template-carousel" :style="carouselStyle">
|
|
<view class="template-heading">
|
|
<view>
|
|
<text class="template-heading-title">选择模板</text>
|
|
<text class="template-heading-subtitle">左右滑动预览效果</text>
|
|
</view>
|
|
<text class="template-heading-badge">同源双版本</text>
|
|
</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>
|
|
|
|
<view class="template-dots">
|
|
<view
|
|
v-for="(template, index) in templates"
|
|
:key="`${template.templateId || index}-dot`"
|
|
class="template-dot"
|
|
:class="{ 'is-active': index === modelValue }"
|
|
/>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import TemplateCard from "../TemplateCard/index.vue";
|
|
|
|
const props = defineProps({
|
|
templates: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
modelValue: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["update:modelValue", "select"]);
|
|
|
|
const BASE_CARD_WIDTH = 318;
|
|
const BASE_CARD_HEIGHT = 494;
|
|
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;
|
|
const PAGE_BOTTOM_RESERVE = 8;
|
|
const MIN_CARD_SCALE = 0.72;
|
|
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 instance = getCurrentInstance();
|
|
|
|
let touchStartLeft = 0;
|
|
let touchStartIndex = 0;
|
|
let isTouching = false;
|
|
let settleTimer = null;
|
|
|
|
const getMaxIndex = () => Math.max(props.templates.length - 1, 0);
|
|
|
|
const clampIndex = (index) => Math.max(0, Math.min(getMaxIndex(), index));
|
|
|
|
const cardWidth = computed(() => Math.round(BASE_CARD_WIDTH * cardScale.value));
|
|
const cardHeight = computed(() => Math.round(BASE_CARD_HEIGHT * cardScale.value));
|
|
const mediaHeight = computed(() => Math.round(BASE_MEDIA_HEIGHT * cardScale.value));
|
|
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 carouselStyle = computed(() => ({
|
|
"--template-card-width": `${cardWidth.value}px`,
|
|
"--template-card-height": `${cardHeight.value}px`,
|
|
"--template-card-media-height": `${mediaHeight.value}px`,
|
|
"--template-card-copy-height": `${copyHeight.value}px`,
|
|
"--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 = () => {
|
|
try {
|
|
const systemInfo = uni.getSystemInfoSync();
|
|
return {
|
|
width: Number(systemInfo.windowWidth) || 375,
|
|
height: Number(systemInfo.windowHeight) || 812,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
width: 375,
|
|
height: 812,
|
|
};
|
|
}
|
|
};
|
|
|
|
const getCarouselTop = () =>
|
|
new Promise((resolve) => {
|
|
try {
|
|
let query = uni.createSelectorQuery();
|
|
if (instance?.proxy && typeof query.in === "function") {
|
|
query = query.in(instance.proxy);
|
|
}
|
|
|
|
query
|
|
.select(".template-carousel")
|
|
.boundingClientRect((rect) => {
|
|
resolve(Number(rect?.top) || 0);
|
|
})
|
|
.exec();
|
|
} catch (error) {
|
|
resolve(0);
|
|
}
|
|
});
|
|
|
|
const getNextScale = (carouselTop = 0) => {
|
|
const availableRailHeight =
|
|
screenHeight.value -
|
|
carouselTop -
|
|
HEADING_TOTAL_HEIGHT -
|
|
DOTS_TOTAL_HEIGHT -
|
|
FLOW_STEPS_TOTAL_HEIGHT -
|
|
PAGE_BOTTOM_RESERVE;
|
|
const heightScale = availableRailHeight / BASE_RAIL_HEIGHT;
|
|
const widthScale = (screenWidth.value - BASE_SIDE_PADDING * 2) / BASE_CARD_WIDTH;
|
|
const nextScale = Math.min(heightScale, widthScale, MAX_CARD_SCALE);
|
|
|
|
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;
|
|
screenHeight.value = Number(size.windowHeight) || screenSize.height;
|
|
|
|
await nextTick();
|
|
|
|
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 handleTouchStart = () => {
|
|
clearTimeout(settleTimer);
|
|
isTouching = true;
|
|
touchStartLeft = currentScrollLeft.value;
|
|
touchStartIndex = clampIndex(props.modelValue);
|
|
};
|
|
|
|
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 handleSelect = (template, index) => {
|
|
settleToIndex(index);
|
|
emit("select", template, index);
|
|
};
|
|
|
|
watch(
|
|
() => [props.modelValue, props.templates.length],
|
|
() => {
|
|
scrollToIndex(props.modelValue);
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
updateLayoutMetrics();
|
|
if (typeof uni.onWindowResize === "function") {
|
|
uni.onWindowResize(handleWindowResize);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
clearTimeout(settleTimer);
|
|
if (typeof uni.offWindowResize === "function") {
|
|
uni.offWindowResize(handleWindowResize);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@import "./styles/index.scss";
|
|
</style>
|