front/src/views/h5/customer-center/components/AppTabBar.vue
zzh 2dd3e656a3
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m58s
feat: H5 访客可逛四 Tab,登录门禁与微信注册引导优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 10:22:58 +08:00

749 lines
17 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>
<div class="h5-dock-shell">
<nav ref="dockRef" class="h5-dock" aria-label="Customer center tabs">
<div class="h5-dock__glass" aria-hidden="true" />
<div
class="h5-dock__slider"
:class="{ 'is-ready': sliderReady }"
:style="sliderStyle"
aria-hidden="true"
/>
<router-link
v-for="item in tabs"
:key="item.path"
:to="item.path"
replace
class="h5-dock__item"
:class="{ 'is-active': isActive(item.path) }"
>
<span class="h5-dock__icon-wrap">
<component :is="item.icon" class="h5-dock__icon" />
<span v-if="item.badge && item.badge > 0" class="h5-dock__badge">
{{ item.badge > 99 ? '99+' : item.badge }}
</span>
</span>
<span class="h5-dock__label">{{ t(item.labelKey) }}</span>
</router-link>
</nav>
<div class="h5-dock-fab-slot" :class="{ 'is-hidden': hideCreate }">
<button
v-show="showFabHint && !hideCreate"
type="button"
class="h5-dock-fab-hint"
@click="dismissFabHint"
>
{{ t('h5.tab.createOrder') }}
</button>
<button
type="button"
class="h5-dock-fab"
:tabindex="hideCreate ? -1 : 0"
:aria-hidden="hideCreate ? 'true' : 'false'"
:aria-label="t('h5.tab.createOrder')"
@click="onCreateOrder"
>
<span class="h5-dock-fab__glass" aria-hidden="true" />
<span class="h5-dock-fab__blob" aria-hidden="true" />
<icon-plus :size="26" class="h5-dock-fab__icon" />
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { IconPlus } from '@arco-design/web-vue/es/icon';
import IconTabCalculator from '@/assets/icons/h5/tab-calculator.svg';
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
import IconTabService from '@/assets/icons/h5/tab-service.svg';
import IconTabMe from '@/assets/icons/h5/tab-me.svg';
import { isLogin } from '@/utils/auth';
defineOptions({ name: 'H5AppTabBar' });
const props = defineProps<{
orderBadge?: number;
/** 勾选批量时隐藏凸起下单按钮,避免挡住底部操作条 */
hideCreate?: boolean;
}>();
const emit = defineEmits<{
(e: 'create-order'): void;
}>();
const { t } = useI18n();
const route = useRoute();
const dockRef = ref<HTMLElement | null>(null);
const sliderReady = ref(false);
const sliderStyle = ref<Record<string, string>>({
left: '0px',
width: '0px',
opacity: '0',
});
const sliderPos = { x: 0, w: 0 };
const showFabHint = ref(false);
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let hideCreateTimer: ReturnType<typeof setTimeout> | null = null;
let hintShowTimer: ReturnType<typeof setTimeout> | null = null;
let hintHideTimer: ReturnType<typeof setTimeout> | null = null;
let sliderAnimating = false;
const FAB_HINT_STORAGE_KEY = 'h5_create_order_fab_hint_seen';
const tabs = computed(() => [
{
path: '/h5/price',
labelKey: 'h5.tab.price',
icon: IconTabCalculator,
},
{
path: '/h5/assistant',
labelKey: 'h5.tab.assistant',
icon: IconTabService,
},
{
path: '/h5/orders',
labelKey: 'h5.tab.orders',
icon: IconTabPackage,
badge: props.orderBadge || 0,
},
{
path: '/h5/me',
labelKey: 'h5.tab.me',
icon: IconTabMe,
},
]);
function isActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`);
}
function hasSeenFabHint() {
try {
return localStorage.getItem(FAB_HINT_STORAGE_KEY) === '1';
} catch {
return true;
}
}
function markFabHintSeen() {
try {
localStorage.setItem(FAB_HINT_STORAGE_KEY, '1');
} catch {
/* 隐私模式无法写入时,至少本页不再反复弹出 */
}
}
function clearHintTimers() {
if (hintShowTimer) {
clearTimeout(hintShowTimer);
hintShowTimer = null;
}
if (hintHideTimer) {
clearTimeout(hintHideTimer);
hintHideTimer = null;
}
}
function dismissFabHint() {
showFabHint.value = false;
clearHintTimers();
markFabHintSeen();
}
function scheduleFabHint() {
clearHintTimers();
// 访客点下单会先弹登录,不主动推「下单」气泡
if (props.hideCreate || hasSeenFabHint() || !isLogin()) {
showFabHint.value = false;
return;
}
hintShowTimer = setTimeout(() => {
hintShowTimer = null;
if (props.hideCreate || hasSeenFabHint() || !isLogin()) return;
showFabHint.value = true;
hintHideTimer = setTimeout(() => {
hintHideTimer = null;
dismissFabHint();
}, 4200);
}, 700);
}
function onCreateOrder() {
if (props.hideCreate) return;
dismissFabHint();
try {
navigator.vibrate?.(12);
} catch {
/* 部分 WebView 不支持震动 */
}
emit('create-order');
}
const SLIDER_INSET = 4;
const LIQUID_STRETCH_MS = 120;
function applySlider(x: number, w: number, opacity = '1') {
sliderPos.x = x;
sliderPos.w = w;
sliderStyle.value = {
left: `${x}px`,
width: `${w}px`,
opacity,
};
}
function readLiveSlider() {
const slider = dockRef.value?.querySelector<HTMLElement>('.h5-dock__slider');
if (!slider) return { ...sliderPos };
const cs = getComputedStyle(slider);
return {
x: Number.parseFloat(cs.left) || sliderPos.x,
w: Number.parseFloat(cs.width) || sliderPos.w,
};
}
function clearSettleTimer() {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
}
function updateSlider(animate: boolean) {
const dock = dockRef.value;
const active = dock?.querySelector<HTMLElement>('.h5-dock__item.is-active');
if (!dock || !active) {
sliderStyle.value = { ...sliderStyle.value, opacity: '0' };
sliderAnimating = false;
return;
}
const x = active.offsetLeft + SLIDER_INSET;
const w = Math.max(active.offsetWidth - SLIDER_INSET * 2, 0);
clearSettleTimer();
const reduceMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!animate || !sliderReady.value || sliderPos.w <= 0 || reduceMotion) {
sliderReady.value = false;
applySlider(x, w);
requestAnimationFrame(() => {
sliderReady.value = true;
});
sliderAnimating = false;
return;
}
const from = readLiveSlider();
if (Math.abs(from.x - x) < 1 && Math.abs(from.w - w) < 1) {
applySlider(x, w);
sliderAnimating = false;
return;
}
const coverLeft = Math.min(from.x, x);
const coverWidth = Math.max(from.x + from.w, x + w) - coverLeft;
sliderAnimating = true;
applySlider(coverLeft, coverWidth);
settleTimer = setTimeout(() => {
applySlider(x, w);
sliderAnimating = false;
settleTimer = null;
}, LIQUID_STRETCH_MS);
}
async function syncSlider(animate: boolean) {
await nextTick();
updateSlider(animate);
}
let resizeObserver: ResizeObserver | null = null;
function onViewportResize() {
if (sliderAnimating) return;
updateSlider(false);
}
onMounted(() => {
syncSlider(false);
scheduleFabHint();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(onViewportResize);
if (dockRef.value) resizeObserver.observe(dockRef.value);
}
window.addEventListener('resize', onViewportResize);
});
onBeforeUnmount(() => {
clearSettleTimer();
clearHintTimers();
if (hideCreateTimer) {
clearTimeout(hideCreateTimer);
hideCreateTimer = null;
}
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});
watch(
() => route.path,
() => {
syncSlider(true);
},
);
watch(
() => props.hideCreate,
(hidden) => {
if (hideCreateTimer) clearTimeout(hideCreateTimer);
hideCreateTimer = setTimeout(() => {
hideCreateTimer = null;
syncSlider(true);
}, 320);
if (hidden) {
showFabHint.value = false;
clearHintTimers();
} else {
scheduleFabHint();
}
},
);
</script>
<style lang="less" scoped>
.h5-dock-shell {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 0;
/* 16px 侧边 + 半高圆角 ≈ iPhone 屏幕连续圆角 */
padding: 0 var(--h5-dock-side-inset, 16px)
var(--h5-dock-bottom-inset, max(8px, calc(env(safe-area-inset-bottom, 0px) - 12px)));
pointer-events: none;
}
.h5-dock {
position: relative;
pointer-events: auto;
display: flex;
align-items: stretch;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
gap: 0;
height: var(--h5-dock-height, 68px);
min-height: var(--h5-dock-height, 68px);
padding: 5px;
border-radius: var(--h5-dock-radius, 34px);
corner-shape: squircle;
overflow: hidden;
isolation: isolate;
background: transparent;
border: none;
box-shadow:
0 12px 40px rgba(15, 23, 42, 0.14),
0 2px 8px rgba(15, 23, 42, 0.05);
box-sizing: border-box;
}
.h5-dock__glass {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
corner-shape: inherit;
overflow: hidden;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.38) 0%,
rgba(255, 255, 255, 0.16) 100%
);
backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
-webkit-backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
/* 顶部入射高光 */
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
165deg,
rgba(255, 255, 255, 0.62) 0%,
rgba(255, 255, 255, 0.12) 32%,
rgba(255, 255, 255, 0) 58%
);
}
/* Liquid Glass 边缘:上沿更亮、下沿更淡 */
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.92) 0%,
rgba(255, 255, 255, 0.28) 42%,
rgba(255, 255, 255, 0.4) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
}
.h5-dock__slider {
position: absolute;
top: 5px;
bottom: 5px;
border-radius: calc(var(--h5-dock-radius, 34px) - 5px);
corner-shape: squircle;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(255, 255, 255, 0.42) 100%
);
backdrop-filter: blur(10px) saturate(140%) brightness(1.12);
-webkit-backdrop-filter: blur(10px) saturate(140%) brightness(1.12);
box-shadow:
0 4px 16px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(255, 255, 255, 0.2);
pointer-events: none;
z-index: 1;
opacity: 0;
&:not(.is-ready) {
transition: none;
}
/* 先拉长覆盖目标 Tab,再弹簧收回,接近 iOS 26 液态滑块 */
&.is-ready {
transition:
left 0.42s cubic-bezier(0.32, 0.72, 0, 1),
width 0.42s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.2s ease;
}
}
.h5-dock__item {
position: relative;
z-index: 2;
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-width: 0;
padding: 0 2px;
color: #86909c;
text-decoration: none;
background: transparent;
border: none;
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: color 0.22s ease;
&.is-active {
color: var(--app-primary-color);
.h5-dock__icon {
animation: h5-dock-icon-pop 0.42s cubic-bezier(0.22, 1.35, 0.36, 1);
}
.h5-dock__label {
font-weight: 600;
}
}
&:active {
transform: scale(0.96);
}
}
.h5-dock__icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
border-radius: 0;
line-height: 1;
}
.h5-dock__icon {
font-size: 24px;
width: 1em;
height: 1em;
}
.h5-dock__badge {
position: absolute;
top: -4px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #f53f3f;
color: #fff;
font-size: 10px;
line-height: 16px;
text-align: center;
box-sizing: border-box;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92);
}
.h5-dock__label {
max-width: 100%;
font-size: 11px;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.h5-dock-fab-slot {
position: relative;
flex: 0 0 var(--h5-dock-fab-size, 56px);
width: var(--h5-dock-fab-size, 56px);
height: var(--h5-dock-fab-size, 56px);
margin-left: 12px;
overflow: visible;
pointer-events: none;
transition:
flex-basis 0.32s cubic-bezier(0.32, 0.72, 0, 1),
width 0.32s cubic-bezier(0.32, 0.72, 0, 1),
margin-left 0.32s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.22s ease;
&.is-hidden {
overflow: hidden;
flex-basis: 0;
width: 0;
margin-left: 0;
opacity: 0;
.h5-dock-fab,
.h5-dock-fab-hint {
pointer-events: none;
}
}
}
.h5-dock-fab-hint {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
z-index: 3;
transform: translateX(-50%);
pointer-events: auto;
padding: 6px 10px;
border: none;
border-radius: 8px;
background: #1d2129;
color: #fff;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 0;
height: 0;
border: 6px solid transparent;
border-top-color: #1d2129;
transform: translateX(-50%);
}
}
/* 右侧独立圆形下单 FAB:液态玻璃 */
.h5-dock-fab {
position: relative;
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--h5-dock-fab-size, 56px);
height: var(--h5-dock-fab-size, 56px);
padding: 0;
border: none;
border-radius: 50%;
overflow: hidden;
isolation: isolate;
color: var(--app-primary-color);
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transform-origin: center;
transition: transform 0.22s cubic-bezier(0.22, 1.2, 0.36, 1);
&:active {
transform: scale(0.9);
.h5-dock-fab__blob {
transform: translate(18%, 22%) scale(1.28);
opacity: 0.95;
}
.h5-dock-fab__icon {
transform: scale(0.92);
}
}
}
.h5-dock-fab__glass {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.38) 0%,
rgba(255, 255, 255, 0.16) 100%
);
backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
-webkit-backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
155deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(255, 255, 255, 0.16) 36%,
rgba(255, 255, 255, 0) 62%
);
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
160deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.22) 42%,
rgba(255, 255, 255, 0.4) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
}
.h5-dock-fab__blob {
position: absolute;
left: 8%;
top: 4%;
z-index: 1;
width: 62%;
height: 52%;
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle at 40% 35%,
rgba(255, 255, 255, 0.78) 0%,
rgba(255, 255, 255, 0.18) 48%,
rgba(255, 255, 255, 0) 72%
);
filter: blur(1.2px);
opacity: 0.85;
transform: translate(0, 0) scale(1);
transition:
transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.22s ease;
}
.h5-dock-fab__icon {
position: relative;
z-index: 2;
color: var(--app-primary-color);
pointer-events: none;
transition: transform 0.22s cubic-bezier(0.22, 1.2, 0.36, 1);
}
@keyframes h5-dock-icon-pop {
0% {
transform: scale(0.88);
}
70% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
@media (max-width: 380px) {
.h5-dock__icon {
font-size: 22px;
}
.h5-dock__label {
font-size: 10px;
}
.h5-dock-fab-slot,
.h5-dock-fab {
--h5-dock-fab-size: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.h5-dock__slider.is-ready {
transition: none;
}
.h5-dock__item.is-active .h5-dock__icon {
animation: none;
}
.h5-dock-fab-slot,
.h5-dock-fab {
transition: none;
}
}
</style>