17 Commits

Author SHA1 Message Date
581734cedc feat: aigc 组件对接字段 2026-08-07 17:00:36 +08:00
7cfdd27bbc feat: 热线弹窗调整 2026-08-07 16:50:29 +08:00
329547891e docs: define call mobile popup layout 2026-08-07 16:28:03 +08:00
040c56aad1 feat: 颜色字符串格式化 2026-08-07 15:58:01 +08:00
0ae1b15007 实现音频组件的音频播放 2026-08-07 15:57:28 +08:00
fe1b3f5e02 feat: 处理重复请求 2026-08-07 10:21:25 +08:00
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
16 changed files with 491 additions and 168 deletions

View File

@@ -0,0 +1,28 @@
# 呼叫热线弹窗布局调整设计
## 目标
调整呼叫热线弹窗的信息层级,使标题和关闭按钮稳定显示在弹窗顶部,并提高两个电话号码的可读性与可点击性。
## 设计
- 将标题和关闭按钮保留在独立头部区域,头部不参与正文内容排版,也不随正文压缩。
- 正文继续展示原有提示文案,不改变文案含义。
- 两个电话号码从行内文本中拆出,各自使用独立块级容器并各占一行。
- 电话号码字号调整为 `20px`,保留现有蓝色样式和点击拨号行为。
- 使用 Tailwind CSS 工具类完成布局调整,不新增 SCSS 布局样式。
## 数据与交互
电话号码常量、弹窗打开/关闭事件及 `uni.makePhoneCall` 调用保持不变。点击任一电话号码时,仍先关闭弹窗,再呼起对应号码的系统拨号界面。
## 验证
- 结构检查确认头部与正文是相邻且独立的区域。
- 结构检查确认两个电话号码分别位于独立块级元素中。
- 结构检查确认两个号码均使用更大的字号并保留独立点击事件。
- 运行 `git diff --check` 检查格式问题。
## 范围
仅修改 `src/pages/ChatModule/CallMobile/index.vue` 的弹窗模板布局,不调整业务逻辑、文案内容或其他页面。

View File

