feat(aigc): add video save support and improve consent flow

- add support for saving both image and video AIGC generation results
- refactor ResultActions component to accept mediaType prop and use dynamic save button text
- remove AIGC consent dialog from ChatQuickAccess and implement it on AIGC home page
- add WeChat mini-program privacy authorization check before saving media
- create reusable AigcConsentDialog component with proper styling
This commit is contained in:
duanshuwen
2026-07-16 21:49:00 +08:00
parent 38dbf7afc8
commit 52b6722842
6 changed files with 188 additions and 66 deletions

View File

@@ -1,7 +1,7 @@
<template>
<view class="result-actions">
<view :class="['result-action', 'is-primary', { 'is-disabled': saving }]" @tap="handleSave">
{{ saving ? "保存中..." : "保存图片" }}
{{ saving ? "保存中..." : saveButtonText }}
</view>
<button class="result-action is-secondary" :class="{ 'is-disabled': sharePreparing }"
@@ -12,11 +12,17 @@
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
saving: {
type: Boolean,
default: false,
},
mediaType: {
type: String,
default: "image",
},
shareReady: {
type: Boolean,
default: false,
@@ -28,6 +34,9 @@ const props = defineProps({
});
const emit = defineEmits(["save", "prepare-share"]);
const saveButtonText = computed(() =>
props.mediaType === "video" ? "保存视频" : "保存图片"
);
const handleSave = () => {
if (!props.saving) {

View File

@@ -6,8 +6,8 @@
<template v-if="taskDetail">
<ResultPreview v-if="result.cover" :result="result" />
<ResultMeta :items="metaItems" />
<ResultActions :saving="isSaving" :share-ready="Boolean(shareKey)" :share-preparing="isPreparingShare"
@save="handleSave" @prepare-share="handlePrepareShare" />
<ResultActions :saving="isSaving" :media-type="result.mediaType" :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>
@@ -17,12 +17,15 @@
</template>
</view>
<Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree"
@disagree="handlePrivacyDisagree" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
import Privacy from "@/components/Privacy/index.vue";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import {
@@ -39,6 +42,9 @@ const taskDetail = ref(null);
const isSaving = ref(false);
const isPreparingShare = ref(false);
const shareKey = ref("");
const privacyVisible = ref(false);
const privacyContractName = ref("隐私保护指引");
const pendingSaveAfterPrivacy = ref(false);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
@@ -220,7 +226,7 @@ const handleRecharge = () => {
showPlaceholderToast("积分充值");
};
const downloadImage = (url) =>
const downloadMedia = (url) =>
new Promise((resolve, reject) => {
if (!/^https?:\/\//i.test(url)) {
resolve(url);
@@ -249,6 +255,46 @@ const saveImageToAlbum = (filePath) =>
});
});
const saveVideoToAlbum = (filePath) =>
new Promise((resolve, reject) => {
uni.saveVideoToPhotosAlbum({
filePath,
success: resolve,
fail: reject,
});
});
const ensureAlbumPermission = () =>
new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.getSetting({
success: ({ authSetting = {} }) => {
const permission = authSetting["scope.writePhotosAlbum"];
if (permission === true) {
resolve();
return;
}
if (permission === false) {
reject({ errMsg: "scope.writePhotosAlbum permission denied" });
return;
}
uni.authorize({
scope: "scope.writePhotosAlbum",
success: resolve,
fail: reject,
});
},
fail: reject,
});
// #endif
// #ifndef MP-WEIXIN
resolve();
// #endif
});
const isAlbumPermissionDenied = (error) => {
const errorMessage = String(error?.errMsg || "").toLowerCase();
return (
@@ -258,10 +304,11 @@ const isAlbumPermissionDenied = (error) => {
);
};
const promptAlbumPermission = () => {
const promptAlbumPermission = (mediaType) => {
const mediaName = mediaType === "video" ? "视频" : "图片";
uni.showModal({
title: "需要相册权限",
content: "请在设置中允许保存图片到相册。",
content: `请在设置中允许保存${mediaName}到相册。`,
confirmText: "去设置",
success: ({ confirm }) => {
if (confirm) {
@@ -271,51 +318,104 @@ const promptAlbumPermission = () => {
});
};
const handleSave = async () => {
const saveResultMedia = async () => {
if (isSaving.value) return;
if (result.value.mediaType !== "image") {
showPlaceholderToast("当前结果不是图片");
return;
}
if (!result.value.cover) {
showPlaceholderToast("暂无可保存的图片");
const mediaType = result.value.mediaType;
const isVideo = mediaType === "video";
const mediaName = isVideo ? "视频" : "图片";
const mediaUrl = isVideo ? result.value.videoResultUrl : result.value.imageResultUrl;
if (!mediaUrl) {
showPlaceholderToast(`暂无可保存的${mediaName}`);
return;
}
isSaving.value = true;
uni.showLoading({
title: "保存中",
mask: true,
});
let saveError = null;
let loadingVisible = false;
try {
const filePath = await downloadImage(result.value.cover);
await saveImageToAlbum(filePath);
await ensureAlbumPermission();
uni.showLoading({
title: "保存中",
mask: true,
});
loadingVisible = true;
const filePath = await downloadMedia(mediaUrl);
if (isVideo) {
await saveVideoToAlbum(filePath);
} else {
await saveImageToAlbum(filePath);
}
} catch (error) {
saveError = error;
} finally {
uni.hideLoading();
if (loadingVisible) {
uni.hideLoading();
}
isSaving.value = false;
}
if (!saveError) {
uni.showToast({
title: "已保存到相册",
title: `${mediaName}已保存`,
icon: "success",
});
return;
}
if (isAlbumPermissionDenied(saveError)) {
promptAlbumPermission();
promptAlbumPermission(mediaType);
} else {
showPlaceholderToast("保存图片失败");
showPlaceholderToast(`保存${mediaName}失败`);
}
};
const handleSave = () => {
if (isSaving.value || pendingSaveAfterPrivacy.value) return;
// #ifdef MP-WEIXIN
pendingSaveAfterPrivacy.value = true;
wx.getPrivacySetting({
success: (res) => {
if (res?.needAuthorization) {
privacyContractName.value = res.privacyContractName || "隐私保护指引";
privacyVisible.value = true;
return;
}
pendingSaveAfterPrivacy.value = false;
saveResultMedia();
},
fail: (error) => {
pendingSaveAfterPrivacy.value = false;
console.warn("检查微信隐私授权失败", error);
showPlaceholderToast("隐私授权检查失败");
},
});
// #endif
// #ifndef MP-WEIXIN
saveResultMedia();
// #endif
};
const handlePrivacyAgree = () => {
const shouldSave = pendingSaveAfterPrivacy.value;
privacyVisible.value = false;
pendingSaveAfterPrivacy.value = false;
if (shouldSave) {
saveResultMedia();
}
};
const handlePrivacyDisagree = () => {
privacyVisible.value = false;
pendingSaveAfterPrivacy.value = false;
};
const handleRegenerate = () => {
showPlaceholderToast("再生成一版");
};

View File

@@ -0,0 +1,130 @@
<template>
<uni-popup
ref="popupRef"
type="center"
background-color="transparent"
mask-background-color="rgba(0, 0, 0, 0.58)"
:safe-area="false"
:is-mask-click="false"
@change="handlePopupChange"
>
<view class="aigc-consent-overlay" @touchmove.stop.prevent>
<view class="aigc-consent-dialog">
<text class="aigc-consent-title">温馨提示</text>
<view class="aigc-consent-content">
<text v-if="content">{{ content }}</text>
<template v-else>
<text>{{ defaultContentPrefix }}</text>
<text class="aigc-consent-rule" @tap.stop="handleRuleClick">
{{ ruleName }}
</text>
<text>{{ defaultContentSuffix }}</text>
</template>
</view>
<view class="aigc-consent-actions">
<button
class="aigc-consent-button aigc-consent-cancel"
type="default"
@tap.stop="handleCancel"
>
取消
</button>
<button
class="aigc-consent-button aigc-consent-agree"
type="default"
@tap.stop="handleAgree"
>
我同意
</button>
</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from "vue";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
companyName: {
type: String,
default: "智念科技服务有限公司",
},
ruleName: {
type: String,
default: "智念AI用户规则",
},
content: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:visible", "cancel", "agree", "ruleClick"]);
const popupRef = ref(null);
const defaultContentPrefix = computed(
() =>
`欢迎使用【${props.companyName}】提供的AI内容升级服务。本服务通过人工智能技术AIGC将您上传的照片或视频与景区场景模板进行创意优化为您生成具有特定风格的旅行内容。请您仔细阅读`
);
const defaultContentSuffix =
",您点击“我同意”等进行下一步操作的行为均视为您同意受本规则约束。如您不同意,您可以直接关闭页面。";
const close = () => {
popupRef.value?.close();
emit("update:visible", false);
};
const handleCancel = () => {
close();
emit("cancel");
};
const handleAgree = () => {
close();
emit("agree");
};
const handleRuleClick = () => {
emit("ruleClick");
};
const syncPopupVisible = async (visible) => {
await nextTick();
if (visible) {
popupRef.value?.open();
} else {
popupRef.value?.close();
}
};
const handlePopupChange = (event) => {
const show = event?.show ?? event?.detail?.show;
if (show === false && props.visible) {
emit("update:visible", false);
}
};
watch(
() => props.visible,
(visible) => {
syncPopupVisible(visible);
},
{ immediate: true }
);
onMounted(() => {
syncPopupVisible(props.visible);
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,75 @@
.aigc-consent-overlay {
width: 100vw;
padding: 0 28px;
box-sizing: border-box;
}
.aigc-consent-dialog {
width: 100%;
padding: 34px 28px 26px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26);
box-sizing: border-box;
}
.aigc-consent-title {
display: block;
margin-bottom: 24px;
color: #111827;
font-size: 19px;
line-height: 26px;
font-weight: 900;
text-align: center;
}
.aigc-consent-content {
color: #606776;
font-size: 14px;
line-height: 25px;
font-weight: 700;
text-align: justify;
}
.aigc-consent-rule {
color: #18bd78;
font-weight: 900;
}
.aigc-consent-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 28px;
}
.aigc-consent-button {
width: 100%;
height: 54px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 999px;
font-size: 16px;
line-height: 22px;
font-weight: 900;
box-sizing: border-box;
}
.aigc-consent-button::after {
border: 0;
}
.aigc-consent-cancel {
border: 1px solid #e2e6ea;
background: #ffffff;
color: #172033;
}
.aigc-consent-agree {
border: 1px solid #061c31;
background: #061c31;
color: #ffffff;
}

View File

@@ -9,6 +9,12 @@
<FlowSteps :steps="steps" />
</view>
<AigcConsentDialog
v-model:visible="aigcConsentVisible"
@agree="handleAigcConsentAgree"
@cancel="handleAigcConsentCancel"
/>
</view>
</template>
@@ -16,15 +22,22 @@
import { ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { getAigcGiftPointsToastClosed, setAigcGiftPointsToastClosed } from "@/constant/aigc.js";
import {
getAigcConsentAgreed,
getAigcGiftPointsToastClosed,
setAigcConsentAgreed,
setAigcGiftPointsToastClosed,
} from "@/constant/aigc.js";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { getAigcTemplateList } from "@/request/api/AigcApi.js";
import AigcConsentDialog from "./components/AigcConsentDialog/index.vue";
import GiftPointsToast from "./components/GiftPointsToast/index.vue";
import TemplateCarousel from "./components/TemplateCarousel/index.vue";
import FlowSteps from "./components/FlowSteps/index.vue";
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const activeIndex = ref(0);
const aigcConsentVisible = ref(false);
const giftToastVisible = ref(!getAigcGiftPointsToastClosed());
const templates = ref([]);
const steps = ["选模板", "传素材", "添积分", "出结果"];
@@ -42,6 +55,7 @@ const fetchTemplateList = async () => {
};
onLoad(() => {
aigcConsentVisible.value = !getAigcConsentAgreed();
fetchTemplateList();
});
@@ -73,6 +87,22 @@ const handleCloseGiftToast = () => {
setAigcGiftPointsToastClosed();
};
const handleAigcConsentAgree = () => {
setAigcConsentAgreed(true);
};
const handleAigcConsentCancel = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({
url: "/pages/index/index"
});
};
const handleSelectTemplate = (template) => {
const templateId = template?.templateId || "";
if (!templateId) return;