feat: add discovery tool cards, refactor AiTabSwitch and update client configs

- Update xiaoqi client configuration: adjust clientId, sync appid across config files and switch to production build by setting developVersion to false
- Refactor AiTabSwitch component to use background images instead of inline image tags, clean up redundant SCSS styles and replace tab icon assets
- Add three new Discovery page tool components: audio guide, map guide and photo generator cards
- Clean up unused code in Discovery page and ChatMainList component, remove unused scroll view and computed property
- Fix regex pattern and formatting issues in update-appid.js script
- Comment out unused SpriteAnimator component in ChatMainList
This commit is contained in:
duanshuwen
2026-07-06 20:51:45 +08:00
parent 190793b156
commit aa07286010
18 changed files with 1013 additions and 326 deletions

View File

@@ -0,0 +1,89 @@
<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"
/>
<view v-else class="audio-cover audio-cover-fallback" />
<view class="audio-play">
<view class="audio-play-triangle" />
</view>
</view>
<view class="audio-content">
<text class="audio-title ellipsis-1">{{ normalizedItem.title }}</text>
<text class="audio-subtitle ellipsis-1">{{ normalizedItem.subTitle }}</text>
<view class="audio-progress-row">
<view class="audio-progress-track">
<view
class="audio-progress-fill"
:style="{ width: `${normalizedItem.progress}%` }"
/>
</view>
<text class="audio-time ellipsis-1">
{{ normalizedItem.currentTime }} / {{ normalizedItem.duration }}
</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",
};
const props = defineProps({
item: {
type: Object,
default: () => ({}),
},
});
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;
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,
};
});
const handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,124 @@
.discovery-audio-tool-card {
width: 100%;
height: 96px;
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.discovery-audio-tool-card.is-pressed {
opacity: 0.94;
}
.audio-visual {
position: relative;
z-index: 2;
flex: 0 0 88px;
width: 88px;
height: 88px;
overflow: hidden;
border-radius: 28px;
background: #c8f4ab;
box-sizing: border-box;
}
.audio-content {
flex: 1;
min-width: 0;
height: 88px;
display: flex;
flex-direction: column;
padding: 18px 12px 14px;
border-radius: 28px;
background: #ffffff;
box-sizing: border-box;
}
.audio-title {
display: block;
max-width: 100%;
color: #123737;
font-size: 19px;
line-height: 25px;
font-weight: 900;
letter-spacing: 0;
}
.audio-subtitle {
display: block;
max-width: 100%;
margin-top: 2px;
color: #9aa4b4;
font-size: 15px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0;
}
.audio-cover {
width: 100%;
height: 100%;
display: block;
}
.audio-cover-fallback {
background:
radial-gradient(circle at 58% 12%, rgba(255, 255, 255, 0.38) 0 14px, transparent 15px),
linear-gradient(115deg, rgba(11, 41, 26, 0.12), rgba(14, 55, 36, 0.42)),
linear-gradient(145deg, #578a3e 0%, #99bb67 36%, #eff7ff 37%, #cadbcb 44%, #1a6d58 45%, #2c7966 100%);
}
.audio-play {
position: absolute;
left: 50%;
top: 50%;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(24, 31, 43, 0.38);
transform: translate(-50%, -50%);
}
.audio-play-triangle {
width: 0;
height: 0;
margin-left: 4px;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 15px solid #ffffff;
}
.audio-progress-row {
display: flex;
align-items: center;
margin-top: 16px;
}
.audio-progress-track {
flex: 1 1 auto;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #edf2f7;
}
.audio-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, $theme-color-500 0%, #12b981 100%);
}
.audio-time {
flex: 0 0 90px;
max-width: 90px;
margin-left: 12px;
color: #94a0b2;
font-size: 15px;
line-height: 20px;
font-weight: 600;
text-align: right;
letter-spacing: 0;
}

View File

@@ -0,0 +1,67 @@
<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">
<view class="guidepost-sign is-pink">
<view class="guidepost-line" />
</view>
<view class="guidepost-sign is-yellow">
<view class="guidepost-line" />
</view>
<view class="guidepost-pole" />
<view class="guidepost-base">
<view class="guidepost-eye is-left" />
<view class="guidepost-eye is-right" />
</view>
</view>
</view>
<view class="guide-map-content">
<text class="guide-map-title ellipsis-1">{{ normalizedItem.title }}</text>
<text class="guide-map-subtitle ellipsis-1">{{ normalizedItem.subTitle }}</text>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const DEFAULT_ITEM = {
id: "guide-map",
title: "卧龙潭电子导览图",
subTitle: "查看景区平面图与设施位置",
};
const props = defineProps({
item: {
type: Object,
default: () => ({}),
},
});
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 handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,189 @@
.discovery-guide-map-tool-card {
width: 100%;
height: 96px;
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.discovery-guide-map-tool-card.is-pressed {
opacity: 0.94;
}
.guide-map-visual {
position: relative;
z-index: 2;
flex: 0 0 88px;
width: 88px;
height: 88px;
overflow: hidden;
border-radius: 28px;
background: #c8f4ab;
box-sizing: border-box;
}
.guide-map-content {
flex: 1;
min-width: 0;
height: 88px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 18px 12px 14px;
border-radius: 28px;
background: #ffffff;
box-sizing: border-box;
}
.guide-map-title {
display: block;
max-width: 100%;
color: #123737;
font-size: 19px;
line-height: 25px;
font-weight: 900;
letter-spacing: 0;
}
.guide-map-subtitle {
display: block;
max-width: 100%;
margin-top: 2px;
color: #9aa4b4;
font-size: 15px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0;
}
.guidepost-illustration {
position: absolute;
left: 50%;
top: 20px;
width: 56px;
height: 58px;
transform: translateX(-50%);
}
.guidepost-sign {
position: absolute;
left: 4px;
width: 46px;
height: 18px;
border-radius: 8px;
}
.guidepost-sign::before {
content: "";
position: absolute;
left: -6px;
top: 0;
width: 18px;
height: 18px;
border-radius: 6px 0 0 6px;
transform: skewX(-26deg);
}
.guidepost-sign.is-pink {
top: 0;
background: #f27591;
}
.guidepost-sign.is-pink::before {
background: #f27591;
}
.guidepost-sign.is-yellow {
top: 23px;
left: 14px;
background: #ffd65f;
}
.guidepost-sign.is-yellow::before {
background: #ffd65f;
}
.guidepost-sign.is-yellow::after {
content: "";
position: absolute;
right: -10px;
top: 0;
width: 18px;
height: 18px;
border-radius: 0 7px 7px 0;
background: #ffd65f;
transform: skewX(-28deg);
}
.guidepost-line {
position: absolute;
left: 18px;
top: 7px;
width: 17px;
height: 3px;
border-radius: 999px;
background: rgba(210, 66, 91, 0.55);
}
.guidepost-sign.is-yellow .guidepost-line {
background: #ff9e05;
}
.guidepost-pole {
position: absolute;
left: 27px;
top: 39px;
width: 12px;
height: 16px;
background: #b98969;
}
.guidepost-base {
position: absolute;
left: 7px;
bottom: 0;
width: 54px;
height: 24px;
border-radius: 9px 9px 8px 8px;
background: #66d9bf;
}
.guidepost-base::before {
content: "";
position: absolute;
left: -5px;
top: -2px;
width: 18px;
height: 13px;
border-radius: 8px 0 8px 0;
background: #66d9bf;
transform: rotate(-14deg);
}
.guidepost-eye {
position: absolute;
top: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #463b36;
}
.guidepost-eye::after {
content: "";
position: absolute;
left: -2px;
top: 7px;
width: 6px;
height: 3px;
border-radius: 999px;
background: #ff9ab0;
}
.guidepost-eye.is-left {
left: 18px;
}
.guidepost-eye.is-right {
right: 8px;
}

View File

@@ -0,0 +1,116 @@
<template>
<view
class="discovery-photo-tool-card"
hover-class="is-pressed"
hover-stay-time="80"
@tap="handleSelect"
>
<view class="photo-visual">
<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 ellipsis-1">{{ normalizedItem.badge }}</text>
</view>
<view class="photo-content">
<text class="photo-title ellipsis-1">{{ normalizedItem.title }}</text>
<text class="photo-subtitle ellipsis-1">{{ normalizedItem.subTitle }}</text>
<view class="photo-options">
<view
v-for="(option, index) in normalizedItem.options"
:key="getOptionKey(option, index)"
class="photo-option"
hover-class="is-option-pressed"
hover-stay-time="80"
@tap.stop="handleOptionSelect(option, index)"
>
<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}`"
/>
</view>
<text class="photo-option-label ellipsis-1">{{ option.label }}</text>
</view>
</view>
</view>
</view>
</template>
<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,
};
const props = defineProps({
item: {
type: Object,
default: () => ({}),
},
});
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;
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),
};
});
const getOptionKey = (option, index) => option.id ?? option.label ?? index;
const handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw);
};
const handleOptionSelect = (option, index) => {
emit("didSelectOption", option.raw, normalizedItem.value.raw, index);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,259 @@
.discovery-photo-tool-card {
width: 100%;
height: 142px;
display: flex;
align-items: flex-start;
box-sizing: border-box;
}
.discovery-photo-tool-card.is-pressed {
opacity: 0.94;
}
.photo-visual {
position: relative;
z-index: 2;
flex: 0 0 88px;
width: 88px;
height: 142px;
overflow: hidden;
border-radius: 28px;
background: #c8f4ab;
box-sizing: border-box;
}
.photo-content {
flex: 1;
min-width: 0;
height: 142px;
display: flex;
flex-direction: column;
padding: 16px 12px 12px;
border-radius: 28px;
background: #ffffff;
box-sizing: border-box;
}
.photo-title {
display: block;
max-width: 100%;
color: #142034;
font-size: 19px;
line-height: 25px;
font-weight: 900;
letter-spacing: 0;
}
.photo-subtitle {
display: block;
max-width: 100%;
margin-top: 2px;
color: #9aa4b4;
font-size: 15px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0;
}
.camera-illustration {
position: absolute;
left: 50%;
top: 34px;
width: 66px;
height: 58px;
transform: translateX(-50%);
}
.camera-top {
position: absolute;
left: 25px;
top: 0;
width: 28px;
height: 30px;
border-radius: 9px 9px 0 0;
background: #5b6d84;
}
.camera-top::before {
content: "";
position: absolute;
left: 6px;
top: 9px;
width: 18px;
height: 12px;
border-radius: 4px;
background: #ffe97b;
}
.camera-flash {
position: absolute;
right: 0;
top: -10px;
width: 26px;
height: 26px;
border-radius: 9px;
background: #ffd85c;
transform: rotate(45deg);
}
.camera-body {
position: absolute;
left: 0;
bottom: 0;
width: 66px;
height: 42px;
border-radius: 12px;
background: #526376;
}
.camera-body::before {
content: "";
position: absolute;
left: 5px;
top: 0;
width: 11px;
height: 11px;
border-radius: 4px;
background: #f2768d;
}
.camera-lens {
position: absolute;
left: 20px;
top: 5px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f8fbff;
box-shadow: -6px 7px 0 rgba(35, 48, 67, 0.4);
}
.camera-lens-inner {
width: 18px;
height: 18px;
border-radius: 50%;
background: #6fd7ee;
}
.camera-dot {
position: absolute;
right: 5px;
border-radius: 50%;
background: #7f91a5;
}
.camera-dot.is-large {
top: 7px;
width: 5px;
height: 5px;
}
.camera-dot.is-small {
top: 15px;
width: 3px;
height: 3px;
}
.photo-badge {
position: absolute;
left: 9px;
right: 9px;
bottom: 15px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
color: #142034;
font-size: 13px;
line-height: 18px;
font-weight: 900;
letter-spacing: 0;
}
.photo-badge::before,
.photo-badge::after {
content: "";
flex: 0 0 4px;
width: 4px;
height: 12px;
margin: 0 4px;
border-top: 4px solid #142034;
border-bottom: 4px solid #142034;
box-sizing: border-box;
}
.photo-badge::before {
border-left: 4px solid #142034;
}
.photo-badge::after {
border-right: 4px solid #142034;
}
.photo-options {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
}
.photo-option {
flex: 1 1 0;
min-width: 0;
text-align: center;
}
.photo-option.is-option-pressed {
opacity: 0.9;
}
.photo-option-thumb {
width: 70px;
height: 48px;
margin: 0 auto;
overflow: hidden;
border-radius: 8px;
background: #dff5ef;
}
.photo-option-image,
.photo-option-placeholder {
width: 100%;
height: 100%;
display: block;
}
.photo-option-placeholder {
background:
radial-gradient(circle at 28% 68%, rgba(255, 255, 255, 0.9) 0 5px, transparent 6px),
linear-gradient(145deg, rgba(9, 83, 68, 0.75), rgba(14, 170, 112, 0.2)),
linear-gradient(135deg, #0c5f58 0%, #4ecf9d 100%);
}
.photo-option-placeholder.is-1 {
background:
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.82) 0 5px, transparent 6px),
linear-gradient(180deg, rgba(6, 87, 73, 0.18), rgba(11, 91, 76, 0.7)),
linear-gradient(135deg, #196d63 0%, #50c6b3 100%);
}
.photo-option-placeholder.is-2 {
background:
radial-gradient(circle at 70% 48%, rgba(255, 217, 91, 0.8) 0 5px, transparent 6px),
linear-gradient(140deg, rgba(14, 50, 37, 0.68), rgba(67, 149, 83, 0.3)),
linear-gradient(135deg, #28583b 0%, #9ac978 100%);
}
.photo-option-label {
display: block;
max-width: 76px;
margin: 5px auto 0;
color: $theme-color-500;
font-size: 14px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0;
}

View File

@@ -1,43 +1,26 @@
<template>
<view class="flex flex-col h-full overflow-hidden min-height-0">
<view class="flex-shrink-0">
<FindTabs
v-if="discoveryTabs.length > 0"
v-model="activeIndex"
:tabs="discoveryTabs"
@change="handleTabChange"
/>
<FindTabs v-if="discoveryTabs.length > 0" v-model="activeIndex" :tabs="discoveryTabs" @change="handleTabChange" />
</view>
<scroll-view
class="discovery-scroll flex-full border-box min-height-0"
scroll-y
show-scrollbar="false"
@touchstart="emitScrollTouchStart"
@touchmove="emitScrollTouch"
@scroll="emitScrollTouch"
>
<HeritageDiscoverySection
v-if="isHeritageTab"
:cards="discoveryCards"
:prompts="discoveryQuickQuestions"
@select-card="handleCardClick"
@select-prompt="handleQuickQuestionClick"
/>
<HeritageDiscoverySection :cards="discoveryCards" :prompts="discoveryQuickQuestions" @select-card="handleCardClick"
@select-prompt="handleQuickQuestionClick" />
<template v-else>
<ExploreCards v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" />
<ExploreCards v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" />
<QuickQuestions v-if="discoveryQuickQuestions.length > 0" :list="discoveryQuickQuestions" @didSelectItem="handleQuickQuestionClick" />
</template>
<QuickQuestions v-if="discoveryQuickQuestions.length > 0" :list="discoveryQuickQuestions"
@didSelectItem="handleQuickQuestionClick" />
</scroll-view>
<DiscoveryPhotoToolCard @didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
<DiscoveryAudioToolCard @didSelectItem="handleToolCardClick" />
<DiscoveryGuideMapToolCard @didSelectItem="handleToolCardClick" />
</view>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE, SWITCH_TO_COMPANION_TAB } from "@/constant/constant";
import { onMounted, ref } from "vue";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { navigateTo } from "@/router";
import { getAccessToken } from "@/constant/token";
@@ -45,6 +28,9 @@ import FindTabs from "./components/FindTabs/index.vue";
import ExploreCards from "./components/ExploreCards/index.vue";
import HeritageDiscoverySection from "./components/HeritageDiscoverySection/index.vue";
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 { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData } from "../../request/api/MainPageDataApi";
import { useAppStore, useLocationStore } from "@/store";
@@ -52,7 +38,7 @@ import { JumpType } from "../../model/ChatModel";
const appStore = useAppStore();
const locationStore = useLocationStore();
/// 从个渠道获取如二维码
/// 从个渠道获取如二维码
const sceneId = appStore.sceneId || "";
const activeIndex = ref(0);
@@ -61,15 +47,6 @@ const discoveryCards = ref([]);
const discoveryQuickQuestions = ref([]);
const emit = defineEmits(["scroll-touch-start", "scroll-touch"]);
const isHeritageTab = computed(() => discoveryTabs.value[activeIndex.value]?.label === "小七孔古桥");
const emitScrollTouchStart = () => {
emit("scroll-touch-start");
};
const emitScrollTouch = () => {
emit("scroll-touch");
};
/// tabs 切换事件
const handleTabChange = ({ tab, idx }) => {
@@ -131,7 +108,7 @@ const queryQuickQuestionData = async (tabId) => {
}
/// 统一处理接口返回数据结构
const configDataList = (data) => {
const configDataList = (data) => {
return data.map((item) => ({
id: item.id,
title: item.tabContent.mainTitle,
@@ -155,6 +132,20 @@ const handleQuickQuestionClick = (item) => {
handleClick(item);
};
const handleToolCardClick = (item) => {
if (item?.jumpType !== undefined) {
handleClick(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)}`);
/// 商品
@@ -183,7 +174,7 @@ const handleClick = async (item) => {
/// 获取位置信息
const getLocation = () => {
/// 已经有sceneId了说明之前已经获取过位置信息了就不需要再获取一次了
if (sceneId) return;
if (sceneId) return;
uni.getLocation({
type: 'wgs84',
success: function (res) {
@@ -215,11 +206,4 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.discovery-scroll {
flex-basis: 0;
height: 0;
min-height: 0;
overscroll-behavior-y: contain;
}
</style>
<style lang="scss" scoped></style>