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>
<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

@@ -3,11 +3,12 @@
<video
v-if="result.mediaType === 'video'"
class="result-preview-image"
:src="result.cover"
:src="result.videoResultUrl"
:poster="result.imageResultUrl"
controls
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-caption">

View File

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

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: "图片版",
@@ -60,12 +66,17 @@ const getMappedText = (mapping, value, prefix) => {
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 record = taskDetail.value || {};
const imageUrl = record.imageResultUrl || "";
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 statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态");
const consumedText =
@@ -76,7 +87,9 @@ const result = computed(() => {
return {
id: record.taskId,
title: record.itemTitle || record.taskId || "生成结果",
cover: isVideo ? videoUrl : imageUrl || videoUrl,
cover: isVideo ? videoUrl : imageUrl,
imageResultUrl: imageUrl,
videoResultUrl: videoUrl,
mediaType: isVideo ? "video" : "image",
accent: "#0a4d46",
variantLabel: typeText,
@@ -90,8 +103,9 @@ const metaItems = computed(() => {
const record = taskDetail.value;
if (!record) return [];
const completeTime =
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime || "-";
const completeTime = formatDateOnly(
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime
);
return [
{
@@ -218,7 +232,7 @@ const handleRecharge = () => {
showPlaceholderToast("积分充值");
};
const downloadImage = (url) =>
const downloadMedia = (url) =>
new Promise((resolve, reject) => {
if (!/^https?:\/\//i.test(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 errorMessage = String(error?.errMsg || "").toLowerCase();
return (
@@ -256,10 +310,11 @@ const isAlbumPermissionDenied = (error) => {
);
};
const promptAlbumPermission = () => {
const promptAlbumPermission = (mediaType) => {
const mediaName = mediaType === "video" ? "视频" : "图片";
uni.showModal({
title: "需要相册权限",
content: "请在设置中允许保存图片到相册。",
content: `请在设置中允许保存${mediaName}到相册。`,
confirmText: "去设置",
success: ({ confirm }) => {
if (confirm) {
@@ -269,51 +324,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;
let saveError = null;
let loadingVisible = false;
try {
await ensureAlbumPermission();
uni.showLoading({
title: "保存中",
mask: true,
});
loadingVisible = true;
let saveError = null;
try {
const filePath = await downloadImage(result.value.cover);
const filePath = await downloadMedia(mediaUrl);
if (isVideo) {
await saveVideoToAlbum(filePath);
} else {
await saveImageToAlbum(filePath);
}
} catch (error) {
saveError = error;
} finally {
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

@@ -8,10 +8,7 @@
:is-mask-click="false"
@change="handlePopupChange"
>
<view
class="aigc-consent-overlay"
@touchmove.stop.prevent
>
<view class="aigc-consent-overlay" @touchmove.stop.prevent>
<view class="aigc-consent-dialog">
<text class="aigc-consent-title">温馨提示</text>

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;

View File

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

View File

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