14 Commits

Author SHA1 Message Date
7d7c235a71 feat: 优化调整返回按钮样式 2026-08-06 17:07:06 +08:00
0020c8bb47 feat: 音频组件的字段对接 2026-08-06 16:22:04 +08:00
cc1615162a feat: 位置的组件字段对接 2026-08-06 16:10:04 +08:00
b5206c18d1 feat; 数据字段对接 2026-08-06 15:34:53 +08:00
22b1686c26 feat: 图片地址改 2026-08-06 14:59:03 +08:00
f4f4c05766 feat: 组件位置调整 2026-08-06 14:54:04 +08:00
f7b08a0600 feat: 首页接口资源接口对接 2026-08-06 14:51:54 +08:00
b36a47b314 feat: 样式调整 2026-08-06 11:36:17 +08:00
937dfe3042 feat: 实现了商品组件 2026-08-06 10:55:52 +08:00
ea80ce000e feat: 首页开启组件 2026-08-06 10:13:50 +08:00
duanshuwen
8de0038742 feat(aigc): 新增升视频生成类型并修复相关展示逻辑
- 添加生成类型2的“升视频”映射到详情页和记录卡片配置中
- 优化共享作品页面的生成类型文本展示逻辑以适配新类型
- 调整未知生成类型的默认显示文本为“生成作品”
2026-08-05 19:58:01 +08:00
duanshuwen
9f0881a284 feat(aigc): 新增图片生成动效视频的升级功能
- 新增图片转视频相关的API接口
- 调整AIGC详情页英雄区块高度至380px
- 添加结果详情页升级动效视频入口
- 完善升级流程的逻辑与费用计算
- 适配多类型生成任务的判断规则
2026-08-05 19:41:42 +08:00
duanshuwen
57f6cdd85e docs: plan AIGC upgrade image display 2026-08-05 19:06:03 +08:00
duanshuwen
06afbcc5c1 docs: record AIGC upgrade image design 2026-08-05 19:05:22 +08:00
16 changed files with 578 additions and 154 deletions

View File

@@ -0,0 +1,84 @@
# AIGC Upgrade Image Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show the already generated source image in the `useTemplate` upgrade flow while preserving the selected template copy.
**Architecture:** Keep `upgradeTaskId` as the navigation contract. In `useTemplate.vue`, load the source generator task detail only for upgrade flows, then overlay its `imageResultUrl` onto the selected template as `templateContentUrl`. `TemplateVersionHero` remains unchanged because it already renders `templateContentUrl`.
**Tech Stack:** Vue 3 `<script setup>`, uni-app lifecycle/API wrappers, Node.js built-in test runner.
---
### Task 1: Add a failing regression assertion
**Files:**
- Modify: `tests/aigc-detail-continuation.test.cjs`
- [ ] **Step 1: Add the upgrade-source-image test**
Append a test that reads `src/pages-aigc/useTemplate/useTemplate.vue` and asserts that the page imports `getAigcGeneratorTaskDetail`, calls it with `{ taskId: upgradeTaskId.value }`, and assigns `imageResultUrl` to `templateContentUrl` inside the `currentTemplate` computed value.
- [ ] **Step 2: Run the focused test and verify RED**
Run:
```powershell
node --test tests/aigc-detail-continuation.test.cjs
```
Expected: the existing tests pass and the new source-image test fails because `useTemplate.vue` does not yet load the source task detail or overlay `imageResultUrl`.
### Task 2: Load and display the generated source image
**Files:**
- Modify: `src/pages-aigc/useTemplate/useTemplate.vue:46-107,116-184`
- [ ] **Step 1: Add source task state and API import**
Import `getAigcGeneratorTaskDetail` from the existing AIGC API module and add `const upgradeSourceTaskDetail = ref(null);` beside the existing upgrade state.
- [ ] **Step 2: Overlay the source image in `currentTemplate`**
Keep the existing template lookup as the base value. When `isImageToVideoUpgrade.value` is true and `upgradeSourceTaskDetail.value.imageResultUrl` is non-empty, return a shallow copy with `templateContentUrl` set to that URL; otherwise return the base template unchanged.
Use this shape:
```js
const currentTemplate = computed(() => {
const template =
templates.value.find((item) => item.templateId === templateId.value) || templates.value[0] || {};
const imageResultUrl = String(upgradeSourceTaskDetail.value?.imageResultUrl || "").trim();
if (!isImageToVideoUpgrade.value || !imageResultUrl) return template;
return { ...template, templateContentUrl: imageResultUrl };
});
```
- [ ] **Step 3: Fetch the source task detail only for upgrade flows**
Add `fetchUpgradeSourceTaskDetail` after `fetchImageToVideoItemDetail`. Return immediately for non-upgrade flows. For an upgrade, call `getAigcGeneratorTaskDetail({ taskId: upgradeTaskId.value })`; on a valid object response assign `upgradeSourceTaskDetail.value`, otherwise show `获取原图片失败`. Catch the request error, log a warning, and show the same toast. Call it from `fetchPageData` after `fetchTemplateList` and before the existing item-list request.
- [ ] **Step 4: Run the focused test and verify GREEN**
Run:
```powershell
node --test tests/aigc-detail-continuation.test.cjs
```
Expected: all tests pass with zero failures.
### Task 3: Check the final diff
**Files:**
- Verify: `src/pages-aigc/useTemplate/useTemplate.vue`
- Verify: `tests/aigc-detail-continuation.test.cjs`
- [ ] **Step 1: Run whitespace validation**
Run `git diff --check` and expect exit code 0.
- [ ] **Step 2: Confirm scope**
Run `git status --short` and confirm only the intended source/test changes remain uncommitted; preserve the users pre-existing `detail.vue` and `AigcApi.js` work without rewriting it.

