3 Commits

Author SHA1 Message Date
duanshuwen
99151bca58 feat(recharge): add points recharge agreement flow
- add new API request for fetching points top up agreement content
- update recharge footer with agreement checkbox and protocol link
- add agreement popup modal to display full agreement text
- add payment pre-validation to enforce agreement acceptance before payment
- handle loading and error states for agreement retrieval
- adjust recharge footer layout to accommodate new UI elements
2026-07-16 20:34:01 +08:00
duanshuwen
67b1b78f1f feat(aigc): add shared work page and AIGC sharing
- add new shared work page to view shared AIGC generated images and videos
- add createAigcGeneratorTaskShare and getAigcGeneratorTaskShareDetail APIs
- update ResultActions component to add share preparation state and native share support
- remove deprecated SharePreviewPopup component and its associated styles
- update AIGC detail page to generate share keys and adjust native share metadata
- register the new shared work page in pages.json
2026-07-16 13:28:46 +08:00
duanshuwen
dcf67dedfc feat(aigc/detail): add share preview popup
create reusable SharePreviewPopup component for share preview
update ResultActions to emit share event instead of direct native share
add share preview flow in aigc detail page
update native share config with correct image and fallback title
2026-07-16 11:25:39 +08:00
9 changed files with 496 additions and 25 deletions

View File

@@ -4,7 +4,10 @@
{{ saving ? "保存中..." : "保存图片" }}
</view>
<button class="result-action is-secondary" open-type="share" hover-class="none">分享好友</button>
<button class="result-action is-secondary" :class="{ 'is-disabled': sharePreparing }"
:open-type="shareReady ? 'share' : undefined" hover-class="none" @tap="handleShare">
{{ sharePreparing ? "准备分享..." : "分享好友" }}
</button>
</view>
</template>
@@ -14,15 +17,29 @@ const props = defineProps({
type: Boolean,
default: false,
},
shareReady: {
type: Boolean,
default: false,
},
sharePreparing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["save"]);
const emit = defineEmits(["save", "prepare-share"]);
const handleSave = () => {
if (!props.saving) {
emit("save");
}
};
const handleShare = () => {
if (!props.shareReady && !props.sharePreparing) {
emit("prepare-share");
}
};
</script>
<style scoped lang="scss">

View File

