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:
@@ -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) {
|
||||
|
||||
@@ -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("再生成一版");
|
||||
};
|
||||
|
||||
130
src/pages-aigc/home/components/AigcConsentDialog/index.vue
Normal file
130
src/pages-aigc/home/components/AigcConsentDialog/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user