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

View File

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

View File

@@ -8,39 +8,36 @@
:is-mask-click="false" :is-mask-click="false"
@change="handlePopupChange" @change="handlePopupChange"
> >
<view <view class="aigc-consent-overlay" @touchmove.stop.prevent>
class="aigc-consent-overlay"
@touchmove.stop.prevent
>
<view class="aigc-consent-dialog"> <view class="aigc-consent-dialog">
<text class="aigc-consent-title">温馨提示</text> <text class="aigc-consent-title">温馨提示</text>
<view class="aigc-consent-content"> <view class="aigc-consent-content">
<text v-if="content">{{ content }}</text> <text v-if="content">{{ content }}</text>
<template v-else> <template v-else>
<text>{{ defaultContentPrefix }}</text> <text>{{ defaultContentPrefix }}</text>
<text class="aigc-consent-rule" @tap.stop="handleRuleClick"> <text class="aigc-consent-rule" @tap.stop="handleRuleClick">
{{ ruleName }} {{ ruleName }}
</text> </text>
<text>{{ defaultContentSuffix }}</text> <text>{{ defaultContentSuffix }}</text>
</template> </template>
</view> </view>
<view class="aigc-consent-actions"> <view class="aigc-consent-actions">
<button <button
class="aigc-consent-button aigc-consent-cancel" class="aigc-consent-button aigc-consent-cancel"
type="default" type="default"
@tap.stop="handleCancel" @tap.stop="handleCancel"
> >
取消 取消
</button> </button>
<button <button
class="aigc-consent-button aigc-consent-agree" class="aigc-consent-button aigc-consent-agree"
type="default" type="default"
@tap.stop="handleAgree" @tap.stop="handleAgree"
> >
我同意 我同意
</button> </button>
</view> </view>
</view> </view>
</view> </view>

View File

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

View File

@@ -11,8 +11,6 @@
</view> </view>
</view> </view>
</view> </view>
<AigcConsentDialog v-model:visible="aigcConsentVisible" @agree="handleAigcConsentAgree" />
</view> </view>
</template> </template>
@@ -20,12 +18,9 @@
import { ref } from "vue"; import { ref } from "vue";
import { Command } from "@/model/ChatModel"; import { Command } from "@/model/ChatModel";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant"; import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { getAigcConsentAgreed, setAigcConsentAgreed } from "@/constant/aigc";
import { checkToken } from "@/hooks/useGoLogin"; import { checkToken } from "@/hooks/useGoLogin";
import AigcConsentDialog from "../AigcConsentDialog/index.vue";
const AIGC_HOME_URL = "/pages-aigc/home/home"; const AIGC_HOME_URL = "/pages-aigc/home/home";
const aigcConsentVisible = ref(false);
const itemList = ref([ const itemList = ref([
{ {
@@ -81,11 +76,7 @@ const sendReply = (item) => {
// 旅行AIGC // 旅行AIGC
if (item.type === Command.travelAIGC) { if (item.type === Command.travelAIGC) {
checkToken().then(() => { checkToken().then(() => {
if (getAigcConsentAgreed()) { navigateToAigcHome();
navigateToAigcHome();
} else {
aigcConsentVisible.value = true;
}
}); });
return; return;
} }
@@ -96,11 +87,6 @@ const sendReply = (item) => {
const navigateToAigcHome = () => { const navigateToAigcHome = () => {
uni.navigateTo({ url: AIGC_HOME_URL }); uni.navigateTo({ url: AIGC_HOME_URL });
}; };
const handleAigcConsentAgree = () => {
setAigcConsentAgreed(true);
navigateToAigcHome();
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>