@@ -6,7 +6,8 @@
<template v-if="taskDetail">
<ResultPreview v-if="result.cover" :result="result" />
<ResultMeta :items="metaItems" />
<ResultActions :saving="isSaving" @save="handleSave" />
<ResultActions :saving="isSaving" :share-ready="Boolean(shareKey)" :share-preparing="isPreparingShare"
@save="handleSave" @prepare-share="handlePrepareShare" />
<view class="aigc-detail-text-actions">
<view class="aigc-detail-text-action" @tap="handleRegenerate">再生成一版</view>
@@ -15,6 +16,7 @@
</view>
</template>
</view>
</view>
</template>
@@ -23,7 +25,10 @@ import { computed, ref } from "vue";
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { getAigcGeneratorTaskDetail } from "@/request/api/AigcApi.js";
import {
createAigcGeneratorTaskShare,
getAigcGeneratorTaskDetail,
} from "@/request/api/AigcApi.js";
import ResultActions from "./components/ResultActions/index.vue";
import ResultMeta from "./components/ResultMeta/index.vue";
import ResultPreview from "./components/ResultPreview/index.vue";
@@ -32,6 +37,8 @@ const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const taskId = ref("");
const taskDetail = ref(null);
const isSaving = ref(false);
const isPreparingShare = ref(false);
const shareKey = ref("");
const GENERATOR_TYPE_TEXT = {
0: "图片版",
@@ -102,6 +109,8 @@ const metaItems = computed(() => {
];
});
const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || "");
const showPlaceholderToast = (title) => {
uni.showToast({
title,
@@ -119,6 +128,7 @@ const fetchTaskDetail = async () => {
const res = await getAigcGeneratorTaskDetail({ taskId: taskId.value });
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
taskDetail.value = res.data;
prepareShareKey();
return;
}
@@ -147,15 +157,14 @@ onShow(() => {
fetchCurrentCredit();
});
onShareAppMessage(() => {
const shareInfo = {
title: taskDetail.value?.itemTitle,
path: taskId.value
? `/pages-aigc/detail/detail?taskId=${encodeURIComponent(taskId.value)}`
: "/pages-aigc/home/home",
title: taskDetail.value?.itemTitle || "分享旅行作品",
path: shareKey.value
? `/pages-aigc/sharedWork/sharedWork?shareKey=${encodeURIComponent(shareKey.value)}`
: "/pages/index/index",
};
const imageUrl = taskDetail.value?.imageResultUrl || "";
const imageUrl = sharePreviewImage.value;
if (imageUrl) {
shareInfo.imageUrl = imageUrl;
@@ -164,6 +173,39 @@ onShareAppMessage(() => {
return shareInfo;
});
const prepareShareKey = async (showFeedback = false) => {
if (isPreparingShare.value || !taskId.value) return;
if (shareKey.value) return;
isPreparingShare.value = true;
try {
const res = await createAigcGeneratorTaskShare({ taskId: taskId.value });
const nextShareKey = String(res?.data?.shareKey || "").trim();
if (res?.code === 0 && nextShareKey) {
shareKey.value = nextShareKey;
if (showFeedback) {
showPlaceholderToast("分享已准备,请再次点击");
}
return;
}
if (showFeedback) {
showPlaceholderToast(res?.msg || "创建分享失败");
}
} catch (error) {
console.error("创建AIGC任务分享失败", error);
if (showFeedback) {
showPlaceholderToast("创建分享失败");
}
} finally {
isPreparingShare.value = false;
}
};
const handlePrepareShare = () => {
prepareShareKey(true);
};
const handleBack = () => {
const pages = getCurrentPages();

View File

@@ -1,21 +1,31 @@
<template>
<view class="recharge-footer">
<text class="recharge-gain">
支付金额 <text class="recharge-gain-value">¥{{ formattedPrice }}</text>
</text>
<view class="recharge-agreement" :class="{ 'is-disabled': !agreementReady }">
<CheckBox :model-value="agreed" @update:model-value="handleAgreementChange">
<text class="recharge-agreement-text">我已阅读并同意</text>
<text class="recharge-agreement-link" @tap.stop="handleViewAgreement">积分充值协议</text>
</CheckBox>
</view>
<view
class="recharge-pay-button"
:class="{ 'is-disabled': loading || price <= 0 }"
@tap="emit('pay')"
>
{{ loading ? "支付中..." : "立即支付" }}
<view class="recharge-payment-row">
<text class="recharge-gain">
支付金额 <text class="recharge-gain-value">¥{{ formattedPrice }}</text>
</text>
<view
class="recharge-pay-button"
:class="{ 'is-disabled': payDisabled }"
@tap="handlePay"
>
{{ loading ? "支付中..." : "立即支付" }}
</view>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
import CheckBox from "@/components/CheckBox/index.vue";
const props = defineProps({
price: {
@@ -26,11 +36,40 @@ const props = defineProps({
type: Boolean,
default: false,
},
agreed: {
type: Boolean,
default: false,
},
agreementReady: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["pay"]);
const emit = defineEmits(["pay", "update:agreed", "view-agreement"]);
const formattedPrice = computed(() => Number(props.price).toLocaleString("zh-CN"));
const payDisabled = computed(() => {
return props.loading || props.price <= 0 || !props.agreed || !props.agreementReady;
});
const handleAgreementChange = (value) => {
if (!props.agreementReady) {
emit("view-agreement");
return;
}
emit("update:agreed", value);
};
const handleViewAgreement = () => {
emit("view-agreement");
};
const handlePay = () => {
if (props.loading || props.price <= 0) return;
emit("pay");
};
</script>
<style scoped lang="scss">

View File

@@ -1,10 +1,9 @@
.recharge-footer {
position: sticky;
bottom: 12px;
display: grid;
grid-template-columns: 1fr 142px;
align-items: center;
gap: 10px;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
padding: 12px;
border-radius: 20px;
@@ -13,6 +12,38 @@
box-sizing: border-box;
}
.recharge-agreement {
display: flex;
align-items: center;
box-sizing: border-box;
}
.recharge-agreement.is-disabled {
opacity: 0.56;
}
.recharge-agreement-text,
.recharge-agreement-link {
font-size: 14px;
line-height: 24px;
}
.recharge-agreement-text {
color: #667386;
}
.recharge-agreement-link {
color: $theme-color-600;
font-weight: 700;
}
.recharge-payment-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 142px;
align-items: center;
gap: 10px;
}
.recharge-gain {
min-width: 0;
overflow: hidden;

View File

@@ -32,9 +32,20 @@
<RechargeFooter
:price="payPrice"
:loading="paying"
:agreed="agreementAccepted"
:agreement-ready="agreementReady"
@update:agreed="agreementAccepted = $event"
@view-agreement="handleViewAgreement"
@pay="handlePay"
/>
</view>
<AgreePopup
:visible="agreementVisible"
title="积分充值协议"
:agreement="agreementContent"
@close="agreementVisible = false"
/>
</view>
</template>
@@ -43,7 +54,12 @@ import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { getRechargeOptions, rechargeCredit } from "@/request/api/AigcApi.js";
import {
getPointsTopUpAgreement,
getRechargeOptions,
rechargeCredit,
} from "@/request/api/AigcApi.js";
import AgreePopup from "@/pages/login/components/AgreePopup/index.vue";
import BalanceCard from "./components/BalanceCard/index.vue";
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
import RechargeFooter from "./components/RechargeFooter/index.vue";
@@ -55,6 +71,12 @@ const selectedRechargeOption = ref(null);
const customAmount = ref("");
const rechargeMode = ref("package");
const paying = ref(false);
const agreementAccepted = ref(false);
const agreementContent = ref("");
const agreementLoading = ref(false);
const agreementVisible = ref(false);
const agreementReady = computed(() => Boolean(agreementContent.value.trim()));
const getRechargeOptionKey = (option) => {
if (!option) return "";
@@ -97,8 +119,41 @@ const fetchRechargeOptions = async () => {
}
};
const fetchPointsTopUpAgreement = async (showError = false) => {
if (agreementLoading.value) return false;
agreementLoading.value = true;
try {
const res = await getPointsTopUpAgreement();
const content = typeof res?.data === "string" ? res.data.trim() : "";
if (res?.code === 0 && content) {
agreementContent.value = content;
return true;
}
agreementContent.value = "";
agreementAccepted.value = false;
if (showError) {
showToast(res?.msg || "协议加载失败,请重试");
}
} catch (error) {
console.error("获取积分充值协议失败", error);
agreementContent.value = "";
agreementAccepted.value = false;
if (showError) {
showToast("协议加载失败,请重试");
}
} finally {
agreementLoading.value = false;
}
return false;
};
onLoad(() => {
agreementAccepted.value = false;
fetchRechargeOptions();
fetchPointsTopUpAgreement();
});
onShow(() => {
@@ -129,6 +184,23 @@ const handleSelectCustomAmount = () => {
selectedRechargeOption.value = null;
};
const handleViewAgreement = async () => {
if (agreementReady.value) {
agreementVisible.value = true;
return;
}
if (agreementLoading.value) {
showToast("协议加载中,请稍候");
return;
}
const loaded = await fetchPointsTopUpAgreement(true);
if (loaded) {
agreementVisible.value = true;
}
};
const requestWechatPay = (payData) => {
const { nonceStr, packageVal, paySign, signType, timeStamp } = payData || {};
@@ -174,6 +246,21 @@ const handlePay = async () => {
return;
}
if (agreementLoading.value) {
showToast("协议加载中,请稍候");
return;
}
if (!agreementReady.value) {
showToast("协议加载失败,请重试");
return;
}
if (!agreementAccepted.value) {
showToast("请先阅读并同意积分充值协议");
return;
}
paying.value = true;
uni.showLoading({ title: "正在发起支付..." });

View File

@@ -0,0 +1,118 @@
<template>
<view class="shared-work-page">
<view class="shared-work-nav">
<TopNavBar title="旅行作品" background="transparent" title-color="#173a5f" back-icon-color="#173a5f"
:align-with-menu-button="true" :z-index="3" @back="handleBack" />
</view>
<view class="shared-work-scroll" scroll-y>
<template v-if="shareDetail">
<video v-if="isVideo" class="shared-work-media is-video" :src="shareDetail.videoUrl"
:poster="shareDetail.imageUrl" controls object-fit="cover" />
<image v-else-if="shareDetail.imageUrl" class="shared-work-media" :src="shareDetail.imageUrl" mode="widthFix" />
<view class="shared-work-meta">
<text class="shared-work-title">{{ shareDetail.templateName || "旅行作品" }}</text>
<text class="shared-work-type">{{ generatorTypeText }}</text>
</view>
</template>
</view>
<view class="shared-work-cta-wrap">
<button class="shared-work-cta" hover-class="is-pressed" @tap="handleCreateOwn">
我也生成一个
</button>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { checkToken } from "@/hooks/useGoLogin.js";
import { getAigcGeneratorTaskShareDetail } from "@/request/api/AigcApi.js";
const shareKey = ref("");
const shareDetail = ref(null);
const isVideo = computed(() => {
return Number(shareDetail.value?.generatorType) === 1 && Boolean(shareDetail.value?.videoUrl);
});
const generatorTypeText = computed(() => {
const generatorType = shareDetail.value?.generatorType;
if (Number(generatorType) === 0) return "图片版";
if (Number(generatorType) === 1) return "视频版";
return generatorType === undefined || generatorType === null || generatorType === ""
? "生成作品"
: `类型${generatorType}`;
});
const showToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const fetchShareDetail = async () => {
uni.showLoading({
title: "加载中",
mask: true,
});
try {
const res = await getAigcGeneratorTaskShareDetail({ shareKey: shareKey.value });
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
shareDetail.value = res.data;
return;
}
shareDetail.value = null;
showToast(res?.msg || "获取分享作品失败");
} catch (error) {
console.error("获取AIGC分享作品失败", error);
shareDetail.value = null;
showToast("获取分享作品失败");
} finally {
uni.hideLoading();
}
};
onLoad((query = {}) => {
shareKey.value = String(query.shareKey || "").trim();
if (!shareKey.value) {
showToast("缺少分享标识");
return;
}
fetchShareDetail();
});
const openAigcHome = () => {
uni.reLaunch({
url: "/pages-aigc/home/home",
fail: () => showToast("打开模板列表失败"),
});
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
return;
}
openAigcHome();
};
const handleCreateOwn = async () => {
await checkToken();
openAigcHome();
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,116 @@
.shared-work-page {
position: relative;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
color: #173a5f;
background: #c5e5f8;
box-sizing: border-box;
}
.shared-work-nav {
position: relative;
z-index: 3;
flex: 0 0 auto;
background: rgba(197, 229, 248, 0.96);
}
.shared-work-nav :deep(.top-nav-bar > .flex) {
padding-right: 12px;
padding-left: 12px;
}
.shared-work-nav :deep(.nav-bar-center text) {
font-size: 17px;
line-height: 23px;
font-weight: 900;
}
.shared-work-scroll {
flex: 1 1 0;
width: 100%;
height: 0;
min-height: 0;
padding-bottom: 104px;
box-sizing: border-box;
}
.shared-work-media {
width: 100%;
display: block;
background: #b8dced;
}
.shared-work-media.is-video {
height: min(500px, 66vh);
}
.shared-work-meta {
padding: 18px 18px 118px;
}
.shared-work-title,
.shared-work-type {
display: block;
}
.shared-work-title {
overflow: hidden;
color: #173a5f;
font-size: 20px;
line-height: 27px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.shared-work-type {
margin-top: 4px;
color: #0b7b60;
font-size: 12px;
line-height: 17px;
font-weight: 800;
}
.shared-work-empty {
min-height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.shared-work-cta-wrap {
position: fixed;
z-index: 4;
right: 0;
bottom: 0;
left: 0;
padding: 18px 16px calc(14px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(197, 229, 248, 0) 0%, #c5e5f8 34%);
box-sizing: border-box;
}
.shared-work-cta {
width: 100%;
height: 54px;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: #20c99a;
color: #173a5f;
font-size: 16px;
line-height: 54px;
font-weight: 900;
box-shadow: 0 10px 22px rgba(32, 201, 154, 0.2);
}
.shared-work-cta::after {
border: 0;
}
.shared-work-cta.is-pressed {
opacity: 0.84;
}

View File

@@ -182,6 +182,12 @@
"navigationStyle": "custom"
}
},
{
"path": "sharedWork/sharedWork",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "recharge/recharge",
"style": {

View File

@@ -36,6 +36,18 @@ function getCreditLedgerPage(args) {
return request.post("/hotelBiz/credit/ledger/page", args);
}
function getPointsTopUpAgreement() {
return request.get("/hotelBiz/mainScene/pointsTopUpAgreement", {});
}
function createAigcGeneratorTaskShare(args) {
return request.post("/hotelBiz/aigcGeneratorTask/share/create", args);
}
function getAigcGeneratorTaskShareDetail(args) {
return request.post("/hotelBiz/aigcGeneratorTask/share/detail", args, { noToken: true });
}
export {
getAigcTemplateList,
getAigcTemplateItemList,
@@ -46,4 +58,7 @@ export {
getRechargeOptions,
rechargeCredit,
getCreditLedgerPage,
getPointsTopUpAgreement,
createAigcGeneratorTaskShare,
getAigcGeneratorTaskShareDetail,
};