feat(aigc): add travel postcard AIGC feature
Add complete travel postcard AIGC creation workflow including: - New AIGC pages: template selection home, result detail, and history records - Reusable UI components: template card, flow steps, result preview, action buttons, record lists - Static assets including template images, fonts and styles - Updated TopNavBar component to support menu button alignment - Register new AIGC page routes in pages.json - Add mock data for templates, user records and result details
This commit is contained in:
25
src/pages-aigc/home/components/FlowSteps/index.vue
Normal file
25
src/pages-aigc/home/components/FlowSteps/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<view class="flow-steps">
|
||||
<view
|
||||
v-for="(step, index) in steps"
|
||||
:key="step"
|
||||
class="flow-step"
|
||||
>
|
||||
<text class="flow-index">{{ index + 1 }}</text>
|
||||
<text class="flow-label">{{ step }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
steps: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
50
src/pages-aigc/home/components/FlowSteps/styles/index.scss
Normal file
50
src/pages-aigc/home/components/FlowSteps/styles/index.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.flow-steps {
|
||||
height: 55px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
margin: 12px 16px 0;
|
||||
padding: 4px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
box-shadow: 0 8px 18px rgba(18, 48, 56, 0.05);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0 4px;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: #576276;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flow-index {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #20cf75;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flow-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
44
src/pages-aigc/home/components/TemplateCard/index.vue
Normal file
44
src/pages-aigc/home/components/TemplateCard/index.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<view
|
||||
class="template-card"
|
||||
:class="[
|
||||
active ? 'is-active' : '',
|
||||
template.tone ? `is-${template.tone}` : '',
|
||||
]"
|
||||
:style="{ '--memory-accent': template.accent || '#0f7069' }"
|
||||
>
|
||||
<view class="template-media">
|
||||
<image class="template-image" :src="template.cover" mode="aspectFill" />
|
||||
<view class="template-media-shade" />
|
||||
</view>
|
||||
|
||||
<view class="template-copy">
|
||||
<text class="template-badge">{{ template.badge }}</text>
|
||||
<text class="template-title ellipsis-1">{{ template.title }}</text>
|
||||
<text class="template-desc ellipsis-1">{{ template.desc }}</text>
|
||||
<text class="template-note ellipsis-1">{{ template.note }}</text>
|
||||
<button class="template-select" type="default" @tap.stop="emit('select')">
|
||||
{{ template.buttonText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
147
src/pages-aigc/home/components/TemplateCard/styles/index.scss
Normal file
147
src/pages-aigc/home/components/TemplateCard/styles/index.scss
Normal file
@@ -0,0 +1,147 @@
|
||||
.template-card {
|
||||
position: relative;
|
||||
flex: 0 0 318px;
|
||||
width: 318px;
|
||||
height: 494px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, #0f7069, #082226);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
box-shadow: 0 18px 34px rgba(24, 37, 50, 0.18);
|
||||
transform: scale(0.96);
|
||||
transform-origin: center;
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.template-card.is-active {
|
||||
box-shadow: 0 22px 42px rgba(24, 37, 50, 0.2);
|
||||
transform: scale(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, #0a4d46, #082226);
|
||||
}
|
||||
|
||||
.template-card.is-postcard .template-copy::before {
|
||||
background: linear-gradient(180deg, rgba(10, 77, 70, 0), #0a4d46);
|
||||
}
|
||||
|
||||
.template-media {
|
||||
position: relative;
|
||||
flex: 0 0 358px;
|
||||
height: 358px;
|
||||
overflow: hidden;
|
||||
background: var(--memory-accent);
|
||||
}
|
||||
|
||||
.template-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-media-shade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
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));
|
||||
}
|
||||
|
||||
.template-copy {
|
||||
position: relative;
|
||||
flex: 0 0 136px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
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, #0f7069, #092128);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.template-copy::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -34px;
|
||||
height: 34px;
|
||||
background: linear-gradient(180deg, rgba(15, 112, 105, 0), #0f7069);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-badge {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 18px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 10px;
|
||||
line-height: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-title {
|
||||
width: 178px;
|
||||
color: #ffffff;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
width: 178px;
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.template-note {
|
||||
max-width: 172px;
|
||||
margin-top: 5px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 9px;
|
||||
line-height: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
min-width: 108px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #172033;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-select::after {
|
||||
border: 0;
|
||||
}
|
||||
198
src/pages-aigc/home/components/TemplateCarousel/index.vue
Normal file
198
src/pages-aigc/home/components/TemplateCarousel/index.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<view class="template-carousel">
|
||||
<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.id || 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.id || index}-dot`"
|
||||
class="template-dot"
|
||||
:class="{ 'is-active': index === modelValue }"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { 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 cardStep = 330;
|
||||
const cardWidth = 318;
|
||||
const sidePadding = 16;
|
||||
const switchThreshold = 88;
|
||||
const screenWidth = ref(375);
|
||||
const railScrollLeft = ref(0);
|
||||
const currentScrollLeft = ref(0);
|
||||
|
||||
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 getScreenWidth = () => {
|
||||
try {
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
return Number(systemInfo.windowWidth) || 375;
|
||||
} catch (error) {
|
||||
return 375;
|
||||
}
|
||||
};
|
||||
|
||||
const getCenterOffset = () => (screenWidth.value - cardWidth) / 2 - sidePadding;
|
||||
|
||||
const getTargetScrollLeft = (index) => {
|
||||
const targetIndex = clampIndex(index);
|
||||
if (targetIndex === 0) return 0;
|
||||
return Math.round(targetIndex * cardStep - getCenterOffset());
|
||||
};
|
||||
|
||||
const getNearestIndex = (left) =>
|
||||
clampIndex(Math.round((left + getCenterOffset()) / cardStep));
|
||||
|
||||
const updateScreenWidth = (width) => {
|
||||
screenWidth.value = Number(width) || getScreenWidth();
|
||||
scrollToIndex(props.modelValue);
|
||||
};
|
||||
|
||||
const handleWindowResize = (event) => {
|
||||
updateScreenWidth(event?.size?.windowWidth);
|
||||
};
|
||||
|
||||
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
|
||||
? 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(() => {
|
||||
updateScreenWidth();
|
||||
if (typeof uni.onWindowResize === "function") {
|
||||
uni.onWindowResize(handleWindowResize);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof uni.offWindowResize === "function") {
|
||||
uni.offWindowResize(handleWindowResize);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
.template-carousel {
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-heading {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 16px 20px;
|
||||
}
|
||||
|
||||
.template-heading-title,
|
||||
.template-heading-subtitle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-heading-title {
|
||||
color: #172033;
|
||||
font-size: 16px;
|
||||
line-height: 21px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-heading-subtitle {
|
||||
margin-top: 1px;
|
||||
color: #7a8596;
|
||||
font-size: 10px;
|
||||
line-height: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.template-heading-badge {
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(32, 207, 117, 0.12);
|
||||
color: #10a765;
|
||||
font-size: 11px;
|
||||
line-height: 26px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.template-rail {
|
||||
max-width: 100vw;
|
||||
height: 560px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.template-rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-track {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
height: 560px;
|
||||
padding: 0 41px 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.template-dots {
|
||||
height: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.template-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(23, 32, 51, 0.16);
|
||||
}
|
||||
|
||||
.template-dot.is-active {
|
||||
width: 18px;
|
||||
background: #20cf75;
|
||||
}
|
||||
Reference in New Issue
Block a user