@@ -65,6 +65,7 @@ const pointDialogVisible = ref(false);
const GENERATOR_TYPE_TEXT = { const GENERATOR_TYPE_TEXT = {
0: "图片版", 0: "图片版",
1: "视频版", 1: "视频版",
2: "升视频",
}; };
const TASK_STATUS_TEXT = { const TASK_STATUS_TEXT = {
@@ -92,7 +93,7 @@ const result = computed(() => {
const imageUrl = record.imageResultUrl || ""; const imageUrl = record.imageResultUrl || "";
const videoUrl = record.videoResultUrl || ""; const videoUrl = record.videoResultUrl || "";
const isVideo = [1, 2].includes(Number(record.generatorType)); const isVideo = [1, 2].includes(Number(record.generatorType));
const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型"); const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型");
const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态"); const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态");
const consumedText = const consumedText =

View File

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

View File

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

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.deepEqual(Object.keys(PRODUCT_IMAGES), visibleProducts.map(({ id }) => id))
assert.ok(SCENIC_HERO_IMAGE.endsWith('/hero-bridge.jpg')) assert.ok(SCENIC_HERO_IMAGE.endsWith('/hero-bridge.jpg'))
assert.notEqual(SCENIC_HERO_IMAGE, PLACEHOLDER_IMAGE) 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(new Set([SCENIC_HERO_IMAGE, ...visibleProducts.map(({ images }) => images[0])]).size, 10)
assert.equal(productsById.get('guide').images[0], PLACEHOLDER_IMAGE) assert.equal(productsById.get('guide').images[0], PLACEHOLDER_IMAGE)
assert.deepEqual(PRODUCTS.map(({ id, salePrice, marketPrice, sold }) => [id, salePrice, marketPrice, sold]), [ assert.deepEqual(PRODUCTS.map(({ id, salePrice, marketPrice, sold }) => [id, salePrice, marketPrice, sold]), [

View File

@@ -29,7 +29,7 @@
</view> </view>
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }"> <view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
<Discovery @content-scroll="handleDiscoveryContentScroll" /> <Discovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
</view> </view>
</view> </view>

View File

@@ -1,17 +1,18 @@
<template> <template>
<uni-popup ref="popup" type="center" :safe-area="false"> <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="popup-content mx-[40px] flex max-h-[80vh] flex-col overflow-hidden rounded-[12px] bg-white">
<view class="header flex items-center pb-[12px]"> <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> <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" /> <uni-icons type="close" size="24" color="#CACFD8" @click="close" />
</view> </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]"> <view class="text-[16px] text-[#a3a3a3] leading-[22px]">
如有任何疑问欢迎随时致电景区咨询热线 如有任何疑问欢迎随时致电景区咨询热线我们将全程为您提供细致解答与暖心服务
<text class="text-[#2563eb]" @click="handleClick(mobleNumber1)">{{ mobleNumber1 }}</text> / <text </view>
class="text-[#2563eb]" @click="handleClick(mobleNumber2)">{{ mobleNumber2 }}</text> <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> </view>
</view> </view>

View File

@@ -1,10 +1,14 @@
<template> <template>
<view class="discovery-audio-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect"> <view class="discovery-audio-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect">
<view class="audio-visual"> <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 v-else class="audio-cover audio-cover-fallback" />
<view class="audio-play"> <view class="audio-play" @tap.stop="handlePlaybackToggle">
<view class="audio-play-triangle" /> <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>
</view> </view>
@@ -14,58 +18,209 @@
<view class="audio-progress-row w-full min-w-0"> <view class="audio-progress-row w-full min-w-0">
<view class="audio-progress-track"> <view class="audio-progress-track">
<view class="audio-progress-fill" :style="{ width: `${normalizedItem.progress}%` }" /> <view class="audio-progress-fill" :style="{ width: `${progressPercent}%` }" />
</view> </view>
<text class="audio-time block w-[90px] truncate"> <text class="audio-time block w-[90px] truncate">{{ playbackTimeText }}</text>
{{ normalizedItem.currentTime }} / {{ normalizedItem.duration }}
</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import { computed, onUnmounted, ref, watch } from "vue";
import { onPageHide } from "@dcloudio/uni-app";
const DEFAULT_ITEM = {
id: "audio-guide",
title: "卧龙潭语音讲解",
subTitle: "解说:响水洞瀑布",
progress: 31,
currentTime: "02:15",
duration: "05:40",
};
const props = defineProps({ const props = defineProps({
item: { item: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
active: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(["didSelectItem"]); 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 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 { return {
raw: source, raw: source,
id: source.id ?? source.tabContentId ?? DEFAULT_ITEM.id, code: resource.code || "",
title: source.title || DEFAULT_ITEM.title, title: summary.title || "",
subTitle: source.subTitle || source.desc || DEFAULT_ITEM.subTitle, subTitle: summary.subtitle || "",
coverImage: source.coverImage || source.image || DEFAULT_ITEM.coverImage || "", coverUrl: summary.coverUrl || "",
progress: clampProgress(source.progress ?? DEFAULT_ITEM.progress), url: payload.url || "",
currentTime: source.currentTime || DEFAULT_ITEM.currentTime, mimeType: payload.mimeType || "",
duration: source.duration || DEFAULT_ITEM.duration, 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 = () => { const handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw); emit("didSelectItem", normalizedItem.value.raw);
}; };

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> <template>
<view class="discovery-guide-map-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect"> <view class="discovery-guide-map-tool-card" hover-class="is-pressed" hover-stay-time="80" @tap="handleSelect">
<view class="guide-map-visual"> <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-sign is-pink">
<view class="guidepost-line" /> <view class="guidepost-line" />
</view> </view>
@@ -25,12 +26,10 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import {
const DEFAULT_ITEM = { getGuideMapNavigationOptions,
id: "guide-map", normalizeGuideMapItem,
title: "卧龙潭电子导览图", } from "./model.mjs";
subTitle: "查看景区平面图与设施位置",
};
const props = defineProps({ const props = defineProps({
item: { item: {
@@ -39,21 +38,27 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["didSelectItem"]); const normalizedItem = computed(() => normalizeGuideMapItem(props.item));
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 handleSelect = () => { 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> </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 { .discovery-guide-map-tool-card {
width: 100%; width: 100%;
height: 96px; height: 88px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
box-sizing: border-box; box-sizing: border-box;

View File

@@ -1,18 +1,7 @@
<template> <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="photo-visual w-[88px] min-w-0">
<view class="camera-illustration"> <image v-if="normalizedItem.coverUrl" class="block h-full w-full" :src="normalizedItem.coverUrl" mode="aspectFill" />
<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>
</view> </view>
<view class="photo-content min-w-0 flex-1"> <view class="photo-content min-w-0 flex-1">
@@ -20,13 +9,12 @@
<text class="photo-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text> <text class="photo-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text>
<view class="photo-options"> <view class="photo-options">
<view v-for="(option, index) in normalizedItem.options" :key="getOptionKey(option, index)" class="photo-option min-w-0 flex-1" <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="handleOptionSelect(option, index)"> hover-class="is-option-pressed" hover-stay-time="80" @tap.stop="handleJump">
<view class="photo-option-thumb"> <view class="photo-option-thumb">
<image v-if="option.coverImage" class="photo-option-image" :src="option.coverImage" mode="aspectFill" /> <image v-if="photo?.photoUrl" class="photo-option-image" :src="photo.photoUrl" mode="aspectFill" />
<view v-else class="photo-option-placeholder" :class="`is-${index}`" />
</view> </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> </view>
</view> </view>
@@ -35,20 +23,7 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { navigateTo } from "@/router";
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,
};
const props = defineProps({ const props = defineProps({
item: { 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 normalizedItem = computed(() => {
const source = Object.keys(props.item).length > 0 ? props.item : DEFAULT_ITEM; const resource = props.item?.resource;
const options = source.options || source.children || source.items || DEFAULT_OPTIONS; const summary = resource?.summary;
const componentParams = resource?.payload?.componentParams;
return { return {
raw: source, title: summary?.title,
id: source.id ?? source.tabContentId ?? DEFAULT_ITEM.id, subTitle: summary?.subtitle,
title: source.title || DEFAULT_ITEM.title, coverUrl: summary?.coverUrl,
subTitle: source.subTitle || source.desc || DEFAULT_ITEM.subTitle, jumpUrl: componentParams?.jumpUrl,
badge: source.badge || source.tag || DEFAULT_ITEM.badge, options: Array.isArray(componentParams?.photoList)
options: options.slice(0, 3).map(normalizeOption), ? 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 = () => { const handleJump = () => {
emit("didSelectItem", normalizedItem.value.raw); if (!normalizedItem.value.jumpUrl) return;
}; navigateTo(normalizedItem.value.jumpUrl);
const handleOptionSelect = (option, index) => {
emit("didSelectOption", option.raw, normalizedItem.value.raw, index);
}; };
</script> </script>

View File

@@ -5,10 +5,14 @@
class="z-[12] flex w-full shrink-0 flex-row items-center bg-[#F0F8F3]" class="z-[12] flex w-full shrink-0 flex-row items-center bg-[#F0F8F3]"
> >
<view <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" @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>
<view v-if="discoveryTabs.length > 0" class="min-w-0 flex-1 overflow-hidden"> <view v-if="discoveryTabs.length > 0" class="min-w-0 flex-1 overflow-hidden">
<FindTabs v-model="activeIndex" :tabs="discoveryTabs" @change="handleTabChange" /> <FindTabs v-model="activeIndex" :tabs="discoveryTabs" @change="handleTabChange" />
@@ -23,12 +27,15 @@
<CardSwiper v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" /> <CardSwiper v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" />
<ExploreCards v-if="exploreCards.length > 0" :list="exploreCards" @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" <QuickQuestions v-if="discoveryQuickQuestions.length > 0" :list="discoveryQuickQuestions"
@didSelectItem="handleQuickQuestionClick" /> @didSelectItem="handleQuickQuestionClick" />
<!-- <DiscoveryPhotoToolCard @didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
<DiscoveryAudioToolCard @didSelectItem="handleToolCardClick" />
<DiscoveryGuideMapToolCard @didSelectItem="handleToolCardClick" /> -->
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
@@ -47,22 +54,34 @@ import QuickQuestions from "./components/QuickQuestions/index.vue";
import DiscoveryPhotoToolCard from "./components/DiscoveryPhotoToolCard/index.vue"; import DiscoveryPhotoToolCard from "./components/DiscoveryPhotoToolCard/index.vue";
import DiscoveryAudioToolCard from "./components/DiscoveryAudioToolCard/index.vue"; import DiscoveryAudioToolCard from "./components/DiscoveryAudioToolCard/index.vue";
import DiscoveryGuideMapToolCard from "./components/DiscoveryGuideMapToolCard/index.vue"; import DiscoveryGuideMapToolCard from "./components/DiscoveryGuideMapToolCard/index.vue";
import DiscoveryGoodsCard from "./components/DiscoveryGoodsCard/index.vue";
import PanoramicHome from "./components/PanoramicHome/index.vue"; import PanoramicHome from "./components/PanoramicHome/index.vue";
import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData, resourceCenterData } from "../../request/api/MainPageDataApi";
import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData } from "../../request/api/MainPageDataApi";
import { useAppStore, useLocationStore } from "@/store"; import { useAppStore, useLocationStore } from "@/store";
import { JumpType } from "../../model/ChatModel"; import { JumpType } from "../../model/ChatModel";
const props = defineProps({
active: {
type: Boolean,
default: true,
},
});
const appStore = useAppStore(); const appStore = useAppStore();
const locationStore = useLocationStore(); const locationStore = useLocationStore();
/// 从个渠道获取如二维码 /// 从个渠道获取如二维码
const sceneId = appStore.sceneId || ""; const sceneId = appStore.sceneId || "";
const currentTabsId = ref("");
const activeIndex = ref(0); const activeIndex = ref(0);
const discoveryTabs = ref([]); const discoveryTabs = ref([]);
const discoveryCards = ref([]); const discoveryCards = ref([]);
const exploreCards = ref([]); const exploreCards = ref([]);
const discoveryQuickQuestions = ref([]); const discoveryQuickQuestions = ref([]);
const discoveryPhotoToolData = ref(null);
const discoveryAudioToolData = ref(null);
const discoveryGuideMapToolData = ref(null);
const discoveryGoodsData = ref([]);
const emit = defineEmits(["content-scroll"]); const emit = defineEmits(["content-scroll"]);
/// 是否是主景区引导页 默认是 true点击主景区后为 false /// 是否是主景区引导页 默认是 true点击主景区后为 false
@@ -78,17 +97,22 @@ const handleContentScroll = (e) => {
/// 点击主景区 /// 点击主景区
const handleMainPanoramaClick = () => { const handleMainPanoramaClick = () => {
isMainPanoramaGuide.value = true; isMainPanoramaGuide.value = true;
currentTabsId.value = "";
discoveryTabs.value = []; discoveryTabs.value = [];
discoveryCards.value = []; discoveryCards.value = [];
exploreCards.value = []; exploreCards.value = [];
discoveryQuickQuestions.value = []; discoveryQuickQuestions.value = [];
discoveryPhotoToolData.value = null;
discoveryAudioToolData.value = null;
discoveryGuideMapToolData.value = null;
discoveryGoodsData.value = [];
}; };
/// tabs 切换事件 /// tabs 切换事件
const handleTabChange = ({ tab, idx }) => { const handleTabChange = ({ tab, idx }) => {
activeIndex.value = idx; activeIndex.value = idx;
queryDiscoveryData(tab.id); queryDiscoveryData(tab);
} }
/// 请求发现页tab数据 /// 请求发现页tab数据
@@ -121,24 +145,33 @@ const handlePanoramicHomeSelect = (item) => {
/// 根据id查询tab并设置activeIndex /// 根据id查询tab并设置activeIndex
const findTabByIdWithActiveTabIndex = (tabsId) => { const findTabByIdWithActiveTabIndex = (tabsId) => {
/// 查询是否有id参数 const resolvedTab = resolveDiscoveryTab(discoveryTabs.value, tabsId);
const activeTabIndex = discoveryTabs.value.findIndex((tab) => tab.id === tabsId); if (!resolvedTab.tab?.id) return;
/// 如果有则优先展示对应tab数据没有则展示第一个tab数据
if (activeTabIndex > -1) { if (currentTabsId.value === resolvedTab.tab.id) return;
activeIndex.value = activeTabIndex; currentTabsId.value = resolvedTab.tab.id;
queryDiscoveryData(tabsId); activeIndex.value = resolvedTab.activeIndex;
} else { queryDiscoveryData(resolvedTab.tab);
if (discoveryTabs.value.length > 0) {
activeIndex.value = 0;
queryDiscoveryData(discoveryTabs.value[0].id);
}
}
} }
/// 找到对应的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) => { const queryDiscoveryData = async (tab) => {
queryDiscoveryCards(tabId); if (!tab?.id) return;
queryQuickQuestionData(tabId);
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) => { const configDataList = (data) => {
return data.map((item) => ({ 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) => { const handleClick = async (item) => {
console.log(`执行点击事件: ${item.jumpType},参数:${JSON.stringify(item.jumpContent)}`); console.log(`执行点击事件: ${item.jumpType},参数:${JSON.stringify(item.jumpContent)}`);
/// 商品 /// 商品

View File

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

View File

@@ -13,16 +13,16 @@
*/ */
/* 主题颜色(由 switch-client 命令自动更新) */ /* 主题颜色(由 switch-client 命令自动更新) */
$theme-color-900: #0b5c2d; $theme-color-900: #0B5C2D;
$theme-color-800: #0b7034; $theme-color-800: #0B7034;
$theme-color-700: #0b5c2d; $theme-color-700: #0B5C2D;
$theme-color-600: #02c34e; $theme-color-600: #02C34E;
$theme-color-500: #0ccd58; $theme-color-500: #0CCD58;
$theme-color-400: #35f37f; $theme-color-400: #35F37F;
$theme-color-300: #77feab; $theme-color-300: #77FEAB;
$theme-color-200: #a0e5ba; $theme-color-200: #A0E5BA;
$theme-color-100: #e8fff1; $theme-color-100: #E8FFF1;
$theme-color-50: #f0f8f3; $theme-color-50: #F0F8F3;
// text 颜色 // text 颜色
$text-color-900: #181b25; $text-color-900: #181b25;