View File

@@ -0,0 +1,24 @@
# AIGC 图片升级视频页展示原图设计
## 目标
从图片生成结果详情页点击“升级为动效视频”后,进入 `useTemplate` 页面时,顶部展示原任务已生成的图片,让用户明确当前要升级的视频来源。
## 方案
详情页继续只传递 `upgradeTaskId`、模板 ID 和生成项 ID。`useTemplate` 页面在检测到升级流程后,使用 `upgradeTaskId` 请求原任务详情,读取 `imageResultUrl`,并在 `currentTemplate` 的计算结果上覆盖 `templateContentUrl`。模板标题、标签和副标题保持模板列表返回值,避免改变现有文案语义。
`TemplateVersionHero` 无需改动:它已经优先读取 `templateContentUrl` 作为媒体地址,图片地址不会匹配视频扩展名,因此会按图片渲染。
## 数据流
1. `detail.vue` 通过 `upgradeTaskId` 跳转到 `useTemplate`
2. `useTemplate.vue` 并行/顺序加载模板列表、升级生成项详情和原任务详情。
3. 原任务详情成功且存在 `imageResultUrl` 时,`currentTemplate` 返回基础模板字段加 `templateContentUrl: imageResultUrl`
4. 原任务详情缺失或请求失败时,保留模板默认图片,并提示用户图片信息加载失败;升级创建流程仍按现有费用和任务参数校验执行。
## 验证
- 增加静态回归测试,确认升级流程请求原任务详情,并将 `imageResultUrl` 覆盖到 `currentTemplate``templateContentUrl`
- 运行现有 AIGC continuation 测试和新增回归测试。
- 运行 `git diff --check`;不运行项目生产构建或微信小程序开发命令。

View File

