Compare commits
20 Commits
93dd4f388f
...
home3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 581734cedc | |||
| 7cfdd27bbc | |||
| 329547891e | |||
| 040c56aad1 | |||
| 0ae1b15007 | |||
| fe1b3f5e02 | |||
| 7d7c235a71 | |||
| 0020c8bb47 | |||
| cc1615162a | |||
| b5206c18d1 | |||
| 22b1686c26 | |||
| f4f4c05766 | |||
| f7b08a0600 | |||
| b36a47b314 | |||
| 937dfe3042 | |||
| ea80ce000e | |||
|
|
8de0038742 | ||
|
|
9f0881a284 | ||
|
|
57f6cdd85e | ||
|
|
06afbcc5c1 |
84
docs/superpowers/plans/2026-08-05-aigc-upgrade-image.md
Normal file
84
docs/superpowers/plans/2026-08-05-aigc-upgrade-image.md
Normal 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 user’s pre-existing `detail.vue` and `AigcApi.js` work without rewriting it.
|
||||
@@ -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`;不运行项目生产构建或微信小程序开发命令。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 呼叫热线弹窗布局调整设计
|
||||
|
||||
## 目标
|
||||
|
||||
调整呼叫热线弹窗的信息层级,使标题和关闭按钮稳定显示在弹窗顶部,并提高两个电话号码的可读性与可点击性。
|
||||
|
||||
## 设计
|
||||
|
||||
- 将标题和关闭按钮保留在独立头部区域,头部不参与正文内容排版,也不随正文压缩。
|
||||
- 正文继续展示原有提示文案,不改变文案含义。
|
||||
- 两个电话号码从行内文本中拆出,各自使用独立块级容器并各占一行。
|
||||
- 电话号码字号调整为 `20px`,保留现有蓝色样式和点击拨号行为。
|
||||
- 使用 Tailwind CSS 工具类完成布局调整,不新增 SCSS 布局样式。
|
||||
|
||||
## 数据与交互
|
||||
|
||||
电话号码常量、弹窗打开/关闭事件及 `uni.makePhoneCall` 调用保持不变。点击任一电话号码时,仍先关闭弹窗,再呼起对应号码的系统拨号界面。
|
||||
|
||||
## 验证
|
||||
|
||||
- 结构检查确认头部与正文是相邻且独立的区域。
|
||||
- 结构检查确认两个电话号码分别位于独立块级元素中。
|
||||
- 结构检查确认两个号码均使用更大的字号并保留独立点击事件。
|
||||
- 运行 `git diff --check` 检查格式问题。
|
||||
|
||||
## 范围
|
||||
|
||||
仅修改 `src/pages/ChatModule/CallMobile/index.vue` 的弹窗模板布局,不调整业务逻辑、文案内容或其他页面。
|
||||
@@ -6,7 +6,9 @@
|
||||
<template v-if="taskDetail">
|
||||
<ResultPreview v-if="result.cover" class="aigc-detail-preview" :result="result" />
|
||||
<view class="aigc-detail-controls">
|
||||
<ResultMeta :items="metaItems" />
|
||||
<!-- <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" />
|
||||
|
||||
@@ -41,9 +43,9 @@ import {
|
||||
getAigcGeneratorTaskDetail,
|
||||
} 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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.template-version-hero {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: var(--template-accent, #0a4d46);
|
||||
|
||||
@@ -48,7 +48,10 @@ 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 {
|
||||
createAigcImageToVideoTask,
|
||||
createAigcGeneratorTask,
|
||||
getAigcImageToVideoItemDetail,
|
||||
getAigcGeneratorTaskDetail,
|
||||
getAigcTemplateItemList,
|
||||
getAigcTemplateList,
|
||||
} from "@/pages-aigc/request/AigcApi.js";
|
||||
@@ -76,9 +79,22 @@ 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 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(() => {
|
||||
@@ -90,7 +106,11 @@ const currentTemplateItem = computed(() => {
|
||||
});
|
||||
|
||||
const currentCost = computed(() => {
|
||||
return Number(currentTemplateItem.value.generatorCost) || 0;
|
||||
return isImageToVideoUpgrade.value
|
||||
? Number(imageToVideoItemDetail.value?.generatorCost) || 0
|
||||
: Number(currentTemplateItem.value.generatorType) === 1
|
||||
? VIDEO_GENERATION_COST
|
||||
: IMAGE_GENERATION_COST;
|
||||
});
|
||||
|
||||
const showPlaceholderToast = (title) => {
|
||||
@@ -100,6 +120,40 @@ const showPlaceholderToast = (title) => {
|
||||
});
|
||||
};
|
||||
|
||||
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 = "";
|
||||
uploadedImageUrl.value = "";
|
||||
@@ -130,7 +184,17 @@ const fetchTemplateItemList = async () => {
|
||||
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 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);
|
||||
@@ -139,11 +203,15 @@ const fetchTemplateItemList = async () => {
|
||||
|
||||
const fetchPageData = async () => {
|
||||
await fetchTemplateList();
|
||||
await fetchImageToVideoItemDetail();
|
||||
await fetchUpgradeSourceTaskDetail();
|
||||
await fetchTemplateItemList();
|
||||
};
|
||||
|
||||
onLoad((query = {}) => {
|
||||
templateId.value = query.templateId || "";
|
||||
templateId.value = String(query.templateId || "").trim();
|
||||
upgradeTaskId.value = String(query.upgradeTaskId || "").trim();
|
||||
upgradeTemplateItemId.value = String(query.upgradeTemplateItemId || "").trim();
|
||||
fetchPageData();
|
||||
});
|
||||
|
||||
@@ -336,7 +404,12 @@ const handleGenerate = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploadedImageUrl.value) {
|
||||
if (isImageToVideoUpgrade.value && !imageToVideoItemDetail.value) {
|
||||
showPlaceholderToast("升级视频费用加载失败,请稍后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isImageToVideoUpgrade.value && !uploadedImageUrl.value) {
|
||||
showPlaceholderToast("请先上传图片");
|
||||
currentStep.value = "photoPick";
|
||||
return;
|
||||
@@ -357,14 +430,30 @@ const handleGenerate = async () => {
|
||||
});
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (isImageToVideoUpgrade.value) {
|
||||
const wxLoginCode = await getWechatLoginCode();
|
||||
const res = await createAigcGeneratorTask({
|
||||
res = await createAigcImageToVideoTask({
|
||||
taskId: upgradeTaskId.value,
|
||||
wxLoginCode,
|
||||
});
|
||||
} else {
|
||||
const wxLoginCode = await getWechatLoginCode();
|
||||
res = await createAigcGeneratorTask({
|
||||
templateItemId,
|
||||
imageUrlList: [uploadedImageUrl.value],
|
||||
wxLoginCode,
|
||||
});
|
||||
}
|
||||
|
||||
const taskId = String(res?.data || "").trim();
|
||||
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({
|
||||
|
||||
@@ -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]), [
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</view>
|
||||
|
||||
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
|
||||
<Discovery @content-scroll="handleDiscoveryContentScroll" />
|
||||
<Discovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<uni-popup ref="popup" type="center" :safe-area="false">
|
||||
<view class="popup-content bg-white mx-[24px] px-[12px] py-16 rounded-[12px]">
|
||||
<view class="header flex items-center pb-[12px]">
|
||||
<view class="popup-content mx-[40px] flex max-h-[80vh] flex-col overflow-hidden rounded-[12px] bg-white">
|
||||
<view class="header flex shrink-0 items-center bg-white px-[12px] pb-[12px] pt-[16px]">
|
||||
<view class="title flex-1 text-center text-[17px] text-[#000000] font-[500] ml-[24px]">呼叫热线</view>
|
||||
<uni-icons type="close" size="24" color="#CACFD8" @click="close" />
|
||||
</view>
|
||||
|
||||
<view class="border-box pl-[12px] pr-[12px]">
|
||||
<view class="border-box min-h-0 flex-1 overflow-y-auto px-[24px] pb-[32px]">
|
||||
<view class="text-[16px] text-[#a3a3a3] leading-[22px]">
|
||||
如有任何疑问,欢迎随时致电景区咨询热线:
|
||||
<text class="text-[#2563eb]" @click="handleClick(mobleNumber1)">{{ mobleNumber1 }}</text> / <text
|
||||
class="text-[#2563eb]" @click="handleClick(mobleNumber2)">{{ mobleNumber2 }}</text>,
|
||||
我们将全程为您提供细致解答与暖心服务。
|
||||
如有任何疑问,欢迎随时致电景区咨询热线,我们将全程为您提供细致解答与暖心服务。
|
||||
</view>
|
||||
<view class="mt-[12px] flex flex-col gap-[8px]">
|
||||
<view class="phone-row text-[16px] text-[#a3a3a3] leading-[28px]" @click="handleClick(mobleNumber1)">热线1: <text class="text-[18px] text-[#2563eb]">{{ mobleNumber1 }}</text></view>
|
||||
<view class="phone-row text-[16px] text-[#a3a3a3] leading-[28px]" @click="handleClick(mobleNumber2)">热线2: <text class="text-[18px] text-[#2563eb]">{{ mobleNumber2 }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<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" />
|
||||
<view class="audio-play" @tap.stop="handlePlaybackToggle">
|
||||
<view v-if="isPlaying" class="flex items-center gap-[6px]">
|
||||
<view class="h-[20px] w-[5px] rounded-[2px] bg-white" />
|
||||
<view class="h-[20px] w-[5px] rounded-[2px] bg-white" />
|
||||
</view>
|
||||
<view v-else class="audio-play-triangle" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -14,58 +18,209 @@
|
||||
|
||||
<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 class="audio-progress-fill" :style="{ width: `${progressPercent}%` }" />
|
||||
</view>
|
||||
<text class="audio-time block w-[90px] truncate">
|
||||
{{ normalizedItem.currentTime }} / {{ normalizedItem.duration }}
|
||||
</text>
|
||||
<text class="audio-time block w-[90px] truncate">{{ playbackTimeText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_ITEM = {
|
||||
id: "audio-guide",
|
||||
title: "卧龙潭语音讲解",
|
||||
subTitle: "解说:响水洞瀑布",
|
||||
progress: 31,
|
||||
currentTime: "02:15",
|
||||
duration: "05:40",
|
||||
};
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { onPageHide } from "@dcloudio/uni-app";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 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,
|
||||
};
|
||||
});
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const currentTimeSeconds = ref(0);
|
||||
const audioDurationSeconds = ref(0);
|
||||
let audioContext = null;
|
||||
|
||||
const formatSeconds = (secondsValue) => {
|
||||
const value = Number(secondsValue);
|
||||
if (!Number.isFinite(value) || value < 0) return "";
|
||||
|
||||
const totalSeconds = Math.floor(value);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const metadataDurationSeconds = computed(() => {
|
||||
const durationMs = Number(normalizedItem.value.durationMs);
|
||||
return Number.isFinite(durationMs) && durationMs > 0 ? durationMs / 1000 : 0;
|
||||
});
|
||||
|
||||
const effectiveDurationSeconds = computed(
|
||||
() => audioDurationSeconds.value || metadataDurationSeconds.value,
|
||||
);
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!effectiveDurationSeconds.value) return 0;
|
||||
|
||||
const progress = (currentTimeSeconds.value / effectiveDurationSeconds.value) * 100;
|
||||
return Math.min(Math.max(progress, 0), 100);
|
||||
});
|
||||
|
||||
const playbackTimeText = computed(() => {
|
||||
if (!effectiveDurationSeconds.value && !currentTimeSeconds.value) return "";
|
||||
|
||||
const currentTimeText = formatSeconds(currentTimeSeconds.value);
|
||||
const durationText = formatSeconds(effectiveDurationSeconds.value);
|
||||
return durationText ? `${currentTimeText} / ${durationText}` : currentTimeText;
|
||||
});
|
||||
|
||||
const resetPlaybackState = () => {
|
||||
isPlaying.value = false;
|
||||
currentTimeSeconds.value = 0;
|
||||
audioDurationSeconds.value = 0;
|
||||
};
|
||||
|
||||
const destroyAudioContext = () => {
|
||||
if (!audioContext) return;
|
||||
|
||||
const context = audioContext;
|
||||
audioContext = null;
|
||||
context.pause();
|
||||
context.destroy();
|
||||
};
|
||||
|
||||
const stopAndResetPlayback = () => {
|
||||
destroyAudioContext();
|
||||
resetPlaybackState();
|
||||
};
|
||||
|
||||
const createAudioContext = () => {
|
||||
const url = normalizedItem.value.url;
|
||||
console.log("Creating audio context for URL:", url);
|
||||
if (!url) return null;
|
||||
|
||||
const context = uni.createInnerAudioContext();
|
||||
audioContext = context;
|
||||
context.autoplay = false;
|
||||
context.src = url;
|
||||
|
||||
context.onPlay(() => {
|
||||
if (audioContext === context) isPlaying.value = true;
|
||||
});
|
||||
|
||||
context.onPause(() => {
|
||||
if (audioContext === context) isPlaying.value = false;
|
||||
});
|
||||
|
||||
context.onCanplay(() => {
|
||||
if (audioContext !== context) return;
|
||||
|
||||
const duration = Number(context.duration);
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
audioDurationSeconds.value = duration;
|
||||
}
|
||||
});
|
||||
|
||||
context.onTimeUpdate(() => {
|
||||
if (audioContext !== context) return;
|
||||
|
||||
currentTimeSeconds.value = Number(context.currentTime) || 0;
|
||||
const duration = Number(context.duration);
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
audioDurationSeconds.value = duration;
|
||||
}
|
||||
});
|
||||
|
||||
context.onEnded(() => {
|
||||
if (audioContext !== context) return;
|
||||
|
||||
isPlaying.value = false;
|
||||
currentTimeSeconds.value = 0;
|
||||
});
|
||||
|
||||
context.onStop(() => {
|
||||
if (audioContext !== context) return;
|
||||
|
||||
isPlaying.value = false;
|
||||
currentTimeSeconds.value = 0;
|
||||
});
|
||||
|
||||
context.onError(() => {
|
||||
if (audioContext !== context) return;
|
||||
|
||||
stopAndResetPlayback();
|
||||
uni.showToast({
|
||||
title: "音频播放失败",
|
||||
icon: "none",
|
||||
});
|
||||
});
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
const handlePlaybackToggle = () => {
|
||||
if (!normalizedItem.value.url) return;
|
||||
|
||||
const context = audioContext || createAudioContext();
|
||||
if (!context) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
isPlaying.value = false;
|
||||
context.pause();
|
||||
} else {
|
||||
isPlaying.value = true;
|
||||
context.play();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
if (!active) stopAndResetPlayback();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => normalizedItem.value.url,
|
||||
() => {
|
||||
stopAndResetPlayback();
|
||||
},
|
||||
);
|
||||
|
||||
onPageHide(() => {
|
||||
stopAndResetPlayback();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAndResetPlayback();
|
||||
});
|
||||
|
||||
const handleSelect = () => {
|
||||
emit("didSelectItem", normalizedItem.value.raw);
|
||||
};
|
||||
|
||||
73
src/pages/Discovery/components/DiscoveryGoodsCard/index.vue
Normal file
73
src/pages/Discovery/components/DiscoveryGoodsCard/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
<template>
|
||||
<view class="discovery-photo-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect">
|
||||
<view class="discovery-photo-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleJump">
|
||||
<view class="photo-visual w-[88px] min-w-0">
|
||||
<view class="camera-illustration">
|
||||
<view class="camera-top" />
|
||||
<view class="camera-flash" />
|
||||
<view class="camera-body">
|
||||
<view class="camera-lens">
|
||||
<view class="camera-lens-inner" />
|
||||
</view>
|
||||
<view class="camera-dot is-large" />
|
||||
<view class="camera-dot is-small" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="photo-badge w-[calc(100%_-_18px)] truncate">{{ normalizedItem.badge }}</text>
|
||||
<image v-if="normalizedItem.coverUrl" class="block h-full w-full" :src="normalizedItem.coverUrl" mode="aspectFill" />
|
||||
</view>
|
||||
|
||||
<view class="photo-content min-w-0 flex-1">
|
||||
@@ -20,13 +9,12 @@
|
||||
<text class="photo-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text>
|
||||
|
||||
<view class="photo-options">
|
||||
<view v-for="(option, index) in normalizedItem.options" :key="getOptionKey(option, index)" class="photo-option min-w-0 flex-1"
|
||||
hover-class="is-option-pressed" hover-stay-time="80" @tap.stop="handleOptionSelect(option, index)">
|
||||
<view v-for="(photo, index) in normalizedItem.options" :key="getPhotoKey(photo, index)" class="photo-option min-w-0 flex-1"
|
||||
hover-class="is-option-pressed" hover-stay-time="80" @tap.stop="handleJump">
|
||||
<view class="photo-option-thumb">
|
||||
<image v-if="option.coverImage" class="photo-option-image" :src="option.coverImage" mode="aspectFill" />
|
||||
<view v-else class="photo-option-placeholder" :class="`is-${index}`" />
|
||||
<image v-if="photo?.photoUrl" class="photo-option-image" :src="photo.photoUrl" mode="aspectFill" />
|
||||
</view>
|
||||
<text class="photo-option-label block w-[76px] truncate">{{ option.label }}</text>
|
||||
<text class="photo-option-label block w-[76px] truncate">{{ photo?.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -35,20 +23,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_OPTIONS = [
|
||||
{ id: "check-in", label: "景点打卡" },
|
||||
{ id: "companion", label: "小七陪伴" },
|
||||
{ id: "memory", label: "风格纪念" },
|
||||
];
|
||||
|
||||
const DEFAULT_ITEM = {
|
||||
id: "travel-photo",
|
||||
title: "在卧龙潭和小七合影",
|
||||
subTitle: "生成你在卧龙潭的专属纪念照",
|
||||
badge: "旅行纪念照",
|
||||
options: DEFAULT_OPTIONS,
|
||||
};
|
||||
import { navigateTo } from "@/router";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@@ -57,37 +32,27 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["didSelectItem", "didSelectOption"]);
|
||||
|
||||
const normalizeOption = (option, index) => ({
|
||||
raw: option,
|
||||
id: option.id ?? option.tabContentId ?? index,
|
||||
label: option.label || option.title || DEFAULT_OPTIONS[index]?.label || "",
|
||||
coverImage: option.coverImage || option.image || option.url || "",
|
||||
});
|
||||
|
||||
const normalizedItem = computed(() => {
|
||||
const source = Object.keys(props.item).length > 0 ? props.item : DEFAULT_ITEM;
|
||||
const options = source.options || source.children || source.items || DEFAULT_OPTIONS;
|
||||
const resource = props.item?.resource;
|
||||
const summary = resource?.summary;
|
||||
const componentParams = resource?.payload?.componentParams;
|
||||
|
||||
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,
|
||||
badge: source.badge || source.tag || DEFAULT_ITEM.badge,
|
||||
options: options.slice(0, 3).map(normalizeOption),
|
||||
title: summary?.title,
|
||||
subTitle: summary?.subtitle,
|
||||
coverUrl: summary?.coverUrl,
|
||||
jumpUrl: componentParams?.jumpUrl,
|
||||
options: Array.isArray(componentParams?.photoList)
|
||||
? componentParams.photoList.slice(0, 3)
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
const getOptionKey = (option, index) => option.id ?? option.label ?? index;
|
||||
const getPhotoKey = (photo, index) => photo?.photoUrl ?? photo?.title ?? index;
|
||||
|
||||
const handleSelect = () => {
|
||||
emit("didSelectItem", normalizedItem.value.raw);
|
||||
};
|
||||
|
||||
const handleOptionSelect = (option, index) => {
|
||||
emit("didSelectOption", option.raw, normalizedItem.value.raw, index);
|
||||
const handleJump = () => {
|
||||
if (!normalizedItem.value.jumpUrl) return;
|
||||
navigateTo(normalizedItem.value.jumpUrl);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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,15 @@
|
||||
<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" />
|
||||
<DiscoveryAudioToolCard v-if="discoveryAudioToolData" :item="discoveryAudioToolData"
|
||||
:active="props.active" @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,22 +54,34 @@ 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";
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const locationStore = useLocationStore();
|
||||
/// 从个渠道获取如二维码
|
||||
const sceneId = appStore.sceneId || "";
|
||||
const currentTabsId = ref("");
|
||||
|
||||
const activeIndex = ref(0);
|
||||
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
|
||||
@@ -78,17 +97,22 @@ const handleContentScroll = (e) => {
|
||||
/// 点击主景区
|
||||
const handleMainPanoramaClick = () => {
|
||||
isMainPanoramaGuide.value = true;
|
||||
currentTabsId.value = "";
|
||||
discoveryTabs.value = [];
|
||||
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 +145,33 @@ 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?.id) return;
|
||||
|
||||
if (currentTabsId.value === resolvedTab.tab.id) return;
|
||||
currentTabsId.value = resolvedTab.tab.id;
|
||||
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 +204,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) => ({
|
||||
@@ -202,14 +249,6 @@ const handleToolCardClick = (item) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolOptionClick = (option, parentItem) => {
|
||||
if (option?.jumpType !== undefined) {
|
||||
handleClick(option);
|
||||
} else {
|
||||
handleToolCardClick(parentItem);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async (item) => {
|
||||
console.log(`执行点击事件: ${item.jumpType},参数:${JSON.stringify(item.jumpContent)}`);
|
||||
/// 商品
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/uni.scss
20
src/uni.scss
@@ -13,16 +13,16 @@
|
||||
*/
|
||||
|
||||
/* 主题颜色(由 switch-client 命令自动更新) */
|
||||
$theme-color-900: #0b5c2d;
|
||||
$theme-color-800: #0b7034;
|
||||
$theme-color-700: #0b5c2d;
|
||||
$theme-color-600: #02c34e;
|
||||
$theme-color-500: #0ccd58;
|
||||
$theme-color-400: #35f37f;
|
||||
$theme-color-300: #77feab;
|
||||
$theme-color-200: #a0e5ba;
|
||||
$theme-color-100: #e8fff1;
|
||||
$theme-color-50: #f0f8f3;
|
||||
$theme-color-900: #0B5C2D;
|
||||
$theme-color-800: #0B7034;
|
||||
$theme-color-700: #0B5C2D;
|
||||
$theme-color-600: #02C34E;
|
||||
$theme-color-500: #0CCD58;
|
||||
$theme-color-400: #35F37F;
|
||||
$theme-color-300: #77FEAB;
|
||||
$theme-color-200: #A0E5BA;
|
||||
$theme-color-100: #E8FFF1;
|
||||
$theme-color-50: #F0F8F3;
|
||||
|
||||
// text 颜色
|
||||
$text-color-900: #181b25;
|
||||
|
||||
Reference in New Issue
Block a user