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:
duanshuwen
2026-07-05 17:47:37 +08:00
parent ca680ab41b
commit f47f68e19b
35 changed files with 1716 additions and 18 deletions

View File

@@ -4,8 +4,10 @@
<view :style="{ height: statusBarHeight + 'px' }" v-if="!hideStatusBar"></view>
<!-- 导航栏内容 -->
<view class="flex flex-items-center flex-justify-between border-box pl-8 pr-8"
:style="{ height: navBarHeight + 'px' }">
<view
class="flex flex-items-center flex-justify-between border-box pl-8 pr-8"
:style="navContentStyle"
>
<!-- 左侧返回按钮 -->
<view class="nav-bar-left flex flex-items-center flex-justify-center" v-if="showBack" @click="handleBack">
<uni-icons type="left" size="20" :color="backIconColor" />
@@ -32,7 +34,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, getCurrentInstance, onMounted, ref } from "vue";
// 定义props
const props = defineProps({
@@ -82,10 +84,15 @@ const props = defineProps({
default: "center", // 'center' | 'left'
validator: (value) => ["center", "left"].includes(value),
},
alignWithMenuButton: {
type: Boolean,
default: false,
},
});
// 定义emits
const emit = defineEmits(["back"]);
const instance = getCurrentInstance();
// 系统信息
const statusBarHeight = ref(0);
@@ -94,7 +101,8 @@ const navBarHeight = ref(44); // 默认导航栏高度
// 获取系统信息
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 0;
const systemStatusBarHeight = systemInfo.statusBarHeight || 0;
statusBarHeight.value = systemStatusBarHeight;
// 根据平台设置导航栏高度
if (systemInfo.platform === "ios") {
@@ -102,6 +110,18 @@ onMounted(() => {
} else {
navBarHeight.value = 48;
}
if (
props.alignWithMenuButton &&
typeof uni.getMenuButtonBoundingClientRect === "function"
) {
const menuButton = uni.getMenuButtonBoundingClientRect();
if (menuButton?.height && typeof menuButton.top === "number") {
statusBarHeight.value = Math.max(menuButton.top, systemStatusBarHeight);
navBarHeight.value = menuButton.height;
}
}
});
// 计算导航栏样式类
@@ -122,15 +142,27 @@ const navBarStyle = computed(() => {
};
});
const navContentStyle = computed(() => {
return {
height: navBarHeight.value + "px",
};
});
const hasBackListener = computed(() => {
const vnodeProps = instance?.vnode?.props || {};
return Boolean(vnodeProps.onBack);
});
// 处理返回事件
const handleBack = () => {
emit("back");
// 如果没有监听back事件默认执行返回上一页
if (!emit("back")) {
uni.navigateBack({
delta: 1,
});
if (hasBackListener.value) {
emit("back");
return;
}
// 如果没有监听back事件默认执行返回上一页
uni.navigateBack({
delta: 1,
});
};
</script>

View File

@@ -0,0 +1,91 @@
<template>
<view class="aigc-topbar" :style="topbarStyle">
<view class="aigc-nav-row">
<view class="aigc-topbar-back" @tap="emit('back')">
<uni-icons class="aigc-back-icon" fontFamily="znicons" size="22" color="#172033">
{{ zniconsMap["zn-nav-arrow-left"] }}
</uni-icons>
</view>
<view v-if="showActions" class="aigc-topbar-actions">
<view class="aigc-wallet-button" @tap="emit('recharge')">
<text class="aigc-wallet-label">{{ rechargeLabel }}</text>
<text class="aigc-wallet-points">
<text class="aigc-wallet-number">{{ points }}</text>
积分
</text>
</view>
<view class="aigc-history-button" @tap="emit('history')">
<uni-icons class="aigc-history-icon" fontFamily="znicons" size="22" color="#436783">
{{ zniconsMap["zn-clock"] }}
</uni-icons>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { zniconsMap } from "@/static/fonts/znicons.js";
defineProps({
points: {
type: [Number, String],
default: 0,
},
rechargeLabel: {
type: String,
default: "充值",
},
showActions: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["back", "history", "recharge"]);
const statusBarHeight = ref(0);
const navHeight = ref(52);
const capsuleSafeWidth = ref(0);
const topbarStyle = computed(() => ({
"--aigc-status-height": `${statusBarHeight.value}px`,
"--aigc-nav-height": `${navHeight.value}px`,
"--aigc-capsule-safe-width": `${capsuleSafeWidth.value}px`,
}));
onMounted(() => {
try {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 0;
const menuButton =
typeof uni.getMenuButtonBoundingClientRect === "function"
? uni.getMenuButtonBoundingClientRect()
: null;
if (menuButton?.width && menuButton?.left) {
const menuTopGap = Math.max(menuButton.top - statusBarHeight.value, 0);
const windowWidth = systemInfo.windowWidth || 375;
navHeight.value = Math.max(menuButton.height + menuTopGap * 2, 44);
capsuleSafeWidth.value = Math.max(windowWidth - menuButton.left + 8, 96);
return;
}
navHeight.value = statusBarHeight.value > 0 ? 44 : 52;
capsuleSafeWidth.value = 0;
} catch (error) {
statusBarHeight.value = 0;
navHeight.value = 52;
capsuleSafeWidth.value = 0;
}
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,138 @@
@font-face {
font-family: znicons;
src: url("@/static/fonts/znicons.ttf");
}
.aigc-topbar {
position: relative;
z-index: 3;
flex: 0 0 auto;
padding: var(--aigc-status-height) 12px 8px;
box-sizing: border-box;
background: linear-gradient(
180deg,
rgba(235, 249, 255, 0.92) 0%,
rgba(240, 251, 247, 0.72) 66%,
rgba(240, 251, 247, 0) 100%
);
}
.aigc-nav-row {
height: var(--aigc-nav-height);
display: flex;
align-items: center;
gap: 10px;
padding-right: var(--aigc-capsule-safe-width);
box-sizing: border-box;
}
.aigc-topbar-back {
flex: 0 0 40px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.86);
color: #172033;
box-shadow: 0 8px 18px rgba(18, 48, 56, 0.08);
}
.aigc-back-icon {
display: flex;
align-items: center;
justify-content: center;
transform: translateX(-1px);
}
.aigc-topbar-actions {
flex: 1 1 auto;
width: auto;
min-width: 0;
height: 40px;
display: grid;
grid-template-columns: minmax(0, 1fr) 36px;
align-items: center;
gap: 6px;
padding: 2px 3px;
border: 1px solid rgba(255, 255, 255, 0.76);
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 8px 18px rgba(18, 48, 56, 0.08);
box-sizing: border-box;
}
.aigc-history-button,
.aigc-wallet-button {
height: 36px;
min-width: 0;
display: flex;
align-items: center;
margin: 0;
padding: 0;
border-radius: 16px;
box-sizing: border-box;
}
.aigc-history-button {
width: 36px;
flex: 0 0 36px;
justify-content: center;
border-radius: 50%;
background: transparent;
color: #436783;
}
.aigc-history-icon {
display: flex;
align-items: center;
justify-content: center;
}
.aigc-wallet-button {
justify-content: space-between;
gap: 6px;
padding: 0 9px 0 8px;
background: #102942;
color: #ffffff;
box-shadow:
0 8px 18px rgba(18, 48, 56, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.aigc-wallet-label {
height: 24px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(32, 207, 117, 0.18);
color: #50e093;
font-size: 10px;
line-height: 24px;
font-weight: 900;
white-space: nowrap;
}
.aigc-wallet-points {
min-width: 0;
display: inline-flex;
align-items: baseline;
overflow: hidden;
color: #ffffff;
font-size: 10px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
}
.aigc-wallet-number {
margin-right: 2px;
font-size: 15px;
line-height: 1;
font-weight: 900;
}

View File

@@ -0,0 +1,29 @@
<template>
<view class="continuation-card">
<view class="continuation-copy">
<text class="continuation-title">{{ info.title }}</text>
<text class="continuation-desc">{{ info.desc }}</text>
</view>
<view class="continuation-button" @tap="emit('upgrade')">
{{ info.buttonText }}
</view>
<text class="continuation-cost">{{ info.cost }}</text>
</view>
</template>
<script setup>
defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["upgrade"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,64 @@
.continuation-card {
min-height: 84px;
display: grid;
grid-template-columns: minmax(0, 1fr) 116px;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 14px;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(32, 207, 117, 0.14), rgba(255, 255, 255, 0.82)),
rgba(255, 255, 255, 0.82);
box-sizing: border-box;
}
.continuation-copy {
min-width: 0;
}
.continuation-title,
.continuation-desc,
.continuation-cost {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.continuation-title {
color: #172033;
font-size: 14px;
line-height: 19px;
font-weight: 900;
}
.continuation-desc {
margin-top: 4px;
color: #667386;
font-size: 11px;
line-height: 16px;
font-weight: 800;
}
.continuation-button {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #172951;
color: #ffffff;
font-size: 12px;
line-height: 40px;
font-weight: 900;
white-space: nowrap;
}
.continuation-cost {
grid-column: 1 / -1;
color: #12a765;
font-size: 10px;
line-height: 13px;
font-weight: 900;
}

View File

@@ -0,0 +1,14 @@
<template>
<view class="result-actions">
<view class="result-action is-primary" @tap="emit('save')">保存图片</view>
<view class="result-action is-secondary" @tap="emit('share')">分享好友</view>
</view>
</template>
<script setup>
const emit = defineEmits(["save", "share"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,28 @@
.result-actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 10px;
margin-top: 18px;
}
.result-action {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 15px;
line-height: 20px;
font-weight: 900;
}
.result-action.is-primary {
background: #20cf75;
color: #ffffff;
box-shadow: 0 8px 16px rgba(32, 207, 117, 0.18);
}
.result-action.is-secondary {
background: rgba(255, 255, 255, 0.78);
color: #526073;
}

View File

@@ -0,0 +1,21 @@
<template>
<view class="result-meta">
<view v-for="item in items" :key="item.label" class="result-meta-item">
<text class="result-meta-label">{{ item.label }}</text>
<text class="result-meta-value">{{ item.value }}</text>
</view>
</view>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => [],
},
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,39 @@
.result-meta {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.result-meta-item {
min-width: 0;
height: 62px;
padding: 10px 8px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.78);
color: #172033;
font-size: 11px;
line-height: 15px;
font-weight: 900;
text-align: center;
box-sizing: border-box;
}
.result-meta-label,
.result-meta-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-meta-label {
margin-bottom: 5px;
color: #8c98a8;
font-size: 10px;
line-height: 13px;
}
.result-meta-value {
color: #173252;
}

View File

@@ -0,0 +1,25 @@
<template>
<view class="result-preview" :style="{ '--result-accent': result.accent }">
<image class="result-preview-image" :src="result.cover" mode="aspectFill" />
<view class="result-preview-shade" />
<view 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>
</template>
<script setup>
defineProps({
result: {
type: Object,
default: () => ({}),
},
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,79 @@
.result-preview {
position: relative;
height: 360px;
overflow: hidden;
border-radius: 26px;
background: var(--result-accent, #0a4d46);
color: #ffffff;
box-shadow: 0 18px 34px rgba(17, 35, 49, 0.12);
}
.result-preview-image {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.result-preview-shade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.02) 0%,
rgba(0, 0, 0, 0.16) 48%,
rgba(0, 0, 0, 0.74) 100%
);
}
.result-preview-caption {
position: absolute;
right: 18px;
bottom: 18px;
left: 18px;
}
.result-preview-badge {
height: 20px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
font-size: 9px;
line-height: 20px;
font-weight: 900;
letter-spacing: 0.7px;
}
.result-preview-title,
.result-preview-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-preview-title {
margin-top: 12px;
color: #ffffff;
font-size: 23px;
line-height: 30px;
font-weight: 900;
}
.result-preview-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
line-height: 18px;
font-weight: 800;
}

View File

@@ -0,0 +1,16 @@
import glassBoatPostcard from "../../home/assets/glass-boat-postcard.gif";
export const aigcDetailResult = {
id: "glass-boat-postcard-result",
title: "鸳鸯湖明信片",
cover: glassBoatPostcard,
accent: "#0a4d46",
variantLabel: "图片版",
resultLabel: "图片版",
consumedText: "100积分",
desc: "已生成可保存、可分享的高清旅行图片",
continuationTitle: "创意续作",
continuationDesc: "让这张图动起来 · 5秒内动效视频",
continuationCost: "追加消耗 100积分",
continuationButtonText: "升级为动效视频",
};

View File

@@ -0,0 +1,105 @@
<template>
<view class="aigc-detail-page">
<AigcTopBar :points="pointBalance" @back="handleBack" @history="handleOpenRecords" @recharge="handleRecharge" />
<view class="aigc-detail-content">
<ResultPreview :result="result" />
<ResultMeta :items="metaItems" />
<ContinuationCard :info="continuationInfo" @upgrade="handleUpgradeToVideo" />
<ResultActions @save="handleSave" @share="handleShare" />
<view class="aigc-detail-text-actions">
<view class="aigc-detail-text-action" @tap="handleRegenerate">再生成一版</view>
<view class="aigc-detail-text-action" @tap="handleChangeTemplate">换个模板</view>
<view class="aigc-detail-text-action" @tap="handleOpenRecords">历史记录</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import ContinuationCard from "./components/ContinuationCard/index.vue";
import ResultActions from "./components/ResultActions/index.vue";
import ResultMeta from "./components/ResultMeta/index.vue";
import ResultPreview from "./components/ResultPreview/index.vue";
import { aigcDetailResult } from "./data/result.js";
const pointBalance = ref(300);
const result = aigcDetailResult;
const metaItems = computed(() => [
{
label: "结果",
value: result.resultLabel,
},
{
label: "已消耗",
value: result.consumedText,
},
{
label: "余额",
value: `${pointBalance.value}积分`,
},
]);
const continuationInfo = computed(() => ({
title: result.continuationTitle,
desc: result.continuationDesc,
cost: result.continuationCost,
buttonText: result.continuationButtonText,
}));
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleRecharge = () => {
showPlaceholderToast("积分充值");
};
const handleUpgradeToVideo = () => {
showPlaceholderToast("升级为动效视频");
};
const handleSave = () => {
showPlaceholderToast("保存图片");
};
const handleShare = () => {
showPlaceholderToast("分享好友");
};
const handleRegenerate = () => {
showPlaceholderToast("再生成一版");
};
const handleChangeTemplate = () => {
uni.navigateTo({
url: "/pages-aigc/home/home",
fail: () => showPlaceholderToast("换个模板"),
});
};
const handleOpenRecords = () => {
uni.navigateTo({
url: "/pages-aigc/record/record",
fail: () => showPlaceholderToast("历史记录"),
});
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,54 @@
.aigc-detail-page {
position: relative;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
min-height: 812px;
overflow: hidden;
color: #172033;
background:
radial-gradient(
130% 78% at 0% 0%,
rgba(255, 249, 226, 0.76),
rgba(255, 249, 226, 0) 42%
),
linear-gradient(180deg, #effaf5 0%, #f4fbf7 58%, #eef8ff 100%);
box-sizing: border-box;
}
.aigc-detail-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 0 12px;
box-sizing: border-box;
}
.aigc-detail-text-actions {
height: 50px;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
gap: 0;
margin: 12px 22px 0;
border-radius: 50px;
background: rgba(255, 255, 255, 0.42);
}
.aigc-detail-text-action {
position: relative;
min-width: 0;
overflow: hidden;
color: #20a765;
font-size: 12px;
line-height: 18px;
font-weight: 900;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.aigc-detail-text-action + .aigc-detail-text-action {
border-left: 1px solid rgba(32, 167, 101, 0.16);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

View 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>

View 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;
}

View 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>

View 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;
}

View 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>

View File

@@ -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;
}

View File

@@ -0,0 +1,53 @@
import glassBoatPostcard from "../assets/glass-boat-postcard.gif";
import bridgeCover from "../assets/card-bridge.png";
import waterfallCover from "../assets/card-waterfall.png";
import cultureCover from "../assets/card-culture-1.png";
export const aigcTemplates = [
{
id: "glass-boat-postcard",
title: "鸳鸯湖明信片",
desc: "适合朋友圈分享的旅行纪念内容",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: glassBoatPostcard,
tone: "postcard",
accent: "#0a4d46",
},
{
id: "bridge-memory",
title: "小七孔古桥留影",
desc: "把古桥与湖光做成专属旅行大片",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: bridgeCover,
tone: "emerald",
accent: "#0f7069",
},
{
id: "waterfall-story",
title: "翠谷瀑布旅拍",
desc: "适合清爽夏日氛围的动态内容",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: waterfallCover,
tone: "aqua",
accent: "#155e75",
},
{
id: "culture-postcard",
title: "瑶山古寨印象",
desc: "把民族风情做成旅行收藏卡",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: cultureCover,
tone: "forest",
accent: "#36563d",
},
];
export const aigcFlowSteps = ["选模板", "传素材", "添积分", "出结果"];

View File

@@ -0,0 +1,54 @@
<template>
<view class="aigc-home-page">
<AigcTopBar :points="pointBalance" @back="handleBack" @history="handleHistory" @recharge="handleRecharge" />
<view class="aigc-home-browser">
<TemplateCarousel v-model="activeIndex" :templates="templates" @select="handleSelectTemplate" />
<FlowSteps :steps="steps" />
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import TemplateCarousel from "./components/TemplateCarousel/index.vue";
import FlowSteps from "./components/FlowSteps/index.vue";
import { aigcFlowSteps, aigcTemplates } from "./data/templates.js";
const pointBalance = ref(300);
const activeIndex = ref(0);
const templates = aigcTemplates;
const steps = aigcFlowSteps;
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleHistory = () => {
showPlaceholderToast("最近任务");
};
const handleRecharge = () => {
showPlaceholderToast("积分充值");
};
const handleSelectTemplate = (template) => {
showPlaceholderToast(template.title);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,26 @@
.aigc-home-page {
position: relative;
display: flex;
flex-direction: column;
max-width: 100vw;
height: 812px;
overflow: hidden;
color: #172033;
background:
radial-gradient(
130% 78% at 0% 0%,
rgba(255, 249, 226, 0.76),
rgba(255, 249, 226, 0) 42%
),
linear-gradient(180deg, #effaf5 0%, #f4fbf7 58%, #eef8ff 100%);
box-sizing: border-box;
}
.aigc-home-browser {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 4px 0 0;
box-sizing: border-box;
}

View File

@@ -0,0 +1,30 @@
<template>
<view class="record-card">
<image class="record-card-image" :src="record.image" mode="aspectFill" />
<view class="record-card-copy">
<text class="record-card-title ellipsis-1">{{ record.title }}</text>
<text class="record-card-time ellipsis-1">{{ record.time }}</text>
<text class="record-card-status ellipsis-1">{{ record.status }}</text>
</view>
<view class="record-card-action" @tap="emit('view', record)">
查看
</view>
</view>
</template>
<script setup>
defineProps({
record: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["view"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,79 @@
.record-card {
height: 90px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 54px;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%);
border: 1px solid rgba(224, 236, 230, 0.92);
box-shadow: 0 10px 24px rgba(29, 58, 47, 0.055);
box-sizing: border-box;
}
.record-card + .record-card {
margin-top: 0;
}
.record-card-image {
width: 64px;
height: 64px;
display: block;
border-radius: 10px;
background: #edf5f1;
}
.record-card-copy {
min-width: 0;
}
.record-card-title,
.record-card-time,
.record-card-status {
display: block;
}
.record-card-title {
color: #162235;
font-size: 15px;
line-height: 20px;
font-weight: 900;
}
.record-card-time {
margin-top: 6px;
color: #7d8c97;
font-size: 11px;
line-height: 15px;
font-weight: 700;
}
.record-card-status {
margin-top: 4px;
color: #10b86b;
font-size: 10px;
line-height: 13px;
font-weight: 900;
}
.record-card-action {
justify-self: end;
width: 48px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #eef8f3;
color: #263f52;
font-size: 11px;
line-height: 28px;
font-weight: 800;
}
.ellipsis-1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,35 @@
<template>
<view class="record-list-wrap">
<view class="record-list">
<RecordCard
v-for="record in records"
:key="record.id"
:record="record"
@view="handleView"
/>
</view>
<text class="record-note">仅保留最近20天生成记录</text>
</view>
</template>
<script setup>
import RecordCard from "../RecordCard/index.vue";
defineProps({
records: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["view"]);
const handleView = (record) => {
emit("view", record);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,19 @@
.record-list-wrap {
margin-top: 0;
}
.record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-note {
display: block;
margin-top: 28px;
color: #9aa8b6;
font-size: 11px;
line-height: 16px;
font-weight: 700;
text-align: center;
}

View File

@@ -0,0 +1,18 @@
import glassBoatPostcard from "@/pages-aigc/home/assets/glass-boat-postcard.gif";
export const aigcRecords = [
{
id: "record-lake-postcard",
title: "鸳鸯湖明信片",
time: "2026-06-29 15:46:12",
status: "图片版 · 已完成 · 可升级动效视频",
image: glassBoatPostcard,
},
{
id: "record-lake-postcard-video",
title: "鸳鸯湖明信片",
time: "2026-06-29 15:58:40",
status: "动效视频版 · 5秒内 · 由图片版升级",
image: glassBoatPostcard,
},
];

View File

@@ -0,0 +1,46 @@
<template>
<view class="aigc-record-page">
<view class="aigc-record-nav">
<TopNavBar
title="最近任务"
background="transparent"
title-color="#162235"
back-icon-color="#162235"
:align-with-menu-button="true"
:z-index="1"
@back="handleBack"
/>
</view>
<RecordList
:records="records"
@view="handleViewRecord"
/>
</view>
</template>
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import RecordList from "./components/RecordList/index.vue";
import { aigcRecords } from "./data/records.js";
const records = aigcRecords;
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleViewRecord = (record) => {
uni.showToast({
title: record.title,
icon: "none",
});
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,41 @@
.aigc-record-page {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
padding: 0 18px;
background: #ffffff;
color: #162235;
box-sizing: border-box;
}
.aigc-record-nav {
position: relative;
min-height: 82px;
margin-bottom: 12px;
}
.aigc-record-nav :deep(.top-nav-bar) {
background: transparent;
}
.aigc-record-nav :deep(.top-nav-bar > .flex) {
padding-right: 0;
padding-left: 0;
}
.aigc-record-nav :deep(.nav-bar-left),
.aigc-record-nav :deep(.nav-bar-right) {
width: 34px;
height: 34px;
}
.aigc-record-nav :deep(.nav-bar-center) {
height: 34px;
}
.aigc-record-nav :deep(.nav-bar-center text) {
font-size: 16px;
line-height: 22px;
font-weight: 900;
}

View File

@@ -158,14 +158,26 @@
{
"root": "pages-aigc",
"pages": [
{
"path": "home",
"style": {
"navigationStyle": "custom"
}
}
]
}
{
"path": "home/home",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "detail/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "record/record",
"style": {
"navigationStyle": "custom"
}
}
]
}
],
"globalStyle": {
"navigationBarTextstyle": "black",