refactor: clean up codebase and add new features

Replace SCSS variable usages with explicit pixel/hex values for consistent styling across all components
Fix broken template syntax including missing class spaces and incorrect closing tags
Migrate constant and API imports to centralized @/constants and @/api modules
Add new utility classes: IdUtils, CallbackUtils, and TimerUtils
Add new chat conversation API endpoints for recent conversations and message lists
Add new Discovery page components (FindTabs, QuickQuestions, CardSwiper) and their styles
Update app store config to use environment variables for base API and WebSocket URLs
Add new selected tab icon assets
This commit is contained in:
duanshuwen
2026-05-26 23:50:37 +08:00
parent c977c485ef
commit 1a5a2ae6a9
101 changed files with 1488 additions and 745 deletions

View File

@@ -14,7 +14,7 @@
<script setup>
import { ref } from "vue";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constants/constant";
const commandModel = ref({
icon: "",

View File

@@ -27,8 +27,8 @@
right: 12px;
bottom: 12px;
background-color: #ffeb00;
color: $uni-text-color;
font-size: $uni-font-size-base;
color: #333;
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;

View File

@@ -31,14 +31,14 @@
<script setup>
import { defineProps, computed, watch, onBeforeUnmount } from "vue";
import ChatMarkdown from "./ChatMarkdown/index.vue";
import ChatMarkdown from "../ChatMarkdown/index.vue";
import ChatLoading from "./ChatLoading/index.vue";
import StreamManager from '@/utils/StreamManager.js';
import {
getLongTextPredivText,
getLongTextValue,
hasLongTextExtraSections,
} from "@/utils/longTextCard";
} from "@/constants/longTextCard";
// 直接根据文字长度判断超过约100个字符认为会溢出约3行
const props = defineProps({

View File

@@ -12,7 +12,7 @@ import { onMounted } from "vue";
import {
SCROLL_TO_BOTTOM,
SEND_MESSAGE_CONTENT_TEXT,
} from "@/constant/constant";
} from "@/constants/constant";
const props = defineProps({
question: {

View File

@@ -19,5 +19,5 @@
.tag-text {
color: #0ccd58;
font-size: $uni-font-size-base;
font-size: 14px;
}

View File

@@ -33,8 +33,8 @@
.hold-to-talk-button {
width: 100%;
height: 44px;
color: $uni-text-color;
font-size: $uni-font-size-lg;
color: #333;
font-size: 16px;
display: flex;
justify-content: center;
align-items: center;
@@ -99,7 +99,7 @@
width: 100%;
max-height: 92px;
min-height: 22px;
font-size: $uni-font-size-lg;
font-size: 16px;
line-height: 22px;
margin: 6px 0;

View File

@@ -44,7 +44,7 @@ import {
LONG_TEXT_KEYS,
getLongTextSections,
getLongTextValue,
} from "@/utils/longTextCard";
} from "@/constants/longTextCard";
const props = defineProps({
answerText: {

View File

@@ -46,7 +46,7 @@
:finish="item.finish"
/> -->
<LongTextGuideCardPrediv v-if="item.componentName && isLongTextCard(item.componentName)"
<LongTextGuideCardPreview v-if="item.componentName && isLongTextCard(item.componentName)"
:componentName="item.componentName" />
<QuickBookingComponent v-if="
@@ -147,12 +147,12 @@ import {
SEND_MESSAGE_COMMAND_TYPE,
NOTICE_EVENT_LOGOUT,
NOTICE_EVENT_LOGIN_SUCCESS,
} from "@/constant/constant";
import { MessageRole, MessageType, CompName, Command } from "@/model/ChatModel";
} from "@/constants/constant";
import { MessageRole, MessageType, CompName, Command } from "@/constants/ChatModel";
import HomeWelcome from "../HomeWelcome/index.vue";
import AiTabSwitch from "@/components/AiTabSwitch/index.vue";
import Discovery from "../../Discovery/index.vue";
import Discovery from "../Discovery/index.vue";
import ChatGuide from "../ChatGuide/index.vue";
import ChatTopNavBar from "../ChatTopNavBar/index.vue";
@@ -162,37 +162,36 @@ import ChatCardOther from "../ChatCardOther/index.vue";
import ChatQuickAccess from "../ChatQuickAccess/index.vue";
import ChatInputArea from "../ChatInputArea/index.vue";
import QuickBookingComponent from "../../ChatModule/QuickBookingComponent/index.vue";
import DiscoveryCardComponent from "../../ChatModule/DiscoveryCardComponent/index.vue";
import ActivityListComponent from "../../ChatModule/ActivityListComponent/index.vue";
import RecommendPostsComponent from "../../ChatModule/RecommendPostsComponent/index.vue";
import AttachListComponent from "../../ChatModule/AttachListComponent/index.vue";
import DetailCardCompontent from "../../ChatModule/DetailCardCompontent/index.vue";
import OpenMapComponent from "../../ChatModule/OpenMapComponent/index.vue";
import AnswerComponent from "../../ChatModule/AnswerComponent/index.vue";
import GeneratorPhotoComponent from "../../ChatModule/GeneratorPhotoComponent/index.vue";
import QuickBookingComponent from "../QuickBookingComponent/index.vue";
import DiscoveryCardComponent from "../DiscoveryCardComponent/index.vue";
import ActivityListComponent from "../ActivityListComponent/index.vue";
import RecommendPostsComponent from "../RecommendPostsComponent/index.vue";
import AttachListComponent from "../AttachListComponent/index.vue";
import DetailCardCompontent from "../DetailCardCompontent/index.vue";
import OpenMapComponent from "../OpenMapComponent/index.vue";
import AnswerComponent from "../AnswerComponent/index.vue";
import GeneratorPhotoComponent from "../GeneratorPhotoComponent/index.vue";
import ZModuleC01 from "../../ChatModule/ZModuleC01/index.vue";
import LongTextGuideCardPrediv from "../../ChatModule/LongTextGuideCardPrediv/index.vue";
import LongTextGuideCardPreview from "../LongTextGuideCardPreview/index.vue";
import CreateServiceOrder from "@/components/CreateServiceOrder/index.vue";
import Feedback from "@/components/Feedback/index.vue";
import AddCarCrad from "@/components/AddCarCrad/index.vue";
import SurveyQuestionnaire from "@/components/SurveyQuestionnaire/index.vue";
import { mainPageData } from "@/request/api/MainPageDataApi";
import { mainPageData } from "@/api/home";
import {
conversationMsgList,
recentConversation,
} from "@/request/api/ConversationApi";
} from "@/api/home";
import WebSocketManager from "@/utils/WebSocketManager";
import { IdUtils } from "@/utils";
import { IdUtils } from "@/utils/IdUtils";
import {
appendLongTextChunk,
createLongTextData,
} from "@/utils/longTextCard";
} from "@/constants/longTextCard";
import { checkToken } from "@/hooks/useGoLogin";
import { useAppStore } from "@/store";
import { getAccessToken } from "@/constant/token";
import { getAccessToken } from "@/constants/token";
const emit = defineEmits(["showDrawer"]);
const appStore = useAppStore();

View File

@@ -11,7 +11,7 @@
</template>
<script setup>
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constants/constant";
import { defineProps } from "vue";
defineProps({

View File

@@ -15,7 +15,7 @@
<script setup>
import { ref } from "vue";
import { Command } from "@/model/ChatModel";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constants/constant";
import { checkToken } from "@/hooks/useGoLogin";
const itemList = ref([

View File

@@ -25,7 +25,7 @@
<script setup>
import { ref, defineProps, computed, defineExpose } from "vue";
import { getCurrentConfig } from "@/constant/base";
import { getCurrentConfig } from "@/constants/base";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
const props = defineProps({

View File

@@ -18,7 +18,7 @@ v
<script setup>
import { defineProps, computed, getCurrentInstance, defineExpose } from "vue";
import { getCurrentConfig } from "@/constant/base";
import { getCurrentConfig } from "@/constants/base";
import ChatMoreTips from "../ChatMoreTips/index.vue";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";

View File

@@ -0,0 +1,471 @@
<template>
<div class="card-swiper" :class="{ 'is-single': props.list.length <= 1 }" @touchstart="handleTouchStart"
@touchmove.stop.prevent="handleTouchMove" @touchend="handleTouchEnd" @touchcancel="handleTouchCancel">
<div class="swiper-stage">
<div v-for="slot in renderSlots" :key="slot.key" class="swiper-card"
:class="[`is-${slot.role}`, { 'is-current': slot.role === 'current' }]" :style="slot.style"
@tap="handleCardTap(slot)">
<div class="card-shell">
<div class="card-media">
<img class="card-image" :src="slot.item.coverImage" mode="aspectFill" />
</div>
<div class="card-body">
<div v-if="slot.item.tag" class="card-tag">
{{ slot.item.tag }}
</div>
<div class="card-title ellipsis-1">
{{ slot.item.title }}
</div>
<div v-if="slot.item.subTitle" class="card-desc ellipsis-1">
{{ slot.item.subTitle }}
</div>
</div>
<div v-if="canSwipe" class="card-mask" :style="getMaskStyle(slot.role)" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps({
modelValue: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "change", "didSelectItem"]);
const DURATION = 280;
const RECYCLE_FRAME_DELAY = 32;
const CLICK_THRESHOLD = 8;
const SWIPE_THRESHOLD = 60;
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const lerp = (from, to, progress) => from + (to - from) * progress;
const { windowWidth = 375 } = uni.getWindowInfo();
const sideOffset = Math.max(108, Math.min(windowWidth * 0.26, 148));
const hiddenOffset = sideOffset + 92;
const activeCursor = ref(0);
const deltaX = ref(0);
const isDragging = ref(false);
const isAnimating = ref(false);
const isRecycling = ref(false);
const isTapCandidate = ref(false);
const swipeStep = ref(0);
let startX = 0;
let startY = 0;
let settleTimer = null;
let recycleTimer = null;
const instance = getCurrentInstance();
const hasExternalModel = Object.prototype.hasOwnProperty.call(
instance?.vnode.props || {},
"modelValue"
);
const actualCount = computed(() => props.list.length);
const virtualCount = computed(() => {
if (actualCount.value <= 1) return actualCount.value;
if (actualCount.value === 2) return 4;
return actualCount.value;
});
const canSwipe = computed(() => actualCount.value > 1);
const progress = computed(() => {
if (!canSwipe.value) return 0;
return clamp(deltaX.value / sideOffset, -1, 1);
});
const normalizeIndex = (index, total) => {
if (!total) return 0;
return ((index % total) + total) % total;
};
const getActualIndex = (virtualIndex) => {
if (!actualCount.value) return 0;
return normalizeIndex(virtualIndex, actualCount.value);
};
const getItemByVirtualIndex = (virtualIndex) => {
if (!actualCount.value) return null;
return props.list[getActualIndex(virtualIndex)] || null;
};
function clearSettleTimer() {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
}
function clearRecycleTimer() {
if (recycleTimer) {
clearTimeout(recycleTimer);
recycleTimer = null;
}
}
const getCircularDistance = (from, to, total) => {
const forward = normalizeIndex(to - from, total);
const backward = normalizeIndex(from - to, total);
return Math.min(forward, backward);
};
const getNearestVirtualIndexByActualIndex = (actualIndex, anchor = activeCursor.value) => {
if (!actualCount.value || !virtualCount.value) return 0;
const targetActualIndex = normalizeIndex(actualIndex, actualCount.value);
let matchedIndex = targetActualIndex;
let minDistance = Infinity;
for (let index = 0; index < virtualCount.value; index += 1) {
if (getActualIndex(index) !== targetActualIndex) continue;
const distance = getCircularDistance(anchor, index, virtualCount.value);
if (distance < minDistance) {
matchedIndex = index;
minDistance = distance;
}
}
return matchedIndex;
};
const syncActiveCursorByVirtualIndex = (incomingIndex = 0) => {
activeCursor.value = normalizeIndex(incomingIndex, virtualCount.value);
};
const syncActiveCursorByActualIndex = (incomingIndex = 0) => {
activeCursor.value = getNearestVirtualIndexByActualIndex(incomingIndex);
};
watch(
() => props.list,
() => {
clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0;
isDragging.value = false;
isAnimating.value = false;
isRecycling.value = false;
if (hasExternalModel) {
syncActiveCursorByActualIndex(props.modelValue);
} else {
syncActiveCursorByVirtualIndex(activeCursor.value);
}
},
{ deep: true, immediate: true }
);
watch(
() => props.modelValue,
(value) => {
if (!hasExternalModel) return;
if (isAnimating.value || isDragging.value) return;
syncActiveCursorByActualIndex(value);
}
);
const getItemKey = (virtualIndex) => {
const item = getItemByVirtualIndex(virtualIndex) || {};
const baseKey =
item.id ??
item.commodityId ??
item.tabLabel ??
item.title ??
virtualIndex;
return `${baseKey}-${virtualIndex}`;
};
const states = {
hiddenLeft: {
x: -hiddenOffset,
scale: 0.68,
opacity: 0,
},
left: {
x: -sideOffset,
scale: 1 / 1.2,
opacity: 0.36,
},
center: {
x: 0,
scale: 1,
opacity: 1,
},
right: {
x: sideOffset,
scale: 1 / 1.2,
opacity: 0.36,
},
hiddenRight: {
x: hiddenOffset,
scale: 0.68,
opacity: 0,
},
};
const interpolateState = (fromKey, toKey, rate) => {
const from = states[fromKey];
const to = states[toKey];
return {
x: lerp(from.x, to.x, rate),
scale: lerp(from.scale, to.scale, rate),
opacity: lerp(from.opacity, to.opacity, rate),
};
};
const getLayerDirection = () => {
if (isDragging.value && deltaX.value !== 0) {
return deltaX.value < 0 ? 1 : -1;
}
if (isAnimating.value) {
return swipeStep.value;
}
return 0;
};
const getCardZIndex = (role) => {
const direction = getLayerDirection();
if (direction === 1) {
if (role === "next") return 4;
if (role === "current") return 3;
return 1;
}
if (direction === -1) {
if (role === "prev") return 4;
if (role === "current") return 3;
return 1;
}
if (role === "current") return 3;
return 1;
};
const getSlotState = (role) => {
if (!canSwipe.value) return states.center;
const currentProgress = progress.value;
if (currentProgress < 0) {
const rate = Math.abs(currentProgress);
if (role === "prev") return interpolateState("left", "hiddenLeft", rate);
if (role === "current") return interpolateState("center", "left", rate);
return interpolateState("right", "center", rate);
}
if (currentProgress > 0) {
const rate = currentProgress;
if (role === "prev") return interpolateState("left", "center", rate);
if (role === "current") return interpolateState("center", "right", rate);
return interpolateState("right", "hiddenRight", rate);
}
if (role === "prev") return states.left;
if (role === "next") return states.right;
return states.center;
};
const buildCardStyle = (role) => {
const state = getSlotState(role);
return {
transform: `translate3d(-50%, 0, 0) translateX(${state.x}px) scale(${state.scale})`,
opacity: state.opacity,
zIndex: getCardZIndex(role),
transition: isDragging.value || isRecycling.value
? "none"
: `transform ${DURATION}ms ease, opacity ${DURATION}ms ease`,
};
};
const getMaskOpacity = (role) => {
if (!canSwipe.value) return 0;
const currentProgress = progress.value;
const baseOpacity = 0.42;
if (currentProgress < 0) {
const rate = Math.abs(currentProgress);
if (role === "next") return baseOpacity * (1 - rate);
if (role === "current") return baseOpacity * rate;
return baseOpacity;
}
if (currentProgress > 0) {
const rate = currentProgress;
if (role === "prev") return baseOpacity * (1 - rate);
if (role === "current") return baseOpacity * rate;
return baseOpacity;
}
return role === "current" ? 0 : baseOpacity;
};
const getMaskStyle = (role) => ({
opacity: getMaskOpacity(role),
transition: isDragging.value || isRecycling.value ? "none" : "opacity 120ms ease-out",
});
const renderSlots = computed(() => {
if (!actualCount.value) return [];
if (!canSwipe.value) {
return [
{
key: getItemKey(activeCursor.value),
role: "current",
index: getActualIndex(activeCursor.value),
item: getItemByVirtualIndex(activeCursor.value),
style: buildCardStyle("current"),
},
];
}
const prevIndex = normalizeIndex(activeCursor.value - 1, virtualCount.value);
const nextIndex = normalizeIndex(activeCursor.value + 1, virtualCount.value);
return [
{
key: getItemKey(prevIndex),
role: "prev",
index: getActualIndex(prevIndex),
item: getItemByVirtualIndex(prevIndex),
style: buildCardStyle("prev"),
},
{
key: getItemKey(activeCursor.value),
role: "current",
index: getActualIndex(activeCursor.value),
item: getItemByVirtualIndex(activeCursor.value),
style: buildCardStyle("current"),
},
{
key: getItemKey(nextIndex),
role: "next",
index: getActualIndex(nextIndex),
item: getItemByVirtualIndex(nextIndex),
style: buildCardStyle("next"),
},
];
});
const resetGesture = () => {
deltaX.value = 0;
isDragging.value = false;
isAnimating.value = false;
isTapCandidate.value = false;
swipeStep.value = 0;
};
const finishSwipe = (step) => {
isAnimating.value = true;
swipeStep.value = step;
deltaX.value = step > 0 ? -sideOffset : sideOffset;
clearSettleTimer();
settleTimer = setTimeout(() => {
const nextCursor = normalizeIndex(activeCursor.value + step, virtualCount.value);
isRecycling.value = true;
activeCursor.value = nextCursor;
const actualIndex = getActualIndex(nextCursor);
emit("update:modelValue", actualIndex);
emit("change", actualIndex);
resetGesture();
clearRecycleTimer();
recycleTimer = setTimeout(() => {
isRecycling.value = false;
recycleTimer = null;
}, RECYCLE_FRAME_DELAY);
}, DURATION);
};
const handleTouchStart = (event) => {
if (!canSwipe.value || isAnimating.value || isRecycling.value) return;
const touch = event.touches?.[0] || event.changedTouches?.[0];
if (!touch) return;
startX = touch.clientX;
startY = touch.clientY;
deltaX.value = 0;
isDragging.value = true;
isTapCandidate.value = true;
};
const handleTouchMove = (event) => {
if (!canSwipe.value || !isDragging.value || isAnimating.value) return;
const touch = event.touches?.[0] || event.changedTouches?.[0];
if (!touch) return;
const moveX = touch.clientX - startX;
const moveY = touch.clientY - startY;
if (Math.abs(moveX) > CLICK_THRESHOLD || Math.abs(moveY) > CLICK_THRESHOLD) {
isTapCandidate.value = false;
}
if (Math.abs(moveY) > Math.abs(moveX) && Math.abs(moveY) > 12) {
deltaX.value = 0;
return;
}
deltaX.value = clamp(moveX, -hiddenOffset, hiddenOffset);
};
const handleTouchEnd = () => {
if (!canSwipe.value || !isDragging.value) return;
isDragging.value = false;
if (isTapCandidate.value) {
deltaX.value = 0;
return;
}
if (Math.abs(deltaX.value) >= SWIPE_THRESHOLD) {
finishSwipe(deltaX.value > 0 ? -1 : 1);
return;
}
deltaX.value = 0;
};
const handleTouchCancel = () => {
if (!canSwipe.value) return;
clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0;
isDragging.value = false;
isAnimating.value = false;
isRecycling.value = false;
isTapCandidate.value = false;
swipeStep.value = 0;
};
const handleCardTap = (slot) => {
if (slot.role !== "current" || isDragging.value || isAnimating.value || isRecycling.value) return;
emit("didSelectItem", slot.item, slot.index);
};
onBeforeUnmount(() => {
clearSettleTimer();
clearRecycleTimer();
});
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,97 @@
.card-swiper {
width: 100%;
}
.swiper-stage {
position: relative;
width: 100%;
height: 270px;
overflow: hidden;
}
.swiper-card {
position: absolute;
left: 50%;
top: 8px;
width: 236px;
height: 234px;
max-width: calc(100% - 56px);
transform-origin: center center;
will-change: transform, opacity;
}
.card-shell {
position: relative;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.14);
}
.card-media {
width: 100%;
height: 142px;
margin: 0;
overflow: hidden;
border-radius: 20px;
}
.card-image {
width: 100%;
height: 100%;
display: block;
}
.card-body {
padding: 0 8px;
}
.card-tag {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 12px;
min-width: 50px;
max-width: 100%;
height: 18px;
padding: 0 8px;
border-radius: 4px;
background: #fff4db;
color: #d78621;
font-size: 9px;
font-weight: 600;
}
.card-title {
margin-top: 6px;
color: #172033;
font-size: 16px;
line-height: 1.2;
font-weight: 700;
}
.card-desc {
margin-top: 2px;
color: #7f8ea3;
font-size: 12px;
line-height: 18px;
}
.card-mask {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.42);
pointer-events: none;
}
.is-current .card-shell {
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.18);
}
.is-single .swiper-stage {
overflow: visible;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,93 @@
<template>
<div class="find-tabs-wrapper">
<div class="tabs-scroll" scroll-x="true" :scroll-left="scrollLeft" scroll-with-animation="true"
@scroll="handleScroll">
<div class="tabs-list">
<div v-for="(tab, idx) in tabs" :key="idx" :id="getTabId(idx)" class="tab-item"
:class="{ active: modelValue === idx }" @tap="handleSwitch(tab, idx)">
<div class="tab-content">
<div class="tab-label">
<span class="tab-text">{{ tab.label }}</span>
<img v-if="modelValue === idx && (isZhiNian ? indicatorSrcB : indicatorSrc)"
:src="isZhiNian ? indicatorSrcB : indicatorSrc" class="tab-indicator" mode="widthFix" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import { isZhiNian } from "@/constants/base";
import indicatorSrc from "./images/selected_tabs_icon.png";
import indicatorSrcB from "./images/selected_tabs_icon_b.png";
const instance = getCurrentInstance();
const props = defineProps({
modelValue: { type: Number, default: 0 },
tabs: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:modelValue', 'change']);
const scrollLeft = ref(0);
let currentScrollLeft = 0;
const getTabId = (idx) => `find-tab-${idx}`;
const handleScroll = (e) => {
currentScrollLeft = e.detail?.scrollLeft || 0;
};
const centerActiveTab = async () => {
await nextTick();
const activeIndex = props.modelValue;
if (activeIndex < 0 || activeIndex >= props.tabs.length) return;
if (!instance || typeof uni === "undefined" || !uni.createSelectorQuery) return;
const query = uni.createSelectorQuery().in(instance);
query.select(".tabs-scroll").boundingClientRect();
query.select(".tabs-scroll").scrollOffset();
query.select(`#${getTabId(activeIndex)}`).boundingClientRect();
query.exec((res) => {
const [scrollRect, scrollOffset, activeRect] = res || [];
if (!scrollRect || !activeRect) return;
const currentLeft = scrollOffset?.scrollLeft ?? currentScrollLeft;
const targetScrollLeft =
currentLeft +
activeRect.left - scrollRect.left -
(scrollRect.width - activeRect.width) / 2;
scrollLeft.value = Math.max(0, targetScrollLeft);
});
};
const handleSwitch = (tab, idx) => {
emit('update:modelValue', idx);
emit('change', { tab, idx });
};
watch(
() => [props.modelValue, props.tabs.length],
() => {
centerActiveTab();
}
);
onMounted(() => {
centerActiveTab();
});
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,71 @@
.find-tabs-wrapper {
width: 100%;
background-color: transparent;
}
.tabs-scroll {
width: 100%;
}
.tabs-list {
display: flex;
align-items: flex-end;
height: 50px;
gap: 16px;
flex-wrap: nowrap;
padding: 0 12px;
}
.tab-item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 50px;
box-sizing: border-box;
flex: 0 0 auto;
}
.tab-item:last-child {
margin-right: 12px;
}
.tab-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
}
.tab-label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tab-text {
position: relative;
font-size: 16px;
font-weight: 500;
color: rgba(128, 140, 153, 0.9);
z-index: 2;
padding: 0 4px;
line-height: 1;
}
.tab-item.active .tab-text {
color: #0b0b0b;
font-weight: bold;
}
.tab-indicator {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 56px;
height: auto;
z-index: 1;
}

View File

@@ -0,0 +1,80 @@
<template>
<div class="container pl-12">
<ModuleTitle :title="themeName" />
<div class="container-scroll font-size-0 scroll-x whitespace-nowrap">
<div class="card-item bg-white inline-block rounded-20 mr-10" v-for="(item, index) in list" :key="index"
@click="sendReply(item)">
<div class="flex flex-row flex-justify-start p-10">
<img class="card-img flex-shrink-0 rounded-14" :src="createSvgIndex(index)" />
<div class="min-width-0 flex flex-col flex-full border-box pl-8 py-4">
<div class="w-full font-size-12 font-600 color-171717 ellipsis-1">
{{ item.title }}
</div>
<div class="w-full font-size-9 color-94A3B8 ellipsis-1">
{{ item.subTitle }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
import ModuleTitle from "@/components/ModuleTitle/index.vue";
const emit = defineEmits(["didSelectItem"]);
const props = defineProps({
themeName: {
type: String,
default: "快捷提问",
},
list: {
type: Array,
default: () => [],
}
});
/// 点击卡片
const sendReply = (item) => {
emit("didSelectItem", item);
};
/// 生成icon
const createSvgIndex = (index) => {
const colors = [
{ bgColor: '#ECFDF5', iconColor: '#10B981' },
{ bgColor: '#FFFBEB', iconColor: '#F59E0B' },
{ bgColor: '#EFF6FF', iconColor: '#3B82F6' },
{ bgColor: '#FFFBEB', iconColor: '#F59E0B' }
];
return createSvg(...Object.values(colors[index % colors.length]));
}
const createSvg = (bgColor = '#FFFBEB', iconColor = '#F59E0B', size = 40) => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" divBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="14" fill="${bgColor}" />
<g stroke="${iconColor}" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.6243 22.3342C18.5648 22.1034 18.4445 21.8929 18.276 21.7244C18.1076 21.5559 17.897 21.4357 17.6663 21.3762L13.5763 20.3215C13.5065 20.3017 13.4451 20.2597 13.4014 20.2018C13.3576 20.1439 13.334 20.0734 13.334 20.0008C13.334 19.9283 13.3576 19.8577 13.4014 19.7999C13.4451 19.742 13.5065 19.7 13.5763 19.6802L17.6663 18.6248C17.8969 18.5654 18.1074 18.4452 18.2759 18.2768C18.4444 18.1085 18.5647 17.8981 18.6243 17.6675L19.679 13.5775C19.6986 13.5074 19.7406 13.4457 19.7985 13.4017C19.8565 13.3578 19.9272 13.334 20 13.334C20.0727 13.334 20.1435 13.3578 20.2014 13.4017C20.2594 13.4457 20.3014 13.5074 20.321 13.5775L21.375 17.6675C21.4345 17.8982 21.5547 18.1087 21.7232 18.2772C21.8917 18.4457 22.1023 18.566 22.333 18.6255L26.423 19.6795C26.4933 19.6989 26.5553 19.7408 26.5995 19.7989C26.6437 19.8569 26.6677 19.9279 26.6677 20.0008C26.6677 20.0738 26.6437 20.1447 26.5995 20.2028C26.5553 20.2608 26.4933 20.3028 26.423 20.3222L22.333 21.3762C22.1023 21.4357 21.8917 21.5559 21.7232 21.7244C21.5547 21.8929 21.4345 22.1034 21.375 22.3342L20.3203 26.4242C20.3007 26.4942 20.2587 26.5559 20.2008 26.5999C20.1428 26.6439 20.072 26.6677 19.9993 26.6677C19.9266 26.6677 19.8558 26.6439 19.7978 26.5999C19.7399 26.5559 19.6979 26.4942 19.6783 26.4242L18.6243 22.3342Z" />
<path d="M25.333 14V16.6667" />
<path d="M26.6667 15.334H24" />
<path d="M14.667 23.334V24.6673" />
<path d="M15.3333 24H14" />
</g>
</svg>
`.replace(/\n\s*/g, '').trim()
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,13 @@
.container-scroll {
margin: 4px 0 6px;
}
.card-item {
min-width: 130px;
max-width: 150px;
}
.card-img {
height: 40px;
width: 40px;
}

View File

@@ -0,0 +1,203 @@
<template>
<div class="flex flex-col h-full overflow-hidden min-height-0">
<div class="flex-shrink-0">
<FindTabs v-if="discoveryTabs.length > 0" v-model="activeIndex" :tabs="discoveryTabs" @change="handleTabChange" />
</div>
<div class="discovery-scroll flex-full border-box min-height-0" scroll-y show-scrollbar="false"
@touchstart="emitScrollTouchStart" @touchmove="emitScrollTouch" @scroll="emitScrollTouch">
<CardSwiper v-if="discoveryCards.length > 0" :list="discoveryCards" @didSelectItem="handleCardClick" />
<QuickQuestions v-if="discoveryQuickQuestions.length > 0" :list="discoveryQuickQuestions"
@didSelectItem="handleQuickQuestionClick" />
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE, SWITCH_TO_COMPANION_TAB } from "@/constants/constant";
import { navigateTo } from "@/router";
import { getAccessToken } from "@/constants/token";
import FindTabs from "./components/FindTabs/index.vue";
import CardSwiper from "./components/CardSwiper/index.vue";
import QuickQuestions from "./components/QuickQuestions/index.vue";
import discoveryCover from "@/components/ImageSwiper/images/2025-07-12_180248.jpg";
import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData } from "../../request/api/MainPageDataApi";
import { useAppStore, useLocationStore } from "@/store";
import { JumpType } from "../../model/ChatModel";
const appStore = useAppStore();
const locationStore = useLocationStore();
/// 从个渠道获取如二维码
const sceneId = appStore.sceneId || "";
const activeIndex = ref(0);
const discoveryTabs = ref([]);
const discoveryCards = ref([]);
const discoveryQuickQuestions = ref([]);
const emit = defineEmits(["scroll-touch-start", "scroll-touch"]);
const emitScrollTouchStart = () => {
emit("scroll-touch-start");
};
const emitScrollTouch = () => {
emit("scroll-touch");
};
/// tabs 切换事件
const handleTabChange = ({ tab, idx }) => {
activeIndex.value = idx;
queryDiscoveryData(tab.id);
}
/// 请求发现页tab数据
const queryTabsList = async () => {
const res = await homeTabsData();
if (res.code === 0) {
/// 处理tab数据
const tabList = res.data.map((item) => ({
id: item.id,
label: item.tagName,
}));
/// 设置tab数据
discoveryTabs.value = tabList;
/// 根据sceneId查询对应tab数据
findTabByIdWithActiveTabIndex(sceneId);
}
}
/// 根据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 queryDiscoveryData = async (tabId) => {
queryDiscoveryCards(tabId);
queryQuickQuestionData(tabId);
};
/// 请求发现页卡片数据
const queryDiscoveryCards = async (tabId) => {
const res = await homeTabContentData({ tagId: tabId });
if (res.code === 0) {
discoveryCards.value = configDataList(res.data);
}
}
/// 请求快速问题列表数据
const queryQuickQuestionData = async (tabId) => {
const res = await homeQuickQuestionData({ tagId: tabId });
if (res.code === 0) {
discoveryQuickQuestions.value = configDataList(res.data);
}
}
/// 统一处理接口返回数据结构
const configDataList = (data) => {
return data.map((item) => ({
id: item.id,
title: item.tabContent.mainTitle,
subTitle: item.tabContent.subTitle,
tag: item.tabContent.tag,
coverImage: item.tabContent.coverImage,
tabContentId: item.tabContent.id,
jumpContent: item.tabContent.jumpContent,
jumpDesc: item.tabContent.jumpDesc,
jumpType: item.tabContent.jumpType,/// 跳转类型: 0商品 1提示词 2链接 3组件指令
}));
}
/// 卡片点击事件
const handleCardClick = (item) => {
handleClick(item);
};
/// 快速问题点击事件
const handleQuickQuestionClick = (item) => {
handleClick(item);
};
const handleClick = async (item) => {
console.log(`执行点击事件: ${item.jumpType},参数:${JSON.stringify(item.jumpContent)}`);
/// 商品
if (item.jumpType === JumpType.commodity) {
uni.navigateTo({
url: `/pages/goods/index?commodityId=${item.jumpContent}`,
});
}
/// 提示词
else if (item.jumpType === JumpType.prompt) {
uni.$emit(SEND_MESSAGE_CONTENT_TEXT, item.jumpContent);
}
/// 链接
else if (item.jumpType === JumpType.link) {
if (item.jumpContent) {
const token = getAccessToken();
navigateTo(item.jumpContent, { token: token });
}
}
/// 组件指令
else if (item.jumpType === JumpType.command) {
uni.$emit(SEND_MESSAGE_COMMAND_TYPE, { type: item.jumpContent, title: item.jumpDesc });
}
}
/// 获取位置信息
const getLocation = () => {
/// 已经有sceneId了说明之前已经获取过位置信息了就不需要再获取一次了
if (sceneId) return;
uni.getLocation({
type: 'wgs84',
success: function (res) {
// 将位置信息存储到 Pinia 中
locationStore.setLocationData({
latitude: res.latitude,
longitude: res.longitude,
});
console.log('当前位置:' + JSON.stringify(res));
getNearbyTagsData();
}
});
}
/// 获取附近标签数据
const getNearbyTagsData = async () => {
const res = await getNearbyTags();
if (res.code === 0) {
const nearbyTagId = res.data;
findTabByIdWithActiveTabIndex(nearbyTagId);
}
}
/// 组件挂载后请求数据
onMounted(() => {
queryTabsList();
getLocation();
});
</script>
<style lang="scss" scoped>
.discovery-scroll {
flex-basis: 0;
height: 0;
min-height: 0;
overscroll-behavior-y: contain;
}
</style>

View File

@@ -8,8 +8,8 @@
<script setup>
import { ref, onMounted, nextTick } from "vue";
import { SCROLL_TO_BOTTOM } from "@/constant/constant";
import { discoveryCradComponent } from "@/request/api/MainPageDataApi";
import { SCROLL_TO_BOTTOM } from "@/constants/constant";
import { discoveryCradComponent } from "@/api/home";
import RecommendPostsList from "../RecommendPostsList/index.vue";
const themeDTOList = ref([]);

View File

@@ -13,7 +13,7 @@
</template>
<script setup>
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE } from "@/constants/constant";
import { defineProps } from "vue";
import ModuleTitle from "@/components/ModuleTitle/index.vue";

View File

@@ -25,8 +25,8 @@
padding: 12px;
text-align: center;
font-weight: 500;
font-size: $uni-font-size-sm;
color: $uni-text-color;
font-size: 12px;
color: #333;
}
}
}

View File

@@ -32,8 +32,8 @@
<script setup>
import { ref, defineEmits, defineExpose } from "vue";
import { getLoginUserPhone } from "@/request/api/LoginApi";
import { NOTICE_EVENT_LOGOUT } from "@/constant/constant";
import { getLoginUserPhone } from "@/api/login";
import { NOTICE_EVENT_LOGOUT } from "@/constants/constant";
import { useAppStore } from "@/store";
const appStore = useAppStore();
@@ -88,22 +88,7 @@ const handleMenuClick = (item) => {
};
// 退出登录
const handleLogout = () => {
uni.showModal({
title: "温馨提示",
content: "确定要退出登录吗?",
success: (res) => {
if (res.confirm) {
uni.clearStorageSync();
emits("close");
uni.$emit(NOTICE_EVENT_LOGOUT);
uni.showToast({
title: "退出登录成功",
});
}
},
});
};
const handleLogout = () => { };
defineExpose({ getLoginUserPhoneInfo });
</script>

View File

@@ -32,7 +32,7 @@
.label {
font-size: 28rpx;
color: $uni-text-color;
color: #333;
}
.value {
@@ -59,7 +59,7 @@
height: 42px;
margin-top: 40px;
background-color: #fff;
color: $uni-text-color;
color: #333;
border-radius: 8rpx;
border: none;
}

View File

@@ -15,7 +15,7 @@
.title {
font-size: 18px;
text-align: center;
color: $uni-text-color;
color: #333;
}
.close-icon {

View File

@@ -35,8 +35,8 @@
import { defineProps, nextTick, onMounted, computed } from "vue";
import {
SCROLL_TO_BOTTOM,
} from "@/constant/constant";
import { getAccessToken } from "@/constant/token";
} from "@/constants/constant";
import { getAccessToken } from "@/constants/token";
import { navigateTo } from "@/router";

View File

@@ -4,7 +4,7 @@
}
.content {
background: linear-gradient(180deg, $theme-color-100 0%, #0ccd58 100%);
background: linear-gradient(180deg, #e8fff1 0%, #0ccd58 100%);
border-radius: 24px 24px 24px 24px;
border: 1px solid #ffffff;
position: relative;
@@ -23,8 +23,8 @@
.btn-bg-sub {
background: #0ccd58;
box-shadow:
inset 0px 0px 41px 0px $theme-color-50,
inset 0px 0px 15px 0px $theme-color-100;
inset 0px 0px 41px 0px #f0f8f3,
inset 0px 0px 15px 0px #e8fff1;
}
/* 左上角标签容器 */

View File

@@ -20,7 +20,7 @@ import { onMounted, ref } from "vue";
import { defineProps, computed } from "vue";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
import NoticeMessage from "../NoticeMessage/index.vue";
import { getLocalWeather } from "@/request/api/MainPageDataApi";
import { getLocalWeather } from "@/api/home";
const weatherText = ref('');

View File

@@ -79,7 +79,7 @@
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constants/constant";
const products = [
{

View File

@@ -76,8 +76,8 @@
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
import { getAccessToken } from "@/constant/token";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constants/constant";
import { getAccessToken } from "@/constants/token";
import { navigateTo } from "@/router";
const photo = {

View File

@@ -69,7 +69,7 @@
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constants/constant";
const faq = ["古桥有什么传说", "最佳观赏时间", "旁边还有什么景点"];

View File

@@ -94,7 +94,7 @@
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constants/constant";
const steps = [
{

View File

@@ -30,8 +30,8 @@
<script setup>
import { ref } from "vue";
import { Command } from "@/model/ChatModel";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { Command } from "@/constants/ChatModel";
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constants/constant";
import { checkToken } from "@/hooks/useGoLogin";
const popup = ref(null);

View File

@@ -37,8 +37,8 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { getTimeNoticeList } from "@/request/api/MainPageDataApi";
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constant/constant";
import { getTimeNoticeList } from "@/api/home";
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
const props = defineProps({
tipsMessage: {

View File

@@ -24,7 +24,7 @@
.label {
font-size: 24rpx;
color: $uni-text-color-grey;
color: #333-grey;
}
.date {
font-size: 28rpx;

View File

@@ -12,8 +12,8 @@ import QuickBookingCalender from "../QuickBookingCalender/index.vue";
import QuickBookingContentList from "../QuickBookingContentList/index.vue";
import { ref, nextTick } from "vue";
import { onMounted } from "vue";
import { quickBookingComponent } from "@/request/api/MainPageDataApi";
import { SCROLL_TO_BOTTOM } from "@/constant/constant";
import { quickBookingComponent } from "@/api/home";
import { SCROLL_TO_BOTTOM } from "@/constants/constant";
const selectedDate = ref({});

View File

@@ -25,7 +25,7 @@
left: 8px;
background: #ffe7b2;
color: #b97a00;
font-size: $uni-font-size-sm;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
z-index: 2;
@@ -59,7 +59,7 @@
}
.card-title {
font-size: $uni-font-size-lg;
font-size: 16px;
font-weight: bold;
color: #222;
width: 100%;
@@ -112,7 +112,7 @@
.card-price {
color: #ff6600;
font-size: $uni-font-size-lg;
font-size: 16px;
font-weight: bold;
}
.card-unit {

View File

@@ -23,7 +23,7 @@
<script setup>
import { defineProps } from "vue";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
import { SEND_MESSAGE_CONTENT_TEXT, SEND_MESSAGE_COMMAND_TYPE } from "@/constants/constant";
import ModuleTitle from "@/components/ModuleTitle/index.vue";
const props = defineProps({