@@ -3,20 +3,22 @@
<AigcTopBar :points="pointBalance" @back="handleBack" @history="handleOpenRecords" @recharge="handleRecharge" />
<view class="aigc-detail-content">
<template v-if="taskDetail">
<ResultPreview v-if="result.cover" class="aigc-detail-preview" :result="result" />
<view class="aigc-detail-controls">
<ResultMeta :items="metaItems" />
<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>
<view class="aigc-detail-text-action" @tap="handleChangeTemplate">换个模板</view>
<view class="aigc-detail-text-action" @tap="handleOpenRecords">历史记录</view>
</view>
</view>
</template>
<template v-if="taskDetail">
<ResultPreview v-if="result.cover" class="aigc-detail-preview" :result="result" />
<view class="aigc-detail-controls">
<!-- <ResultMeta :items="metaItems" /> -->
<ContinuationCard v-if="result.mediaType === 'image'" :info="continuationInfo"
@upgrade="handleUpgradeToVideo" />
<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>
<view class="aigc-detail-text-action" @tap="handleChangeTemplate">换个模板</view>
<view class="aigc-detail-text-action" @tap="handleOpenRecords">历史记录</view>
</view>
</view>
</template>
</view>
<Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree"
@@ -39,11 +41,11 @@ import {
createAigcGeneratorTask,
createAigcGeneratorTaskShare,
getAigcGeneratorTaskDetail,
} from "@/pages-aigc/request/AigcApi.js";
} from "@/pages-aigc/request/AigcApi.js";
import PointInsufficientDialog from "@/pages-aigc/useTemplate/components/PointInsufficientDialog/index.vue";
import ContinuationCard from "./components/ContinuationCard/index.vue";
import RegenerateConfirmPopup from "./components/RegenerateConfirmPopup/index.vue";
import ResultActions from "./components/ResultActions/index.vue";
import ResultMeta from "./components/ResultMeta/index.vue";
import ResultPreview from "./components/ResultPreview/index.vue";
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
@@ -57,11 +59,13 @@ const privacyContractName = ref("隐私保护指引");
const pendingSaveAfterPrivacy = ref(false);
const regeneratePopupRef = ref(null);
const isRegenerating = ref(false);
const isUpgradingToVideo = ref(false);
const pointDialogVisible = ref(false);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
1: "视频版",
2: "升视频",
};
const TASK_STATUS_TEXT = {
@@ -89,7 +93,7 @@ const result = computed(() => {
const imageUrl = record.imageResultUrl || "";
const videoUrl = record.videoResultUrl || "";
const isVideo = Number(record.generatorType) === 1;
const isVideo = [1, 2].includes(Number(record.generatorType));
const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型");
const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态");
const consumedText =
@@ -138,6 +142,12 @@ const metaItems = computed(() => {
const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || "");
const regenerateCost = computed(() => Math.max(0, Number(taskDetail.value?.generatorCost) || 0));
const continuationInfo = {
title: "创意续作",
desc: "让这张图动起来 · 5秒内动效视频",
cost: "追加消耗 650积分",
buttonText: "升级为动效视频",
};
const showPlaceholderToast = (title) => {
uni.showToast({
@@ -182,6 +192,7 @@ onLoad((query = {}) => {
});
onShow(() => {
isUpgradingToVideo.value = false;
fetchCurrentCredit();
});
@@ -249,6 +260,35 @@ const handleRecharge = () => {
});
};
const handleUpgradeToVideo = () => {
if (isUpgradingToVideo.value || result.value.mediaType !== "image") return;
const sourceTaskId = String(taskDetail.value?.taskId || taskId.value || "").trim();
if (!sourceTaskId) {
showPlaceholderToast("缺少任务ID");
return;
}
const templateId = String(taskDetail.value?.templateId || "").trim();
const templateItemId = String(taskDetail.value?.templateItemId || "").trim();
const query = [
templateId && `templateId=${encodeURIComponent(templateId)}`,
`upgradeTaskId=${encodeURIComponent(sourceTaskId)}`,
templateItemId && `upgradeTemplateItemId=${encodeURIComponent(templateItemId)}`,
]
.filter(Boolean)
.join("&");
isUpgradingToVideo.value = true;
uni.navigateTo({
url: `/pages-aigc/useTemplate/useTemplate?${query}`,
fail: () => {
isUpgradingToVideo.value = false;
showPlaceholderToast("打开图片生成页面失败");
},
});
};
const getMediaFileExtension = (url, mediaType) => {
const pathname = String(url || "").split(/[?#]/)[0];
const matchedExtension = pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase();
@@ -372,7 +412,7 @@ const removeDownloadedMedia = (filePath, shouldCleanup) => {
// #ifdef MP-WEIXIN
wx.getFileSystemManager().unlink({
filePath,
fail: () => {},
fail: () => { },
});
// #endif
};

View File

@@ -7,10 +7,10 @@
<text v-if="isGenerating" class="record-card-media-text">生成中</text>
</view>
<view class="record-card-copy w-full min-w-0">
<text class="record-card-title block w-full truncate">{{ title }}</text>
<text class="record-card-time block w-full truncate">{{ record.createTime }}</text>
<text class="record-card-status block w-full truncate">{{ statusText }}</text>
<view class="record-card-copy w-full min-w-0">
<text class="record-card-title block w-full truncate">{{ title }}</text>
<text class="record-card-time block w-full truncate">{{ record.createTime }}</text>
<text class="record-card-status block w-full truncate">{{ statusText }}</text>
</view>
<view v-if="actionText" class="record-card-action">
@@ -34,6 +34,7 @@ const emit = defineEmits(["view"]);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
1: "视频版",
2: "升视频",
};
const TASK_STATUS_TEXT = {
@@ -74,7 +75,7 @@ const statusText = computed(() => {
const taskStatus = props.record.taskStatus;
const generatorCost = normalizedTaskStatus.value === 3 ? 0 : props.record.generatorCost;
const typeText =
GENERATOR_TYPE_TEXT[generatorType] || (generatorType === undefined ? "" : `类型${generatorType}`);
GENERATOR_TYPE_TEXT[generatorType] || (generatorType === undefined ? "" : `生成作品`);
const stateText =
TASK_STATUS_TEXT[taskStatus] || (taskStatus === undefined ? "" : `状态${taskStatus}`);
const costText =

View File

@@ -30,6 +30,21 @@ function createAigcGeneratorTask(args) {
);
}
function createAigcImageToVideoTask(args) {
return request.post(
"/hotelBiz/aigcGeneratorTask/createAigcImageToVideoTask",
args,
{ sensitive: true },
);
}
function getAigcImageToVideoItemDetail(args) {
return request.post(
"/hotelBiz/aigcGeneratorTask/aigcImageToVideoItemDetail",
args,
);
}
function getCurrentCredit() {
return request.get("/hotelBiz/credit/current", {});
}
@@ -62,6 +77,8 @@ export {
getAigcGeneratorTaskList,
getAigcGeneratorTaskDetail,
createAigcGeneratorTask,
createAigcImageToVideoTask,
getAigcImageToVideoItemDetail,
getCurrentCredit,
getCreditLedgerPage,
getPointsTopUpAgreement,

View File

@@ -31,7 +31,7 @@ import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { checkToken } from "@/hooks/useGoLogin.js";
import { getAigcGeneratorTaskShareDetail } from "@/pages-aigc/request/AigcApi.js";
import { getAigcGeneratorTaskShareDetail } from "@/pages-aigc/request/AigcApi.js";
const shareKey = ref("");
const shareDetail = ref(null);
@@ -52,9 +52,8 @@ const generatorTypeText = computed(() => {
const generatorType = shareDetail.value?.generatorType;
if (Number(generatorType) === 0) return "图片版";
if (Number(generatorType) === 1) return "视频版";
return generatorType === undefined || generatorType === null || generatorType === ""
? "生成作品"
: `类型${generatorType}`;
if (Number(generatorType) === 2) return "升视频";
return generatorType === undefined || generatorType === null || generatorType === "" || "生成作品";
});
const showToast = (title) => {

View File

@@ -1,6 +1,6 @@
.template-version-hero {
position: relative;
height: 360px;
height: 380px;
overflow: hidden;
border-radius: 24px;
background: var(--template-accent, #0a4d46);

View File

@@ -46,11 +46,14 @@ import { onLoad, 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 { getWechatLoginCode } from "@/pages-aigc/services/wechatLogin.js";
import {
createAigcGeneratorTask,
getAigcTemplateItemList,
getAigcTemplateList,
import { getWechatLoginCode } from "@/pages-aigc/services/wechatLogin.js";
import {
createAigcImageToVideoTask,
createAigcGeneratorTask,
getAigcImageToVideoItemDetail,
getAigcGeneratorTaskDetail,
getAigcTemplateItemList,
getAigcTemplateList,
} from "@/pages-aigc/request/AigcApi.js";
import { updateImageFile } from "@/request/api/UpdateFile.js";
import GenerateConfirmPanel from "./components/GenerateConfirmPanel/index.vue";
@@ -72,14 +75,27 @@ const uploadedImageUrl = ref("");
const currentStep = ref("version");
const pointDialogVisible = ref(false);
const isUploading = ref(false);
const isCreatingTask = ref(false);
const privacyVisible = ref(false);
const privacyContractName = ref("隐私保护指引");
const pendingImageSourceType = ref("");
const isCreatingTask = ref(false);
const privacyVisible = ref(false);
const privacyContractName = ref("隐私保护指引");
const pendingImageSourceType = ref("");
const upgradeTaskId = ref("");
const upgradeTemplateItemId = ref("");
const imageToVideoItemDetail = ref(null);
const upgradeSourceTaskDetail = ref(null);
const isImageToVideoUpgrade = computed(() => Boolean(upgradeTaskId.value));
const IMAGE_GENERATION_COST = 350;
const VIDEO_GENERATION_COST = 1000;
const currentTemplate = computed(() => {
return templates.value.find((item) => item.templateId === templateId.value) || templates.value[0] || {};
});
const currentTemplate = computed(() => {
const template =
templates.value.find((item) => item.templateId === templateId.value) || templates.value[0] || {};
const imageResultUrl = String(upgradeSourceTaskDetail.value?.imageResultUrl || "").trim();
if (!isImageToVideoUpgrade.value || !imageResultUrl) return template;
return { ...template, templateContentUrl: imageResultUrl };
});
const currentTemplateItem = computed(() => {
return (
@@ -89,16 +105,54 @@ const currentTemplateItem = computed(() => {
);
});
const currentCost = computed(() => {
return Number(currentTemplateItem.value.generatorCost) || 0;
});
const currentCost = computed(() => {
return isImageToVideoUpgrade.value
? Number(imageToVideoItemDetail.value?.generatorCost) || 0
: Number(currentTemplateItem.value.generatorType) === 1
? VIDEO_GENERATION_COST
: IMAGE_GENERATION_COST;
});
const showPlaceholderToast = (title) => {
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
});
};
const fetchImageToVideoItemDetail = async () => {
if (!isImageToVideoUpgrade.value) return;
try {
const res = await getAigcImageToVideoItemDetail({ taskId: upgradeTaskId.value });
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
imageToVideoItemDetail.value = res.data;
return;
}
showPlaceholderToast(res?.msg || "获取升级视频费用失败");
} catch (error) {
console.warn("获取AIGC图片升级视频生成项详情失败", error);
showPlaceholderToast("获取升级视频费用失败");
}
};
const fetchUpgradeSourceTaskDetail = async () => {
if (!isImageToVideoUpgrade.value) return;
try {
const res = await getAigcGeneratorTaskDetail({ taskId: upgradeTaskId.value });
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
upgradeSourceTaskDetail.value = res.data;
return;
}
showPlaceholderToast("获取原图片失败");
} catch (error) {
console.warn("获取AIGC原图片任务详情失败", error);
showPlaceholderToast("获取原图片失败");
}
};
const resetSelectedPhoto = () => {
selectedImageLocalPath.value = "";
@@ -127,25 +181,39 @@ const fetchTemplateItemList = async () => {
}
try {
const res = await getAigcTemplateItemList({ templateId: templateId.value });
if (res?.code === 0 && Array.isArray(res.data)) {
templateItems.value = res.data;
selectedTemplateItemId.value = res.data[0]?.templateItemId || "";
}
const res = await getAigcTemplateItemList({ templateId: templateId.value });
if (res?.code === 0 && Array.isArray(res.data)) {
templateItems.value = res.data;
const selectedUpgradeItem = res.data.find(
(item) =>
String(item.templateItemId || "") === upgradeTemplateItemId.value &&
Number(item.generatorType) === 0,
);
const firstImageItem = res.data.find((item) => Number(item.generatorType) === 0);
selectedTemplateItemId.value =
(isImageToVideoUpgrade.value ? selectedUpgradeItem || firstImageItem : res.data[0])?.templateItemId || "";
if (isImageToVideoUpgrade.value) {
currentStep.value = "generateConfirm";
}
}
} catch (error) {
console.warn("获取AIGC模板生成项失败", error);
}
};
const fetchPageData = async () => {
await fetchTemplateList();
await fetchTemplateItemList();
};
const fetchPageData = async () => {
await fetchTemplateList();
await fetchImageToVideoItemDetail();
await fetchUpgradeSourceTaskDetail();
await fetchTemplateItemList();
};
onLoad((query = {}) => {
templateId.value = query.templateId || "";
fetchPageData();
});
onLoad((query = {}) => {
templateId.value = String(query.templateId || "").trim();
upgradeTaskId.value = String(query.upgradeTaskId || "").trim();
upgradeTemplateItemId.value = String(query.upgradeTemplateItemId || "").trim();
fetchPageData();
});
onShow(() => {
fetchCurrentCredit();
@@ -317,26 +385,31 @@ const handleClosePhotoConfirm = () => {
currentStep.value = "photoPick";
};
const handleConfirmPhoto = () => {
if (!uploadedImageUrl.value) {
showPlaceholderToast("请先上传图片");
currentStep.value = "photoPick";
const handleConfirmPhoto = () => {
if (!uploadedImageUrl.value) {
showPlaceholderToast("请先上传图片");
currentStep.value = "photoPick";
return;
}
currentStep.value = "generateConfirm";
};
const handleGenerate = async () => {
if (isCreatingTask.value) return;
const handleGenerate = async () => {
if (isCreatingTask.value) return;
const templateItemId = currentTemplateItem.value.templateItemId;
if (!templateItemId) {
showPlaceholderToast("请选择生成类型");
return;
}
if (!templateItemId) {
showPlaceholderToast("请选择生成类型");
return;
}
if (isImageToVideoUpgrade.value && !imageToVideoItemDetail.value) {
showPlaceholderToast("升级视频费用加载失败,请稍后重试");
return;
}
if (!uploadedImageUrl.value) {
if (!isImageToVideoUpgrade.value && !uploadedImageUrl.value) {
showPlaceholderToast("请先上传图片");
currentStep.value = "photoPick";
return;
@@ -354,17 +427,33 @@ const handleGenerate = async () => {
uni.showLoading({
title: "创建中",
mask: true,
});
try {
const wxLoginCode = await getWechatLoginCode();
const res = await createAigcGeneratorTask({
templateItemId,
imageUrlList: [uploadedImageUrl.value],
wxLoginCode,
});
const taskId = String(res?.data || "").trim();
});
try {
let res;
if (isImageToVideoUpgrade.value) {
const wxLoginCode = await getWechatLoginCode();
res = await createAigcImageToVideoTask({
taskId: upgradeTaskId.value,
wxLoginCode,
});
} else {
const wxLoginCode = await getWechatLoginCode();
res = await createAigcGeneratorTask({
templateItemId,
imageUrlList: [uploadedImageUrl.value],
wxLoginCode,
});
}
if (isImageToVideoUpgrade.value && String(res?.msg || "").includes("积分余额不足")) {
pointDialogVisible.value = true;
return;
}
const taskId = String(
typeof res?.data === "object" && res.data !== null ? res.data.taskId : res?.data || "",
).trim();
if (res?.code === 0 && taskId) {
fetchCurrentCredit();
uni.redirectTo({

View File

@@ -44,7 +44,7 @@ test('provides coherent default visitor ticket data', async () => {
assert.deepEqual(Object.keys(PRODUCT_IMAGES), visibleProducts.map(({ id }) => id))
assert.ok(SCENIC_HERO_IMAGE.endsWith('/hero-bridge.jpg'))
assert.notEqual(SCENIC_HERO_IMAGE, PLACEHOLDER_IMAGE)
assert.ok(visibleProducts.every((product) => product.images[0] === PRODUCT_IMAGES[product.id] && product.images[0].startsWith('/static/images/tickets/xiaoqikong/')))
assert.ok(visibleProducts.every((product) => product.images[0] === PRODUCT_IMAGES[product.id] && product.images[0].startsWith('https://one-feel-config-images-bucket.oss-cn-chengdu.aliyuncs.com/xiaoqi/tickets/')))
assert.equal(new Set([SCENIC_HERO_IMAGE, ...visibleProducts.map(({ images }) => images[0])]).size, 10)
assert.equal(productsById.get('guide').images[0], PLACEHOLDER_IMAGE)
assert.deepEqual(PRODUCTS.map(({ id, salePrice, marketPrice, sold }) => [id, salePrice, marketPrice, sold]), [

View File

@@ -1,7 +1,7 @@
<template>
<view class="discovery-audio-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect">
<view class="audio-visual">
<image v-if="normalizedItem.coverImage" class="audio-cover" :src="normalizedItem.coverImage" mode="aspectFill" />
<image v-if="normalizedItem.coverUrl" class="audio-cover" :src="normalizedItem.coverUrl" mode="aspectFill" />
<view v-else class="audio-cover audio-cover-fallback" />
<view class="audio-play">
<view class="audio-play-triangle" />
@@ -13,12 +13,8 @@
<text class="audio-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text>
<view class="audio-progress-row w-full min-w-0">
<view class="audio-progress-track">
<view class="audio-progress-fill" :style="{ width: `${normalizedItem.progress}%` }" />
</view>
<text class="audio-time block w-[90px] truncate">
{{ normalizedItem.currentTime }} / {{ normalizedItem.duration }}
</text>
<view class="audio-progress-track" />
<text class="audio-time block w-[90px] truncate">{{ normalizedItem.duration }}</text>
</view>
</view>
</view>
@@ -27,15 +23,6 @@
<script setup>
import { computed } from "vue";
const DEFAULT_ITEM = {
id: "audio-guide",
title: "卧龙潭语音讲解",
subTitle: "解说:响水洞瀑布",
progress: 31,
currentTime: "02:15",
duration: "05:40",
};
const props = defineProps({
item: {
type: Object,
@@ -45,24 +32,36 @@ const props = defineProps({
const emit = defineEmits(["didSelectItem"]);
const clampProgress = (value) => {
const progress = Number(value);
if (Number.isNaN(progress)) return 0;
return Math.min(Math.max(progress, 0), 100);
const formatDuration = (durationMs) => {
if (durationMs === null || durationMs === undefined || durationMs === "") {
return "";
}
const value = Number(durationMs);
if (!Number.isFinite(value) || value < 0) return "";
const totalSeconds = Math.floor(value / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
};
const normalizedItem = computed(() => {
const source = Object.keys(props.item).length > 0 ? props.item : DEFAULT_ITEM;
const source = props.item && typeof props.item === "object" ? props.item : {};
const resource = source.resource || {};
const summary = resource.summary || {};
const payload = resource.payload || {};
return {
raw: source,
id: source.id ?? source.tabContentId ?? DEFAULT_ITEM.id,
title: source.title || DEFAULT_ITEM.title,
subTitle: source.subTitle || source.desc || DEFAULT_ITEM.subTitle,
coverImage: source.coverImage || source.image || DEFAULT_ITEM.coverImage || "",
progress: clampProgress(source.progress ?? DEFAULT_ITEM.progress),
currentTime: source.currentTime || DEFAULT_ITEM.currentTime,
duration: source.duration || DEFAULT_ITEM.duration,
code: resource.code || "",
title: summary.title || "",
subTitle: summary.subtitle || "",
coverUrl: summary.coverUrl || "",
url: payload.url || "",
mimeType: payload.mimeType || "",
durationMs: payload.durationMs,
duration: formatDuration(payload.durationMs),
};
});

View File

@@ -0,0 +1,73 @@
<template>
<view class="box-border w-full px-[12px]">
<scroll-view
class="w-full"
:scroll-x="hasMultipleGoods"
:show-scrollbar="false"
enhanced
>
<view class="flex w-full gap-[12px]">
<view
v-for="goods in goodsList"
:key="goods.resource?.payload?.commodityId"
class="flex h-[88px]"
:class="{
'w-full': !hasMultipleGoods,
'w-4/5 shrink-0': hasMultipleGoods,
}"
>
<view
class="h-[88px] w-[88px] shrink-0 overflow-hidden rounded-[16px] bg-[#eef8f2] shadow-[0_4px_18px_rgba(31,110,74,0.08)]"
>
<image class="h-full w-full" :src="goods.resource?.payload?.commodityPhoto" mode="aspectFill" />
</view>
<view
class="box-border flex h-full min-w-0 flex-1 flex-col rounded-[16px] bg-white px-[12px] py-[8px] shadow-[0_4px_18px_rgba(31,110,74,0.08)]"
>
<text class="block truncate text-[14px] font-semibold leading-[22px] text-[#1f2937]">
{{ goods.resource?.payload?.commodityName }}
</text>
<text class="mt-[2px] block truncate text-[11px] leading-[18px] text-[#9ca3af]">
{{ goods.resource?.payload?.commodityDescription }}
</text>
<view class="mt-auto flex items-end justify-between gap-[8px]">
<view class="min-w-0 flex-1 truncate font-bold text-[#111827]">
<text class="text-[14px]">¥</text>
<text class="text-[20px] leading-[28px]">{{ goods.resource?.payload?.specificationPrice }}</text>
</view>
<button
class="m-0 flex h-[26px] shrink-0 items-center justify-center rounded-full bg-[#20cf6d] px-[12px] text-[14px] leading-none text-white after:border-0"
@click.stop="handleBuy(goods)"
>
购买
</button>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
list: {
type: Array,
default: () => [],
},
});
const goodsList = computed(() => props.list);
const hasMultipleGoods = computed(() => goodsList.value.length > 1);
const handleBuy = (goods) => {
uni.navigateTo({
url: `/pages/goods/index?commodityId=${encodeURIComponent(goods.resource?.payload?.commodityId)}`,
});
};
</script>

View File

@@ -1,7 +1,8 @@
<template>
<view class="discovery-guide-map-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect">
<view class="guide-map-visual">
<view class="guidepost-illustration">
<image v-if="normalizedItem.coverUrl" class="h-full w-full" :src="normalizedItem.coverUrl" mode="aspectFill" />
<view v-else class="guidepost-illustration">
<view class="guidepost-sign is-pink">
<view class="guidepost-line" />
</view>
@@ -25,12 +26,10 @@
<script setup>
import { computed } from "vue";
const DEFAULT_ITEM = {
id: "guide-map",
title: "卧龙潭电子导览图",
subTitle: "查看景区平面图与设施位置",
};
import {
getGuideMapNavigationOptions,
normalizeGuideMapItem,
} from "./model.mjs";
const props = defineProps({
item: {
@@ -39,21 +38,27 @@ const props = defineProps({
},
});
const emit = defineEmits(["didSelectItem"]);
const normalizedItem = computed(() => {
const source = Object.keys(props.item).length > 0 ? props.item : DEFAULT_ITEM;
return {
raw: source,
id: source.id ?? source.tabContentId ?? DEFAULT_ITEM.id,
title: source.title || DEFAULT_ITEM.title,
subTitle: source.subTitle || source.desc || DEFAULT_ITEM.subTitle,
};
});
const normalizedItem = computed(() => normalizeGuideMapItem(props.item));
const handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw);
const locationOptions = getGuideMapNavigationOptions(normalizedItem.value.raw);
if (!locationOptions) {
uni.showToast({
title: "暂无导航坐标",
icon: "none",
});
return;
}
uni.openLocation({
...locationOptions,
fail: () => {
uni.showToast({
title: "打开导航失败",
icon: "none",
});
},
});
};
</script>

View File

@@ -0,0 +1,50 @@
const normalizeGuideMapItem = (item) => {
const source = item && typeof item === "object" ? item : {};
const resource = source.resource || {};
const summary = resource.summary || {};
const payload = resource.payload || {};
const location = payload.location || {};
return {
raw: source,
id: resource.code || payload.poiCode || "",
resourceCode: resource.code || "",
resourceType: resource.type || "",
title: summary.title || payload.name || "",
subTitle: summary.subtitle || payload.description || "",
coverUrl: summary.coverUrl || "",
poiCode: payload.poiCode || "",
name: payload.name || summary.title || "",
categoryCode: payload.categoryCode || "",
address: payload.address || "",
description: payload.description || "",
latitude: location.latitude,
longitude: location.longitude,
};
};
const getGuideMapNavigationOptions = (item) => {
const normalizedItem = normalizeGuideMapItem(item);
const hasEmptyCoordinate = [normalizedItem.latitude, normalizedItem.longitude]
.some((value) => value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
if (hasEmptyCoordinate) {
return null;
}
const latitude = Number(normalizedItem.latitude);
const longitude = Number(normalizedItem.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
latitude,
longitude,
name: normalizedItem.name || normalizedItem.title,
address: normalizedItem.address || normalizedItem.description,
};
};
export { getGuideMapNavigationOptions, normalizeGuideMapItem };

View File

@@ -1,6 +1,6 @@
.discovery-guide-map-tool-card {
width: 100%;
height: 96px;
height: 88px;
display: flex;
align-items: flex-start;
box-sizing: border-box;

View File

@@ -5,10 +5,14 @@
class="z-[12] flex w-full shrink-0 flex-row items-center bg-[#F0F8F3]"
>
<view
class="ml-[12px] mr-[8px] flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full border border-white/20 bg-[rgba(0,0,0,0.3)]"
class="ml-[12px] mr-[8px] p-[4px] flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-full border border-white/70"
@click="handleMainPanoramaClick"
>
<uni-icons type="arrow-left" size="18" color="#FFFFFF" />
<image
class="h-full w-full"
src="https://one-feel-config-images-bucket.oss-cn-chengdu.aliyuncs.com/xiaoqi/back_home.png"
mode="aspectFit"
/>
</view>
<view v-if="discoveryTabs.length > 0" class="min-w-0 flex-1 overflow-hidden">
<FindTabs v-model="activeIndex" :tabs="discoveryTabs" @change="handleTabChange" />
@@ -23,12 +27,16 @@
<CardSwiper v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" />
<ExploreCards v-if="exploreCards.length > 0" :list="exploreCards" @didSelectItem="handleCardClick" />
<DiscoveryPhotoToolCard v-if="discoveryPhotoToolData" :item="discoveryPhotoToolData"
@didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
<DiscoveryAudioToolCard v-if="discoveryAudioToolData" :item="discoveryAudioToolData"
@didSelectItem="handleToolCardClick" />
<DiscoveryGuideMapToolCard v-if="discoveryGuideMapToolData" :item="discoveryGuideMapToolData" />
<DiscoveryGoodsCard v-if="discoveryGoodsData.length > 0" :list="discoveryGoodsData" />
<QuickQuestions v-if="discoveryQuickQuestions.length > 0" :list="discoveryQuickQuestions"
@didSelectItem="handleQuickQuestionClick" />
<!-- <DiscoveryPhotoToolCard @didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
<DiscoveryAudioToolCard @didSelectItem="handleToolCardClick" />
<DiscoveryGuideMapToolCard @didSelectItem="handleToolCardClick" /> -->
</view>
</scroll-view>
</view>
@@ -47,9 +55,9 @@ import QuickQuestions from "./components/QuickQuestions/index.vue";
import DiscoveryPhotoToolCard from "./components/DiscoveryPhotoToolCard/index.vue";
import DiscoveryAudioToolCard from "./components/DiscoveryAudioToolCard/index.vue";
import DiscoveryGuideMapToolCard from "./components/DiscoveryGuideMapToolCard/index.vue";
import DiscoveryGoodsCard from "./components/DiscoveryGoodsCard/index.vue";
import PanoramicHome from "./components/PanoramicHome/index.vue";
import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData } from "../../request/api/MainPageDataApi";
import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData, resourceCenterData } from "../../request/api/MainPageDataApi";
import { useAppStore, useLocationStore } from "@/store";
import { JumpType } from "../../model/ChatModel";
@@ -63,6 +71,10 @@ const discoveryTabs = ref([]);
const discoveryCards = ref([]);
const exploreCards = ref([]);
const discoveryQuickQuestions = ref([]);
const discoveryPhotoToolData = ref(null);
const discoveryAudioToolData = ref(null);
const discoveryGuideMapToolData = ref(null);
const discoveryGoodsData = ref([]);
const emit = defineEmits(["content-scroll"]);
/// 是否是主景区引导页 默认是 true点击主景区后为 false
@@ -82,13 +94,17 @@ const handleMainPanoramaClick = () => {
discoveryCards.value = [];
exploreCards.value = [];
discoveryQuickQuestions.value = [];
discoveryPhotoToolData.value = null;
discoveryAudioToolData.value = null;
discoveryGuideMapToolData.value = null;
discoveryGoodsData.value = [];
};
/// tabs 切换事件
const handleTabChange = ({ tab, idx }) => {
activeIndex.value = idx;
queryDiscoveryData(tab.id);
queryDiscoveryData(tab);
}
/// 请求发现页tab数据
@@ -121,24 +137,31 @@ const handlePanoramicHomeSelect = (item) => {
/// 根据id查询tab并设置activeIndex
const findTabByIdWithActiveTabIndex = (tabsId) => {
/// 查询是否有id参数
const activeTabIndex = discoveryTabs.value.findIndex((tab) => tab.id === tabsId);
/// 如果有则优先展示对应tab数据没有则展示第一个tab数据
if (activeTabIndex > -1) {
activeIndex.value = activeTabIndex;
queryDiscoveryData(tabsId);
} else {
if (discoveryTabs.value.length > 0) {
activeIndex.value = 0;
queryDiscoveryData(discoveryTabs.value[0].id);
}
}
const resolvedTab = resolveDiscoveryTab(discoveryTabs.value, tabsId);
if (!resolvedTab.tab) return;
activeIndex.value = resolvedTab.activeIndex;
queryDiscoveryData(resolvedTab.tab);
}
/// 找到对应的tab后查询发现页数据
const resolveDiscoveryTab = (tabs, tabId) => {
if (!Array.isArray(tabs) || tabs.length === 0) {
return { activeIndex: -1, tab: null };
}
const matchedIndex = tabs.findIndex((tab) => tab.id === tabId);
const activeIndex = matchedIndex > -1 ? matchedIndex : 0;
return { activeIndex, tab: tabs[activeIndex] };
};
/// 统一请求发现页数据
const queryDiscoveryData = async (tabId) => {
queryDiscoveryCards(tabId);
queryQuickQuestionData(tabId);
const queryDiscoveryData = async (tab) => {
if (!tab?.id) return;
queryDiscoveryCards(tab.id);
queryQuickQuestionData(tab.id);
loadResourceCenterDataList(tab.resourceSetCode);
};
/// 请求发现页卡片数据
@@ -171,6 +194,20 @@ const queryQuickQuestionData = async (tabId) => {
}
}
/// 按资源集编码读取当前生效快照
const loadResourceCenterDataList = async (setCode) => {
if (!setCode) return;
const res = await resourceCenterData({ setCode });
if (res.code === 0) {
const discoveryResourceItems = res.data.items || [];
// AUDIO POI COMMODITY COMPONENT
discoveryPhotoToolData.value = discoveryResourceItems.find(item => item.resource?.type === 'COMPONENT') || null;
discoveryAudioToolData.value = discoveryResourceItems.find(item => item.resource?.type === 'AUDIO') || null;
discoveryGuideMapToolData.value = discoveryResourceItems.find(item => item.resource?.type === 'POI') || null;
discoveryGoodsData.value = discoveryResourceItems.filter(item => item.resource?.type === 'COMMODITY') || [];
}
}
/// 统一处理接口返回数据结构
const configDataList = (data) => {
return data.map((item) => ({

View File

@@ -15,6 +15,11 @@ function homeTabContentData(args) {
return request.post("/hotelBiz/mainScene/queryContentListWithCache", args);
}
/// 按资源集编码读取当前生效快照
function resourceCenterData(args) {
return request.post("/hotelBiz/resourceCenter/client/sets", args);
}
/// 获取距离用户最近的标签
function getNearbyTags(args) {
return request.get("/hotelBiz/mainScene/nearestTag", args);
@@ -55,6 +60,7 @@ export {
homeAllScenicData,
homeTabsData,
homeTabContentData,
resourceCenterData,
getNearbyTags,
getLocalWeather,
getTimeNoticeList,