Files
frontend-app/components/qiaobao-assistant/qiaobao-assistant.vue
T
2026-08-25 17:52:05 +08:00

789 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="visible" class="qiaobao-assistant" :class="{ 'is-collapsed': collapsed }">
<view v-if="collapsed" class="qiaobao-edge-tab"
:class="{ 'is-left': collapsedEdge === 'left', 'is-dragging': dockDragging }" :style="dockStyle" role="button"
aria-label="桥宝已收起,点击展开" @click.stop="restoreFromDock" @touchstart.stop="handleDockDragStart"
@touchmove.stop.prevent="handleDockDragMove" @touchend.stop="handleDockDragEnd"
@touchcancel.stop="handleDockDragEnd" @mousedown.stop.prevent="handleDockMouseDragStart">
<image class="qiaobao-edge-head" src="https://static.tbmall.xin/static/mine/qiaobao-edge-peek.png"
mode="aspectFit" />
</view>
<view v-else class="qiaobao-float" :class="{ 'is-scratching': action === 'scratch' }" :style="floatStyle"
role="button" aria-label="桥宝,点击查看当前页面说明,长按可隐藏" @click.stop="openHelp" @longpress.stop="openHideMenu"
@contextmenu.stop.prevent="openHideMenu" @touchstart.stop="handleDragStart"
@touchmove.stop.prevent="handleDragMove" @touchend.stop="handleDragEnd" @touchcancel.stop="handleDragEnd"
@mousedown.stop.prevent="handleMouseDragStart">
<view v-if="showBubble" class="qiaobao-bubble">点我,了解这个页面</view>
<image class="qiaobao-image" :src="actionImage" mode="aspectFit" />
<view class="qiaobao-shadow" />
</view>
<view v-if="panelVisible" class="qiaobao-mask" @click="closeHelp" @touchmove.stop.prevent>
<view class="qiaobao-sheet" @click.stop>
<view class="qiaobao-handle" />
<view class="qiaobao-sheet-head">
<view class="qiaobao-avatar-wrap">
<image class="qiaobao-avatar" src="https://static.tbmall.xin/static/mine/qiaobao-idle.png"
mode="aspectFit" />
</view>
<view class="qiaobao-title-wrap">
<text class="qiaobao-kicker">桥宝 · 页面向导</text>
<text class="qiaobao-title">{{ resolvedTitle }}</text>
</view>
<view class="qiaobao-close" aria-label="关闭" @click="closeHelp">×</view>
</view>
<scroll-view class="qiaobao-content" scroll-y>
<text class="qiaobao-summary">{{ resolvedSummary }}</text>
<view v-if="resolvedTips.length" class="qiaobao-tips">
<view v-for="(tip, index) in resolvedTips" :key="index" class="qiaobao-tip">
<text class="qiaobao-tip-index">{{ index + 1 }}</text>
<text class="qiaobao-tip-text">{{ tip }}</text>
</view>
</view>
<slot />
</scroll-view>
<button class="qiaobao-confirm" @click="closeHelp">我知道了</button>
<text class="qiaobao-hide-hint">长按桥宝,可以选择隐藏</text>
</view>
</view>
</view>
</template>
<script>
import {
getCurrentPagePath,
getQiaobaoDockEdge,
getQiaobaoPosition,
hideQiaobaoForPage,
hideQiaobaoGlobally,
isQiaobaoHidden,
saveQiaobaoPosition,
setQiaobaoDockEdge,
showQiaobao,
} from "@/utils/qiaobao.js";
import { resolveQiaobaoPageHelp } from "@/utils/qiaobao-page-help.js";
const ACTION_IMAGES = {
idle: "https://static.tbmall.xin/static/mine/qiaobao-idle.png",
blink: "https://static.tbmall.xin/static/mine/qiaobao-blink.png",
scratch: "https://static.tbmall.xin/static/mine/qiaobao-scratch.png",
};
export default {
name: "QiaobaoAssistant",
props: {
pageKey: { type: String, default: "" },
title: { type: String, default: "" },
summary: { type: String, default: "" },
tips: { type: Array, default: () => [] },
bubble: { type: Boolean, default: true },
},
emits: ["open", "close", "hide"],
data() {
return {
visible: false,
collapsed: false,
collapsedEdge: "right",
panelVisible: false,
showBubble: false,
action: "idle",
actionTimer: null,
bubbleTimer: null,
longPressTriggered: false,
position: null,
viewportWidth: 0,
viewportHeight: 0,
safeAreaTop: 0,
safeAreaBottom: 0,
petWidth: 82,
petHeight: 88,
dockHeight: 48,
dockDragging: false,
dockDragMoved: false,
dockDragJustEnded: false,
dockDragStartPoint: { x: 0, y: 0 },
dockDragOriginY: 0,
dockPointerX: 0,
dragging: false,
dragMoved: false,
dragJustEnded: false,
dragStartPoint: { x: 0, y: 0 },
dragOrigin: { x: 0, y: 0 },
};
},
computed: {
currentPageKey() {
return this.pageKey || getCurrentPagePath();
},
actionImage() {
return ACTION_IMAGES[this.action] || ACTION_IMAGES.idle;
},
floatStyle() {
if (!this.position) return null;
return {
left: `${this.position.x}px`,
top: `${this.position.y}px`,
right: "auto",
bottom: "auto",
};
},
dockStyle() {
if (!this.position) return null;
const maxTop = Math.max(this.safeAreaTop + 8, this.viewportHeight - this.safeAreaBottom - this.dockHeight - 8);
return {
top: `${Math.min(maxTop, Math.max(this.safeAreaTop + 8, this.position.y + (this.petHeight - this.dockHeight) / 2))}px`,
bottom: "auto",
};
},
automaticHelp() {
return resolveQiaobaoPageHelp(this.currentPageKey);
},
resolvedTitle() {
return this.title || this.automaticHelp.title;
},
resolvedSummary() {
return this.summary || this.automaticHelp.summary;
},
resolvedTips() {
const customTips = Array.isArray(this.tips) ? this.tips.filter(Boolean) : [];
return customTips.length ? customTips : this.automaticHelp.tips;
},
},
mounted() {
uni.$on("qiaobao-visibility-change", this.handleVisibilityChange);
this.initializeDragPosition();
this.refreshVisibility();
// #ifdef H5
window.addEventListener("mousemove", this.handleMouseDragMove);
window.addEventListener("mouseup", this.handleMouseDragEnd);
// #endif
},
beforeUnmount() {
uni.$off("qiaobao-visibility-change", this.handleVisibilityChange);
// #ifdef H5
window.removeEventListener("mousemove", this.handleMouseDragMove);
window.removeEventListener("mouseup", this.handleMouseDragEnd);
// #endif
this.clearTimers();
},
methods: {
initializeDragPosition() {
const systemInfo = uni.getSystemInfoSync();
this.viewportWidth = Number(systemInfo.windowWidth) || 375;
this.viewportHeight = Number(systemInfo.windowHeight) || 667;
this.safeAreaTop = Number(systemInfo.safeAreaInsets?.top) || 0;
this.safeAreaBottom = Number(systemInfo.safeAreaInsets?.bottom) || 0;
const scale = this.viewportWidth / 750;
this.petWidth = 164 * scale;
this.petHeight = 176 * scale;
this.dockHeight = 126 * scale;
const storedPosition = getQiaobaoPosition();
const defaultPosition = {
x: this.viewportWidth - this.petWidth - 10,
y: this.viewportHeight - this.safeAreaBottom - this.petHeight - 66,
};
this.position = this.clampPosition(storedPosition || defaultPosition);
},
clampPosition(position) {
const minX = 0;
const maxX = Math.max(0, this.viewportWidth - this.petWidth);
const minY = this.safeAreaTop + 4;
const maxY = Math.max(minY, this.viewportHeight - this.safeAreaBottom - this.petHeight - 4);
return {
x: Math.min(maxX, Math.max(minX, Number(position?.x) || 0)),
y: Math.min(maxY, Math.max(minY, Number(position?.y) || minY)),
};
},
getTouchPoint(event) {
const touch = event?.touches?.[0] || event?.changedTouches?.[0];
if (!touch) return null;
return { x: Number(touch.clientX), y: Number(touch.clientY) };
},
getMousePoint(event) {
if (!event || !Number.isFinite(Number(event.clientX))) return null;
return { x: Number(event.clientX), y: Number(event.clientY) };
},
startDragging(point) {
if (!point || this.panelVisible) return;
this.dragging = true;
this.dragMoved = false;
this.dragStartPoint = point;
this.dragOrigin = { ...(this.position || { x: 0, y: 0 }) };
this.showBubble = false;
},
handleDragStart(event) {
this.startDragging(this.getTouchPoint(event));
},
handleMouseDragStart(event) {
this.startDragging(this.getMousePoint(event));
},
handleDragMove(event) {
if (!this.dragging) return;
const point = this.getTouchPoint(event);
if (!point) return;
const deltaX = point.x - this.dragStartPoint.x;
const deltaY = point.y - this.dragStartPoint.y;
if (!this.dragMoved && Math.hypot(deltaX, deltaY) > 6) {
this.dragMoved = true;
this.longPressTriggered = false;
}
this.position = this.clampPosition({
x: this.dragOrigin.x + deltaX,
y: this.dragOrigin.y + deltaY,
});
},
handleMouseDragMove(event) {
if (this.dockDragging) {
this.moveDockDragging(this.getMousePoint(event));
return;
}
if (!this.dragging) return;
const point = this.getMousePoint(event);
if (!point) return;
const deltaX = point.x - this.dragStartPoint.x;
const deltaY = point.y - this.dragStartPoint.y;
if (!this.dragMoved && Math.hypot(deltaX, deltaY) > 6) this.dragMoved = true;
this.position = this.clampPosition({
x: this.dragOrigin.x + deltaX,
y: this.dragOrigin.y + deltaY,
});
},
handleMouseDragEnd() {
if (this.dockDragging) {
this.handleDockDragEnd();
return;
}
this.handleDragEnd();
},
startDockDragging(point) {
if (!point) return;
this.dockDragging = true;
this.dockDragMoved = false;
this.dockDragStartPoint = point;
this.dockDragOriginY = this.position ? this.position.y : this.safeAreaTop + 120;
this.dockPointerX = point.x;
},
handleDockDragStart(event) {
this.startDockDragging(this.getTouchPoint(event));
},
handleDockMouseDragStart(event) {
this.startDockDragging(this.getMousePoint(event));
},
moveDockDragging(point) {
if (!this.dockDragging || !point) return;
const deltaX = point.x - this.dockDragStartPoint.x;
const deltaY = point.y - this.dockDragStartPoint.y;
if (!this.dockDragMoved && Math.hypot(deltaX, deltaY) > 5) {
this.dockDragMoved = true;
}
this.dockPointerX = point.x;
this.collapsedEdge = point.x < this.viewportWidth / 2 ? "left" : "right";
this.position = this.clampPosition({
x: this.position ? this.position.x : 0,
y: this.dockDragOriginY + deltaY,
});
},
handleDockDragMove(event) {
this.moveDockDragging(this.getTouchPoint(event));
},
handleDockDragEnd() {
if (!this.dockDragging) return;
this.dockDragging = false;
if (!this.dockDragMoved) return;
setQiaobaoDockEdge(this.collapsedEdge);
saveQiaobaoPosition(this.position);
this.dockDragJustEnded = true;
setTimeout(() => {
this.dockDragJustEnded = false;
this.dockDragMoved = false;
}, 280);
},
handleDragEnd() {
if (!this.dragging) return;
this.dragging = false;
if (!this.dragMoved) return;
this.position = this.clampPosition(this.position);
saveQiaobaoPosition(this.position);
const edgeThreshold = Math.max(22, this.viewportWidth * 0.06);
if (this.position.x <= edgeThreshold) {
hideQiaobaoGlobally("left");
} else if (this.position.x + this.petWidth >= this.viewportWidth - edgeThreshold) {
hideQiaobaoGlobally("right");
}
this.dragJustEnded = true;
setTimeout(() => {
this.dragJustEnded = false;
this.dragMoved = false;
}, 280);
},
getNearestEdge() {
if (!this.position) return "right";
return this.position.x + this.petWidth / 2 < this.viewportWidth / 2 ? "left" : "right";
},
refreshVisibility() {
this.visible = true;
this.collapsed = isQiaobaoHidden(this.currentPageKey);
this.collapsedEdge = getQiaobaoDockEdge();
if (this.collapsed) {
this.showBubble = false;
this.clearTimers();
return;
}
this.preloadImages();
this.startMotion();
if (this.bubble) {
this.showBubble = true;
this.bubbleTimer = setTimeout(() => {
this.showBubble = false;
}, 5000);
}
},
handleVisibilityChange({ scope, pagePath, edge } = {}) {
if (scope === "all") this.collapseToEdge(edge);
if (scope === "page" && pagePath === this.currentPageKey) this.collapseToEdge(edge);
if (scope === "show" && pagePath === this.currentPageKey) this.expandFromEdge();
if (scope === "reset") this.refreshVisibility();
},
preloadImages() {
// #ifdef H5
Object.values(ACTION_IMAGES).forEach((src) => {
const image = new Image();
image.src = src;
});
// #endif
},
startMotion() {
this.clearActionTimer();
const nextDelay = 3200 + Math.floor(Math.random() * 2600);
this.actionTimer = setTimeout(() => {
const shouldScratch = Math.random() > 0.72;
this.action = shouldScratch ? "scratch" : "blink";
const duration = shouldScratch ? 1450 : 180;
this.actionTimer = setTimeout(() => {
this.action = "idle";
this.startMotion();
}, duration);
}, nextDelay);
},
openHelp() {
if (this.dragJustEnded) return;
if (this.longPressTriggered) {
this.longPressTriggered = false;
return;
}
this.showBubble = false;
this.panelVisible = true;
this.action = "blink";
this.$emit("open", this.currentPageKey);
},
closeHelp() {
this.panelVisible = false;
this.action = "idle";
this.$emit("close", this.currentPageKey);
},
openHideMenu() {
if (this.dragging || this.dragMoved) return;
this.longPressTriggered = true;
const edge = this.getNearestEdge();
uni.showActionSheet({
title: "桥宝收起设置",
itemList: ["当前页面收起到边缘", "所有页面收起到边缘"],
success: ({ tapIndex }) => {
if (tapIndex === 0) hideQiaobaoForPage(this.currentPageKey, edge);
if (tapIndex === 1) hideQiaobaoGlobally(edge);
this.$emit("hide", tapIndex === 1 ? "all" : "page");
},
complete: () => {
setTimeout(() => {
this.longPressTriggered = false;
}, 350);
},
});
},
collapseToEdge(edge = getQiaobaoDockEdge()) {
this.collapsedEdge = edge === "left" ? "left" : "right";
this.collapsed = true;
this.panelVisible = false;
this.showBubble = false;
this.action = "idle";
this.clearTimers();
},
expandFromEdge() {
this.collapsed = false;
const edgeX = this.collapsedEdge === "left"
? 12
: Math.max(12, this.viewportWidth - this.petWidth - 12);
const currentY = this.position ? this.position.y : this.safeAreaTop + 120;
this.position = this.clampPosition({ x: edgeX, y: currentY });
saveQiaobaoPosition(this.position);
this.action = "blink";
this.preloadImages();
this.startMotion();
setTimeout(() => {
if (!this.collapsed) this.action = "idle";
}, 180);
},
restoreFromDock() {
if (this.dockDragJustEnded) return;
showQiaobao(this.currentPageKey);
},
show() {
this.visible = true;
showQiaobao(this.currentPageKey);
},
clearActionTimer() {
if (this.actionTimer) clearTimeout(this.actionTimer);
this.actionTimer = null;
},
clearTimers() {
this.clearActionTimer();
if (this.bubbleTimer) clearTimeout(this.bubbleTimer);
this.bubbleTimer = null;
},
},
};
</script>
<style scoped lang="scss">
.qiaobao-assistant {
position: relative;
z-index: 9999;
}
.qiaobao-edge-tab {
position: fixed;
right: -18rpx;
bottom: calc(150rpx + constant(safe-area-inset-bottom));
bottom: calc(150rpx + env(safe-area-inset-bottom));
width: 132rpx;
height: 126rpx;
z-index: 99999;
overflow: visible;
animation: qiaobao-edge-in-right .22s cubic-bezier(.22, 1, .36, 1) both;
-webkit-tap-highlight-color: transparent;
touch-action: none;
}
.qiaobao-edge-tab.is-left {
left: -18rpx;
right: auto;
animation-name: qiaobao-edge-in-left;
}
.qiaobao-edge-tab.is-dragging {
animation: none;
}
.qiaobao-edge-head {
width: 132rpx;
height: 132rpx;
display: block;
pointer-events: none;
transform-origin: center;
}
.qiaobao-edge-tab.is-left .qiaobao-edge-head {
transform: scaleX(-1);
}
.qiaobao-float {
position: fixed;
right: 20rpx;
bottom: calc(132rpx + constant(safe-area-inset-bottom));
bottom: calc(132rpx + env(safe-area-inset-bottom));
width: 164rpx;
height: 176rpx;
z-index: 99999;
transform-origin: 50% 90%;
-webkit-tap-highlight-color: transparent;
touch-action: none;
}
.qiaobao-image {
position: relative;
z-index: 2;
width: 164rpx;
height: 164rpx;
}
.qiaobao-shadow {
position: absolute;
left: 28rpx;
right: 22rpx;
bottom: 3rpx;
height: 16rpx;
border-radius: 50%;
background: rgba(41, 21, 77, .2);
filter: blur(6rpx);
z-index: 1;
}
.qiaobao-bubble {
position: absolute;
right: 132rpx;
top: 26rpx;
width: 250rpx;
padding: 20rpx 24rpx;
border-radius: 24rpx 24rpx 6rpx 24rpx;
color: #fff;
background: #302645;
box-shadow: 0 12rpx 32rpx rgba(27, 17, 51, .18);
font-size: 24rpx;
line-height: 1.45;
white-space: nowrap;
animation: qiaobao-bubble-in .2s ease-out both;
}
.is-scratching {
animation: qiaobao-curious 1.45s ease-in-out both;
}
.qiaobao-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999999;
display: flex;
align-items: flex-end;
background: rgba(20, 16, 29, .52);
animation: qiaobao-mask-in .2s ease-out both;
}
.qiaobao-sheet {
width: 100%;
max-height: 80vh;
padding: 12rpx 32rpx 32rpx;
padding-bottom: calc(32rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
border-radius: 36rpx 36rpx 0 0;
background: #fff;
box-shadow: 0 -16rpx 48rpx rgba(29, 17, 55, .14);
animation: qiaobao-sheet-in .24s ease-out both;
}
.qiaobao-handle {
width: 72rpx;
height: 8rpx;
margin: 4rpx auto 22rpx;
border-radius: 999rpx;
background: #ddd8e8;
}
.qiaobao-sheet-head {
display: flex;
align-items: center;
min-height: 116rpx;
}
.qiaobao-avatar-wrap {
width: 112rpx;
height: 112rpx;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1eaff;
}
.qiaobao-avatar {
width: 104rpx;
height: 104rpx;
}
.qiaobao-title-wrap {
min-width: 0;
flex: 1;
margin-left: 22rpx;
display: flex;
flex-direction: column;
}
.qiaobao-kicker {
color: #7b2cff;
font-size: 22rpx;
font-weight: 600;
}
.qiaobao-title {
margin-top: 8rpx;
color: #17131f;
font-size: 36rpx;
line-height: 1.25;
font-weight: 700;
}
.qiaobao-close {
width: 64rpx;
height: 64rpx;
margin-left: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #6c6674;
background: #f4f2f7;
font-size: 46rpx;
line-height: 1;
}
.qiaobao-content {
max-height: 46vh;
margin-top: 24rpx;
}
.qiaobao-summary {
display: block;
color: #4b4653;
font-size: 28rpx;
line-height: 1.7;
}
.qiaobao-tips {
margin-top: 26rpx;
}
.qiaobao-tip {
display: flex;
align-items: flex-start;
margin-bottom: 22rpx;
}
.qiaobao-tip-index {
width: 40rpx;
height: 40rpx;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #6b24e8;
background: #eee4ff;
font-size: 22rpx;
font-weight: 700;
}
.qiaobao-tip-text {
flex: 1;
margin-left: 18rpx;
color: #302b38;
font-size: 27rpx;
line-height: 1.55;
}
.qiaobao-confirm {
width: 100%;
height: 88rpx;
margin: 28rpx 0 0;
padding: 0;
border: 0;
border-radius: 24rpx;
color: #fff;
background: #7433f1;
font-size: 30rpx;
font-weight: 600;
line-height: 88rpx;
}
.qiaobao-confirm::after {
border: 0;
}
.qiaobao-hide-hint {
display: block;
margin-top: 18rpx;
color: #9a94a3;
text-align: center;
font-size: 22rpx;
}
@keyframes qiaobao-bubble-in {
from {
opacity: 0;
transform: translateY(8rpx) scale(.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes qiaobao-mask-in {
from {
background: rgba(20, 16, 29, 0);
}
to {
background: rgba(20, 16, 29, .52);
}
}
@keyframes qiaobao-sheet-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes qiaobao-curious {
0%,
100% {
transform: rotate(0);
}
32% {
transform: rotate(2deg);
}
68% {
transform: rotate(-1deg);
}
}
@keyframes qiaobao-edge-in-right {
from {
opacity: 0;
transform: translateX(54rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes qiaobao-edge-in-left {
from {
opacity: 0;
transform: translateX(-54rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qiaobao-bubble,
.qiaobao-mask,
.qiaobao-sheet,
.qiaobao-edge-tab,
.is-scratching {
animation: none !important;
}
}
</style>