feat(aigc): add template, recharge & points pages

This commit adds the full AIGC feature set including template usage flow, points recharge and transaction ledger pages, along with all supporting components, styles and data files. It also updates the home page navigation and registers all new pages in the project's pages configuration.
This commit is contained in:
duanshuwen
2026-07-05 19:34:55 +08:00
parent f47f68e19b
commit b381c712df
37 changed files with 1840 additions and 1 deletions

View File

@@ -45,7 +45,10 @@ const handleRecharge = () => {
};
const handleSelectTemplate = (template) => {
showPlaceholderToast(template.title);
uni.navigateTo({
url: `/pages-aigc/useTemplate/useTemplate?templateId=${encodeURIComponent(template.id || "")}`,
fail: () => showPlaceholderToast(template.title),
});
};
</script>

View File

@@ -0,0 +1,31 @@
<template>
<view class="ledger-card">
<view class="ledger-card-copy">
<text class="ledger-title">{{ record.type }}</text>
<text class="ledger-task">{{ record.task }}</text>
<text class="ledger-time">{{ record.time }}</text>
</view>
<text :class="['ledger-delta', isPlus ? 'is-plus' : 'is-minus']">
{{ record.delta }}
</text>
<text class="ledger-status">{{ record.status }}</text>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
record: {
type: Object,
default: () => ({}),
},
});
const isPlus = computed(() => String(props.record.delta || "").startsWith("+"));
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,73 @@
.ledger-card {
position: relative;
min-height: 78px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
padding: 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.84);
box-sizing: border-box;
}
.ledger-card-copy {
min-width: 0;
padding-right: 48px;
}
.ledger-title,
.ledger-task,
.ledger-time {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-title {
color: #172033;
font-size: 14px;
line-height: 20px;
font-weight: 900;
}
.ledger-task {
margin-top: 2px;
color: #667386;
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
.ledger-time {
margin-top: 2px;
color: #7a9abd;
font-size: 10px;
line-height: 13px;
font-weight: 900;
}
.ledger-delta {
color: #172033;
font-size: 15px;
line-height: 20px;
font-weight: 900;
}
.ledger-delta.is-plus {
color: #12a765;
}
.ledger-delta.is-minus {
color: #172033;
}
.ledger-status {
position: absolute;
right: 14px;
bottom: 12px;
color: #7a8596;
font-size: 10px;
line-height: 13px;
font-weight: 900;
}

View File

@@ -0,0 +1,24 @@
<template>
<view class="ledger-list">
<LedgerCard
v-for="record in records"
:key="record.id"
:record="record"
/>
</view>
</template>
<script setup>
import LedgerCard from "../LedgerCard/index.vue";
defineProps({
records: {
type: Array,
default: () => [],
},
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,5 @@
.ledger-list {
display: grid;
gap: 10px;
margin-top: 12px;
}

View File

@@ -0,0 +1,42 @@
export const pointsLedgerRecords = [
{
id: "new-user-gift",
type: "新用户赠送",
task: "试用权益",
time: "2026-06-29 09:41:00",
delta: "+300",
status: "已到账",
},
{
id: "recharge-1000",
type: "优惠充值",
task: "实付 ¥9.5",
time: "2026-06-29 10:18:25",
delta: "+1000",
status: "已到账",
},
{
id: "video-freeze",
type: "动效视频版冻结",
task: "鸳鸯湖明信片",
time: "2026-06-29 15:45:58",
delta: "-200",
status: "冻结中",
},
{
id: "video-success",
type: "动效视频版成功",
task: "鸳鸯湖明信片",
time: "2026-06-29 15:46:12",
delta: "-200",
status: "已扣除",
},
{
id: "video-refund",
type: "动效视频版失败退回",
task: "卧龙潭旅行成片",
time: "2026-06-29 16:05:30",
delta: "+200",
status: "已退回",
},
];

View File

@@ -0,0 +1,36 @@
<template>
<view class="points-details-page">
<TopNavBar
title="积分明细"
background="transparent"
title-color="#172033"
back-icon-color="#172033"
:align-with-menu-button="true"
:z-index="3"
@back="handleBack"
/>
<view class="points-details-content">
<LedgerList :records="records" />
</view>
</view>
</template>
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import LedgerList from "./components/LedgerList/index.vue";
import { pointsLedgerRecords } from "./data/ledger.js";
const records = pointsLedgerRecords;
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,15 @@
.points-details-page {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
color: #172033;
background: linear-gradient(180deg, #effaf5 0%, #eef8ff 100%);
box-sizing: border-box;
}
.points-details-content {
min-height: 0;
padding: 0 18px 48px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,26 @@
<template>
<view class="balance-card">
<view class="balance-copy">
<text class="balance-label">当前可用积分</text>
<text class="balance-value">{{ balance.points }}</text>
<text class="balance-rule">{{ balance.ruleText }}</text>
</view>
<view class="balance-ledger" @tap="emit('ledger')">积分明细</view>
</view>
</template>
<script setup>
defineProps({
balance: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["ledger"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,60 @@
.balance-card {
min-height: 116px;
display: grid;
grid-template-columns: 1fr 78px;
align-items: center;
gap: 12px;
padding: 18px;
border-radius: 22px;
background: #172951;
color: #ffffff;
box-sizing: border-box;
}
.balance-copy {
min-width: 0;
}
.balance-label,
.balance-value,
.balance-rule {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.balance-label {
color: rgba(255, 255, 255, 0.66);
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
.balance-value {
margin: 4px 0;
color: #ffffff;
font-size: 34px;
line-height: 39px;
font-weight: 900;
}
.balance-rule {
color: rgba(255, 255, 255, 0.66);
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
.balance-ledger {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-size: 12px;
line-height: 34px;
font-weight: 900;
}

View File

@@ -0,0 +1,54 @@
<template>
<view :class="['custom-card', { 'is-selected': selected }]" @tap="emit('select')">
<text class="custom-title">自选金额</text>
<label class="custom-input-wrap">
<text class="custom-currency">¥</text>
<input
class="custom-input"
type="number"
:value="modelValue"
@focus="emit('select')"
@input="handleInput"
/>
</label>
<view class="custom-hint">
<text> 1 = 100 积分换算</text>
<text>当前可得 {{ currentPoints }} 积分</text>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "select"]);
const currentPoints = computed(() => {
const amount = Number.parseInt(props.modelValue || "0", 10);
return Number.isNaN(amount) ? 0 : amount * 100;
});
const handleInput = (event) => {
const rawValue = event?.detail?.value ?? event?.target?.value ?? "";
const value = String(rawValue).replace(/\D/g, "");
emit("select");
emit("update:modelValue", value);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,84 @@
.custom-card {
min-height: 90px;
display: grid;
grid-template-columns: minmax(0, 1fr) 118px;
grid-template-rows: 28px auto;
column-gap: 14px;
row-gap: 2px;
align-items: center;
margin-top: 12px;
padding: 14px 16px;
border: 1.5px solid transparent;
border-radius: 18px;
background: rgba(255, 255, 255, 0.8);
box-sizing: border-box;
}
.custom-card.is-selected {
border-color: #20cf75;
}
.custom-title {
grid-column: 1;
grid-row: 1;
display: block;
overflow: hidden;
color: #172033;
font-size: 14px;
line-height: 28px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-input-wrap {
grid-column: 2;
grid-row: 1 / 3;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 0 0 18px;
border-left: 1px solid rgba(82, 96, 115, 0.12);
color: #172033;
font-size: 17px;
line-height: 56px;
font-weight: 900;
box-sizing: border-box;
}
.custom-currency {
flex: 0 0 auto;
}
.custom-input {
width: 52px;
height: 56px;
min-height: 56px;
border: 0;
background: transparent;
color: #172033;
font-size: 18px;
line-height: 56px;
font-weight: 900;
}
.custom-hint {
grid-column: 1;
grid-row: 2;
min-width: 0;
display: grid;
gap: 1px;
color: #7a8596;
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
.custom-hint text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,34 @@
<template>
<view class="recharge-footer">
<text class="recharge-gain">
本次获得 <text class="recharge-gain-value">{{ points }}</text> 积分
</text>
<view class="recharge-pay-button" @tap="emit('pay')">
支付 ¥{{ formattedPrice }}
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
points: {
type: Number,
default: 0,
},
price: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["pay"]);
const formattedPrice = computed(() => Number(props.price).toLocaleString("zh-CN"));
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,44 @@
.recharge-footer {
position: sticky;
bottom: 12px;
display: grid;
grid-template-columns: 1fr 142px;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 24px rgba(18, 48, 56, 0.1);
box-sizing: border-box;
}
.recharge-gain {
min-width: 0;
overflow: hidden;
color: #667386;
font-size: 12px;
line-height: 18px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.recharge-gain-value {
color: #172033;
font-weight: 900;
}
.recharge-pay-button {
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #000000;
color: #ffffff;
font-size: 14px;
line-height: 46px;
font-weight: 900;
white-space: nowrap;
}

View File

@@ -0,0 +1,42 @@
<template>
<view class="package-section">
<text class="package-title">优惠充值</text>
<view class="package-list">
<view
v-for="item in packages"
:key="item.id"
:class="['package-card', { 'is-selected': item.id === selectedId }]"
@tap="emit('select', item)"
>
<view class="package-copy">
<text class="package-points">{{ item.points }}积分</text>
<text class="package-origin">原价 ¥{{ item.originalPrice }} · {{ item.discount }}</text>
</view>
<text class="package-price">¥{{ formatPrice(item.price) }}</text>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
packages: {
type: Array,
default: () => [],
},
selectedId: {
type: String,
default: "",
},
});
const emit = defineEmits(["select"]);
const formatPrice = (price) => Number(price).toLocaleString("zh-CN");
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,71 @@
.package-section {
margin-top: 18px;
}
.package-title {
display: block;
margin-bottom: 10px;
color: #172033;
font-size: 15px;
line-height: 20px;
font-weight: 900;
}
.package-list {
display: grid;
gap: 10px;
}
.package-card {
height: 74px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 12px 14px;
border: 1.5px solid transparent;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
text-align: left;
box-sizing: border-box;
}
.package-card.is-selected {
border-color: #20cf75;
background: rgba(255, 255, 255, 0.72);
}
.package-copy {
min-width: 0;
}
.package-points,
.package-origin,
.package-price {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-points {
color: #172033;
font-size: 16px;
line-height: 22px;
font-weight: 900;
}
.package-origin {
margin-top: 6px;
color: #7a8596;
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
.package-price {
color: #12a765;
font-size: 16px;
line-height: 22px;
font-weight: 900;
}

View File

@@ -0,0 +1,28 @@
export const rechargeBalance = {
points: 0,
ruleText: "图片版 100积分/次 · 动效视频版 200积分/次",
};
export const rechargePackages = [
{
id: "points-1000",
points: 1000,
price: 9.5,
originalPrice: 10,
discount: "9.5折",
},
{
id: "points-3000",
points: 3000,
price: 27,
originalPrice: 30,
discount: "9折",
},
{
id: "points-5000",
points: 5000,
price: 42.5,
originalPrice: 50,
discount: "8.5折",
},
];

View File

@@ -0,0 +1,117 @@
<template>
<view class="aigc-recharge-page">
<TopNavBar
title="积分充值"
title-align="left"
background="transparent"
title-color="#172033"
back-icon-color="#172033"
:align-with-menu-button="true"
:z-index="3"
@back="handleBack"
/>
<view class="aigc-recharge-content">
<BalanceCard
:balance="balance"
@ledger="handleOpenLedger"
/>
<RechargePackageList
:packages="packages"
:selected-id="selectedId"
@select="handleSelectPackage"
/>
<CustomAmountCard
v-model="customAmount"
:selected="selectedId === customId"
@select="handleSelectCustom"
/>
<RechargeFooter
:points="payPoints"
:price="payPrice"
@pay="handlePay"
/>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue";
import BalanceCard from "./components/BalanceCard/index.vue";
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
import RechargeFooter from "./components/RechargeFooter/index.vue";
import RechargePackageList from "./components/RechargePackageList/index.vue";
import { rechargeBalance, rechargePackages } from "./data/packages.js";
const customId = "custom";
const balance = rechargeBalance;
const packages = rechargePackages;
const selectedId = ref("points-3000");
const customAmount = ref("2");
const selectedPackage = computed(() =>
packages.find((item) => item.id === selectedId.value)
);
const customPrice = computed(() => {
const amount = Number.parseInt(customAmount.value || "0", 10);
return Number.isNaN(amount) ? 0 : amount;
});
const payPoints = computed(() => {
if (selectedId.value === customId) {
return customPrice.value * 100;
}
return selectedPackage.value?.points || 0;
});
const payPrice = computed(() => {
if (selectedId.value === customId) {
return customPrice.value;
}
return selectedPackage.value?.price || 0;
});
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleOpenLedger = () => {
uni.navigateTo({
url: "/pages-aigc/pointsDetails/pointsDetails",
fail: () => showPlaceholderToast("积分明细"),
});
};
const handleSelectPackage = (item) => {
selectedId.value = item.id;
};
const handleSelectCustom = () => {
selectedId.value = customId;
};
const handlePay = () => {
showPlaceholderToast(`支付 ¥${payPrice.value}`);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,31 @@
.aigc-recharge-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-recharge-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 24px 12px;
scrollbar-width: none;
box-sizing: border-box;
}
.aigc-recharge-content::-webkit-scrollbar {
display: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

View File

@@ -0,0 +1,87 @@
<template>
<uni-popup
ref="popupRef"
type="bottom"
background-color="transparent"
mask-background-color="rgba(0, 0, 0, 0)"
:safe-area="false"
:is-mask-click="false"
>
<view class="photo-guide-panel">
<view class="photo-guide-close" @tap="emit('close')">
<uni-icons type="closeempty" size="30" color="#8fa2ba" />
</view>
<text class="photo-guide-title">选图说明</text>
<view class="photo-guide-section">
<text class="photo-guide-section-title">正确照片示例</text>
<view class="photo-guide-grid">
<view
v-for="item in positiveExamples"
:key="item.id"
class="photo-example-card is-positive"
:class="`example-${item.variant}`"
>
<view class="photo-example-thumb">
<image class="photo-example-image" :src="avatarSrc" mode="aspectFill" />
</view>
<text class="photo-example-caption">{{ item.label }}</text>
</view>
</view>
</view>
<view class="photo-guide-section">
<text class="photo-guide-section-title">错误照片示例</text>
<view class="photo-guide-grid">
<view
v-for="item in negativeExamples"
:key="item.id"
class="photo-example-card is-negative"
:class="`example-${item.variant}`"
>
<view class="photo-example-thumb">
<image class="photo-example-image" :src="avatarSrc" mode="aspectFill" />
</view>
<text class="photo-example-caption">{{ item.label }}</text>
</view>
</view>
</view>
<view class="photo-guide-primary" @tap="emit('confirm')">
我已了解开始选图
</view>
</view>
</uni-popup>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
defineProps({
avatarSrc: {
type: String,
default: "",
},
positiveExamples: {
type: Array,
default: () => [],
},
negativeExamples: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "confirm"]);
const popupRef = ref(null);
onMounted(async () => {
await nextTick();
popupRef.value?.open();
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,145 @@
.photo-guide-panel {
position: relative;
width: 100vw;
max-width: 100%;
min-height: 448px;
margin-top: -34px;
padding: 30px 26px 24px;
border-radius: 28px 28px 0 0;
background: #ffffff;
box-shadow: 0 -8px 28px rgba(18, 35, 48, 0.08);
box-sizing: border-box;
}
.photo-guide-close {
position: absolute;
left: 24px;
top: 28px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #9aa4b2;
}
.photo-guide-title {
display: block;
margin: 6px 0 24px;
color: #111827;
font-size: 18px;
line-height: 24px;
font-weight: 900;
text-align: center;
}
.photo-guide-section + .photo-guide-section {
margin-top: 22px;
}
.photo-guide-section-title {
display: block;
margin: 0 0 12px;
color: #667386;
font-size: 13px;
line-height: 18px;
font-weight: 800;
}
.photo-guide-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.photo-example-card {
min-width: 0;
text-align: center;
}
.photo-example-thumb {
width: 74px;
height: 74px;
display: block;
overflow: hidden;
margin: 0 auto;
border-radius: 12px;
background: #f2f4f7;
}
.photo-example-image {
width: 98px;
height: 150px;
display: block;
margin: -24px 0 0 -12px;
}
.example-scene .photo-example-image,
.example-group .photo-example-image {
width: 112px;
margin-left: -18px;
}
.example-blur .photo-example-image {
filter: blur(1.6px);
}
.example-cover .photo-example-image {
transform: translateY(8px) scale(1.08);
}
.photo-example-caption {
position: relative;
display: inline-block;
max-width: 100%;
overflow: hidden;
margin-top: 8px;
padding-left: 15px;
color: #16bf76;
font-size: 11px;
line-height: 15px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.photo-example-caption::before {
content: "";
position: absolute;
left: 0;
top: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #16bf76;
color: #ffffff;
font-size: 9px;
line-height: 12px;
text-align: center;
}
.is-negative .photo-example-caption {
color: #ff504f;
}
.is-negative .photo-example-caption::before {
content: "×";
background: #ff504f;
}
.photo-guide-primary {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 24px;
border-radius: 999px;
background: #061e34;
color: #ffffff;
font-size: 16px;
line-height: 22px;
font-weight: 900;
box-sizing: border-box;
}

View File

@@ -0,0 +1,53 @@
<template>
<uni-popup
ref="popupRef"
type="bottom"
background-color="transparent"
mask-background-color="rgba(0, 0, 0, 0)"
:safe-area="false"
:is-mask-click="false"
>
<view class="photo-pick-drawer">
<view class="photo-pick-close" @tap="emit('close')">
<uni-icons type="closeempty" size="30" color="#8fa2ba" />
</view>
<text class="photo-pick-title">选择照片</text>
<view class="photo-pick-card">
<view class="photo-pick-plus">+</view>
<text class="photo-pick-card-title">上传照片或视频素材</text>
<text class="photo-pick-card-desc">
选择后会自动进行清晰度人脸数量和遮挡检测
</text>
</view>
<view class="photo-pick-actions">
<view class="photo-pick-action" hover-class="is-pressed" @tap="emit('camera')">
拍照
</view>
<view class="photo-pick-action" hover-class="is-pressed" @tap="emit('album')">
相册
</view>
</view>
<text class="photo-pick-hint">建议使用半身照避免墨镜口罩强逆光</text>
</view>
</uni-popup>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
const emit = defineEmits(["close", "camera", "album"]);
const popupRef = ref(null);
onMounted(async () => {
await nextTick();
popupRef.value?.open();
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,122 @@
.photo-pick-drawer {
position: relative;
width: 100vw;
max-width: 100%;
min-height: 336px;
margin-top: -34px;
padding: 22px 18px 20px;
border-radius: 26px 26px 0 0;
background: #ffffff;
box-shadow: 0 -8px 28px rgba(18, 35, 48, 0.08);
box-sizing: border-box;
}
.photo-pick-close {
position: absolute;
left: 18px;
top: 22px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.photo-pick-title {
display: block;
margin: 1px 0 17px;
color: #111827;
font-size: 18px;
line-height: 24px;
font-weight: 900;
text-align: center;
}
.photo-pick-card {
width: 100%;
min-height: 126px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 18px;
border: 1.5px dashed rgba(32, 207, 117, 0.46);
border-radius: 20px;
background: #f2fbf6;
text-align: center;
box-sizing: border-box;
}
.photo-pick-plus {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #20cf75;
color: #ffffff;
font-size: 25px;
line-height: 42px;
font-weight: 500;
}
.photo-pick-card-title,
.photo-pick-card-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-pick-card-title {
max-width: 230px;
color: #172033;
font-size: 16px;
line-height: 22px;
font-weight: 900;
white-space: nowrap;
}
.photo-pick-card-desc {
max-width: 230px;
color: #667386;
font-size: 12px;
line-height: 18px;
font-weight: 800;
}
.photo-pick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.photo-pick-action {
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: #f5f8f7;
color: #172033;
font-size: 14px;
line-height: 20px;
font-weight: 900;
}
.photo-pick-action.is-pressed {
opacity: 0.82;
}
.photo-pick-hint {
display: block;
margin: 10px 0 0;
color: #7a8596;
font-size: 12px;
line-height: 18px;
font-weight: 800;
text-align: center;
}

View File

@@ -0,0 +1,28 @@
<template>
<view
class="template-version-hero"
:style="{ '--template-accent': template.accent || '#0a4d46' }"
>
<image class="template-version-image" :src="template.cover" mode="aspectFill" />
<view class="template-version-shade" />
<view class="template-version-copy">
<text class="template-version-badge">{{ template.badge || "同源双版本" }}</text>
<text class="template-version-title">{{ template.title }}</text>
<text class="template-version-desc">{{ template.desc }}</text>
</view>
</view>
</template>
<script setup>
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,84 @@
.template-version-hero {
position: relative;
height: 360px;
overflow: hidden;
border-radius: 24px;
background: var(--template-accent, #0a4d46);
color: #ffffff;
box-shadow: 0 16px 30px rgba(15, 29, 37, 0.1);
}
.template-version-image {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.template-version-shade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
180deg,
rgba(8, 22, 28, 0.05) 0%,
rgba(8, 22, 28, 0.18) 46%,
rgba(8, 22, 28, 0.72) 100%
);
}
.template-version-copy {
position: absolute;
z-index: 1;
right: 18px;
bottom: 18px;
left: 18px;
min-width: 0;
}
.template-version-badge {
height: 22px;
display: inline-flex;
align-items: center;
max-width: 132px;
overflow: hidden;
padding: 0 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: #172033;
font-size: 10px;
line-height: 22px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-version-title,
.template-version-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-version-title {
margin-top: 10px;
color: #ffffff;
font-size: 24px;
line-height: 30px;
font-weight: 900;
}
.template-version-desc {
margin-top: 5px;
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 18px;
font-weight: 800;
}

View File

@@ -0,0 +1,42 @@
<template>
<view
class="upload-template-preview"
:style="{ '--upload-accent': template.accent || '#0a4d46' }"
>
<image class="upload-template-image" :src="template.cover" mode="aspectFill" />
<view class="upload-template-shade" />
<view class="upload-template-copy">
<text class="upload-template-badge">{{ version.label || "图片版" }}</text>
<text class="upload-template-title">{{ template.title }}</text>
<text class="upload-template-desc">{{ previewDesc }}</text>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
template: {
type: Object,
default: () => ({}),
},
version: {
type: Object,
default: () => ({}),
},
});
const previewDesc = computed(() => {
if (props.template.desc) {
return props.template.desc;
}
return "上传一张清晰半身照,生成旅行内容";
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,85 @@
.upload-template-preview {
position: relative;
height: 286px;
overflow: hidden;
border-radius: 0 0 26px 26px;
background: var(--upload-accent, #0a4d46);
color: #ffffff;
}
.upload-template-image {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.upload-template-shade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
180deg,
rgba(8, 22, 28, 0.04) 0%,
rgba(8, 22, 28, 0.16) 46%,
rgba(8, 22, 28, 0.72) 100%
);
}
.upload-template-copy {
position: absolute;
z-index: 1;
right: 16px;
bottom: 92px;
left: 16px;
min-width: 0;
color: #ffffff;
}
.upload-template-badge {
height: 20px;
display: inline-flex;
align-items: center;
max-width: 130px;
overflow: hidden;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #172033;
font-size: 9px;
line-height: 20px;
font-weight: 900;
letter-spacing: 0.7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-template-title,
.upload-template-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-template-title {
margin-top: 10px;
color: #ffffff;
font-size: 24px;
line-height: 30px;
font-weight: 900;
}
.upload-template-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,34 @@
<template>
<view
class="version-option-card"
:class="{ 'is-selected': selected }"
hover-class="is-pressed"
@tap="emit('select', option)"
>
<view class="version-option-copy">
<text class="version-option-title">{{ option.label }}</text>
<text class="version-option-desc">{{ option.desc }}</text>
</view>
<text class="version-option-cost">{{ option.cost }}积分</text>
</view>
</template>
<script setup>
defineProps({
option: {
type: Object,
default: () => ({}),
},
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,59 @@
.version-option-card {
min-height: 84px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 20px;
background: rgba(255, 255, 255, 0.82);
text-align: left;
box-shadow: 0 8px 18px rgba(18, 48, 56, 0.05);
box-sizing: border-box;
transition: border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
}
.version-option-card.is-selected {
border-color: rgba(32, 207, 117, 0.64);
box-shadow: 0 10px 22px rgba(32, 207, 117, 0.1);
}
.version-option-card.is-pressed {
opacity: 0.82;
}
.version-option-copy {
min-width: 0;
}
.version-option-title,
.version-option-desc,
.version-option-cost {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-option-title {
color: #172033;
font-size: 15px;
line-height: 21px;
font-weight: 900;
}
.version-option-desc {
margin-top: 4px;
color: #7a8596;
font-size: 11px;
line-height: 16px;
font-weight: 800;
}
.version-option-cost {
color: #12a765;
font-size: 15px;
line-height: 20px;
font-weight: 900;
}

View File

@@ -0,0 +1,32 @@
<template>
<view class="version-option-list">
<VersionOptionCard
v-for="option in options"
:key="option.value"
:option="option"
:selected="option.value === selectedValue"
@select="emit('select', $event)"
/>
</view>
</template>
<script setup>
import VersionOptionCard from "../VersionOptionCard/index.vue";
defineProps({
options: {
type: Array,
default: () => [],
},
selectedValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["select"]);
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,5 @@
.version-option-list {
display: grid;
gap: 10px;
margin-top: 14px;
}

View File

@@ -0,0 +1,35 @@
export const positiveExamples = [
{
id: "portrait",
label: "单人正脸",
variant: "portrait",
},
{
id: "smile",
label: "表情自然",
variant: "smile",
},
{
id: "scene",
label: "背景干净",
variant: "scene",
},
];
export const negativeExamples = [
{
id: "blur",
label: "非清晰正脸",
variant: "blur",
},
{
id: "cover",
label: "面部有遮挡",
variant: "cover",
},
{
id: "group",
label: "多人合照",
variant: "group",
},
];

View File

@@ -0,0 +1,14 @@
export const versionOptions = [
{
value: "image",
label: "图片版",
desc: "生成一张高清旅行图片,适合保存和分享。",
cost: 100,
},
{
value: "video",
label: "动效视频版 · 5秒内",
desc: "先生成图片底图,再升级为 5秒内动效视频。",
cost: 200,
},
];

View File

@@ -0,0 +1,40 @@
.aigc-use-template-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-use-template-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 24px 18px;
box-sizing: border-box;
}
.aigc-use-template-content.is-upload-guide {
padding: 0 12px;
}
.aigc-use-template-note {
display: block;
margin: 12px 4px 0;
color: #7a8596;
font-size: 11px;
line-height: 17px;
font-weight: 800;
text-align: center;
}

View File

@@ -0,0 +1,136 @@
<template>
<view class="aigc-use-template-page">
<AigcTopBar
:points="pointBalance"
recharge-label="充值"
@back="handleBack"
@history="handleHistory"
@recharge="handleRecharge"
/>
<view
class="aigc-use-template-content"
:class="{ 'is-upload-guide': currentStep !== 'version' }"
>
<template v-if="currentStep === 'version'">
<TemplateVersionHero :template="currentTemplate" />
<VersionOptionList
:options="versionOptions"
:selected-value="selectedVersion"
@select="handleSelectVersion"
/>
<text class="aigc-use-template-note">
动效视频版会先生成图片底图再升级为 5秒内动效视频
</text>
</template>
<template v-else>
<UploadTemplatePreview
:template="currentTemplate"
:version="currentVersion"
/>
<PhotoGuidePanel
v-if="currentStep === 'uploadGuide'"
:avatar-src="guideAvatar"
:positive-examples="positiveExamples"
:negative-examples="negativeExamples"
@close="handleCloseGuide"
@confirm="handleConfirmGuide"
/>
<PhotoPickDrawer
v-else
@close="handleCloseGuide"
@camera="handlePickCamera"
@album="handlePickAlbum"
/>
</template>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { aigcTemplates } from "@/pages-aigc/home/data/templates.js";
import guideAvatar from "./assets/xiaoqi-avatar.png";
import PhotoGuidePanel from "./components/PhotoGuidePanel/index.vue";
import PhotoPickDrawer from "./components/PhotoPickDrawer/index.vue";
import TemplateVersionHero from "./components/TemplateVersionHero/index.vue";
import UploadTemplatePreview from "./components/UploadTemplatePreview/index.vue";
import VersionOptionList from "./components/VersionOptionList/index.vue";
import { negativeExamples, positiveExamples } from "./data/photoGuideExamples.js";
import { versionOptions } from "./data/versionOptions.js";
const pointBalance = ref(0);
const templateId = ref("");
const selectedVersion = ref("image");
const currentStep = ref("version");
const currentTemplate = computed(() => {
return aigcTemplates.find((item) => item.id === templateId.value) || aigcTemplates[0] || {};
});
const currentVersion = computed(() => {
return versionOptions.find((item) => item.value === selectedVersion.value) || versionOptions[0] || {};
});
onLoad((query = {}) => {
templateId.value = query.templateId || "";
});
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleHistory = () => {
uni.navigateTo({
url: "/pages-aigc/record/record",
fail: () => showPlaceholderToast("最近任务"),
});
};
const handleRecharge = () => {
uni.navigateTo({
url: "/pages-aigc/recharge/recharge",
fail: () => showPlaceholderToast("积分充值"),
});
};
const handleSelectVersion = (option) => {
selectedVersion.value = option.value;
currentStep.value = "uploadGuide";
};
const handleCloseGuide = () => {
currentStep.value = "version";
};
const handleConfirmGuide = () => {
currentStep.value = "photoPick";
};
const handlePickCamera = () => {
showPlaceholderToast("拍照");
};
const handlePickAlbum = () => {
showPlaceholderToast("相册");
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -164,12 +164,30 @@
"navigationStyle": "custom"
}
},
{
"path": "useTemplate/useTemplate",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "detail/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "recharge/recharge",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pointsDetails/pointsDetails",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "record/record",
"style": {