Merge branch 'aigc' of https://git.nianxx.cn/zoujing/YGChatCS into home3.0

This commit is contained in:
2026-07-16 22:14:28 +08:00
9 changed files with 217 additions and 76 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

@@ -3,11 +3,12 @@
<video <video
v-if="result.mediaType === 'video'" v-if="result.mediaType === 'video'"
class="result-preview-image" class="result-preview-image"
:src="result.cover" :src="result.videoResultUrl"
:poster="result.imageResultUrl"
controls controls
object-fit="cover" object-fit="cover"
/> />
<image v-else class="result-preview-image" :src="result.cover" mode="aspectFill" /> <image v-else class="result-preview-image" :src="result.imageResultUrl" mode="aspectFill" />
<view class="result-preview-shade" /> <view class="result-preview-shade" />
<view class="result-preview-caption"> <view class="result-preview-caption">

View File

@@ -31,6 +31,7 @@
rgba(0, 0, 0, 0.16) 48%, rgba(0, 0, 0, 0.16) 48%,
rgba(0, 0, 0, 0.74) 100% rgba(0, 0, 0, 0.74) 100%
); );
pointer-events: none;
} }
.result-preview-caption { .result-preview-caption {
@@ -38,6 +39,7 @@
right: 18px; right: 18px;
bottom: 18px; bottom: 18px;
left: 18px; left: 18px;
pointer-events: none;
} }
.result-preview-badge { .result-preview-badge {

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: "图片版",
@@ -60,12 +66,17 @@ const getMappedText = (mapping, value, prefix) => {
return value === undefined || value === null || value === "" ? "-" : `${prefix}${value}`; return value === undefined || value === null || value === "" ? "-" : `${prefix}${value}`;
}; };
const formatDateOnly = (value) => {
if (!value) return "-";
return String(value).match(/^\d{4}-\d{2}-\d{2}/)?.[0] || "-";
};
const result = computed(() => { const result = computed(() => {
const record = taskDetail.value || {}; const record = taskDetail.value || {};
const imageUrl = record.imageResultUrl || ""; const imageUrl = record.imageResultUrl || "";
const videoUrl = record.videoResultUrl || ""; const videoUrl = record.videoResultUrl || "";
const isVideo = Boolean(videoUrl) && (record.generatorType === 1 || !imageUrl); const isVideo = Number(record.generatorType) === 1;
const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型"); const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型");
const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态"); const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态");
const consumedText = const consumedText =
@@ -76,7 +87,9 @@ const result = computed(() => {
return { return {
id: record.taskId, id: record.taskId,
title: record.itemTitle || record.taskId || "生成结果", title: record.itemTitle || record.taskId || "生成结果",
cover: isVideo ? videoUrl : imageUrl || videoUrl, cover: isVideo ? videoUrl : imageUrl,
imageResultUrl: imageUrl,
videoResultUrl: videoUrl,
mediaType: isVideo ? "video" : "image", mediaType: isVideo ? "video" : "image",
accent: "#0a4d46", accent: "#0a4d46",
variantLabel: typeText, variantLabel: typeText,
@@ -90,8 +103,9 @@ const metaItems = computed(() => {
const record = taskDetail.value; const record = taskDetail.value;
if (!record) return []; if (!record) return [];
const completeTime = const completeTime = formatDateOnly(
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime || "-"; record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime
);
return [ return [
{ {
@@ -218,7 +232,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);
@@ -247,6 +261,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 (
@@ -256,10 +310,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) {
@@ -269,51 +324,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;
let saveError = null;
let loadingVisible = false;
try {
await ensureAlbumPermission();
uni.showLoading({ uni.showLoading({
title: "保存中", title: "保存中",
mask: true, mask: true,
}); });
loadingVisible = true;
let saveError = null; const filePath = await downloadMedia(mediaUrl);
try { if (isVideo) {
const filePath = await downloadImage(result.value.cover); await saveVideoToAlbum(filePath);
} else {
await saveImageToAlbum(filePath); await saveImageToAlbum(filePath);
}
} catch (error) { } catch (error) {
saveError = error; saveError = error;
} finally { } finally {
if (loadingVisible) {
uni.hideLoading(); 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,10 +8,7 @@
: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 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

@@ -7,9 +7,9 @@
<view class="shared-work-scroll"> <view class="shared-work-scroll">
<template v-if="shareDetail"> <template v-if="shareDetail">
<video v-if="isVideo" class="shared-work-media is-video" :src="shareDetail.videoUrl" <video v-if="isVideo" class="shared-work-media is-video" :src="videoResultUrl"
:poster="shareDetail.imageUrl" controls object-fit="cover" /> :poster="imageResultUrl" controls object-fit="cover" />
<image v-else-if="shareDetail.imageUrl" class="shared-work-media" :src="shareDetail.imageUrl" <image v-else-if="imageResultUrl" class="shared-work-media" :src="imageResultUrl"
mode="aspectFill" /> mode="aspectFill" />
<view class="shared-work-meta"> <view class="shared-work-meta">
@@ -37,8 +37,16 @@ import { getAigcGeneratorTaskShareDetail } from "@/request/api/AigcApi.js";
const shareKey = ref(""); const shareKey = ref("");
const shareDetail = ref(null); const shareDetail = ref(null);
const imageResultUrl = computed(() => {
return shareDetail.value?.imageResultUrl || shareDetail.value?.imageUrl || "";
});
const videoResultUrl = computed(() => {
return shareDetail.value?.videoResultUrl || shareDetail.value?.videoUrl || "";
});
const isVideo = computed(() => { const isVideo = computed(() => {
return Number(shareDetail.value?.generatorType) === 1 && Boolean(shareDetail.value?.videoUrl); return Number(shareDetail.value?.generatorType) === 1 && Boolean(videoResultUrl.value);
}); });
const generatorTypeText = computed(() => { const generatorTypeText = computed(() => {

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>