feat: tab的调整

This commit is contained in:
zoujing
2026-09-18 16:53:52 +08:00
parent 9475a3cc3a
commit f93b9f034f
8 changed files with 439 additions and 300 deletions

View File

@@ -1,10 +1,10 @@
<script setup>
/**
* 自绘底部 Tab 栏(未用原生 tabBar以便还原玻璃拟态 + 渐变发丝线)
* 切换方式uni.redirectTo三个 Tab 页为同级页面)
* 自绘底部 Tab 栏
* 三个 Tab 同屏共存(主包壳页面 pages/home/index.vue 里的三块面板),
* 所以本组件只负责「画」+ 抛切换意图,不做任何路由跳转 —— 由壳处理 @change。
*/
import { TABS } from "@/constant";
import { switchTabTo } from "@/utils/nav";
const props = defineProps({
active: { type: String, default: "home" },
@@ -12,15 +12,17 @@ const props = defineProps({
badges: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["change"]);
const onTap = (key) => {
if (key === props.active) return;
switchTabTo(key);
emit("change", key);
};
</script>
<template>
<view
class="fixed left-0 right-0 bottom-0 h-[78px] pb-[22px] flex z-[120] deco-hairline bg-[rgba(255,255,255,0.94)] backdrop-blur-[26px] backdrop-saturate-[1.8] shadow-[0_-1px_0_rgba(20,24,28,0.03)]"
class="fixed left-0 right-0 bottom-0 h-[78px] pb-[22px] flex z-[120] deco-hairline bg-[rgba(255,255,255,0.94)] shadow-[0_-1px_0_rgba(20,24,28,0.03)] [transform:translateZ(0)]"
>
<view
v-for="tab in TABS"

View File

@@ -89,18 +89,16 @@ export const REGION_TEXT = "贵州省黔南布依族苗族自治州龙里县";
/** 协议页更新/生效日期 */
export const AGREEMENT_DATE = "2026 年 9 月 11 日";
/** Tab 配置(自绘 Tab 栏) */
/** Tab 配置(自绘 Tab 栏;三个 Tab 同属壳页 pages/home/index面板与路径一一对应关系已不存在 */
export const TABS = [
{ key: "home", label: "主页", icon: "home", path: "/pages/home/index" },
{ key: "benefit", label: "权益", icon: "ticket", path: "/pages/benefit/index" },
{ key: "mine", label: "我的", icon: "user", path: "/pages/mine/index" },
{ key: "home", label: "主页", icon: "home" },
{ key: "benefit", label: "权益", icon: "ticket" },
{ key: "mine", label: "我的", icon: "user" },
];
/** 页面路径常量 */
export const ROUTES = {
HOME: "/pages/home/index",
BENEFIT: "/pages/benefit/index",
MINE: "/pages/mine/index",
LOGIN: "/pages/login/index",
CATALOG: "/pages-sub/catalog/index",
DETAIL: "/pages-sub/detail/index",

View File

@@ -23,22 +23,6 @@
"enablePullDownRefresh": false
}
},
{
"path": "pages/benefit/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "权益",
"enablePullDownRefresh": false
}
},
{
"path": "pages/mine/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的",
"enablePullDownRefresh": false
}
},
{
"path": "pages/login/index",
"style": {

View File

@@ -1,171 +1,64 @@
<script setup>
/**
* Tab 1 · 主页
* 数据来自 GET /v1/homepage。
* 纵向顺序:
* 品牌开场区(吸顶固定)→ 主视觉大卡180px→ 玩转龙里(左一通高 + 右二上下)
* → 全域导览 → 精选推荐 → 免责声明
* 无顶栏、无搜索、不展示任何权益入口。
* 主包壳页面:主页 / 权益 / 我的 三块面板同屏共存,底部 Tab 栏只渲染一次
* ---------------------------------------------------------------------------
*/
import { computed, ref } from "vue";
import { onMounted, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import contentApi from "@/api/content";
import { goCatalog, previewImages } from "@/utils/nav";
import { TABS } from "@/constant";
import { consumePendingTab, registerTabController, setTabQuery } from "@/utils/nav";
import HomePanel from "@/views/home/index.vue";
import BenefitPanel from "@/views/benefit/index.vue";
import MinePanel from "@/views/mine/index.vue";
const home = ref({});
const featured = ref([]);
const active = ref("home");
const homeRef = ref(null);
const benefitRef = ref(null);
const mineRef = ref(null);
const brandName = computed(() => home.value?.brand_name || "");
/** 品牌名末两字走品牌色(「发现龙里」→ 发现 + 龙里) */
const brandHead = computed(() => brandName.value.slice(0, -2));
const brandTail = computed(() => brandName.value.slice(-2));
const brand = computed(() => home.value?.brand || {});
const hero = computed(() => home.value?.hero || {});
const entries = computed(() => home.value?.entries || []);
const footerLines = computed(() => home.value?.footer_lines || []);
/** 玩转龙里:有板块数据才展示整块 */
const hasSections = computed(() => entries.value.length > 0);
/** 全域导览:有图或有标题才展示整块 */
const hasGuide = computed(() => !!(home.value?.guide_image_url || home.value?.guide_title));
/** 精选推荐:有卡片才展示整块(含标题行与「更多」) */
const hasFeatured = computed(() => featured.value.length > 0);
const panelRefs = { home: homeRef, benefit: benefitRef, mine: mineRef };
const entryOf = (key) => entries.value.find((e) => e.section_key === key) || {};
/** 面板按需刷新(切到它、或页面 onShow 时) */
const showPanel = (key) => panelRefs[key]?.value?.onPanelShow?.();
let loading = false;
const load = async () => {
if (loading) return;
loading = true;
try {
const data = await contentApi.getHomepage();
home.value = data || {};
featured.value = data?.featured?.items || [];
} catch (e) {
home.value = {};
featured.value = [];
} finally {
loading = false;
}
/**
* 切 Tab 的唯一入口Tab 栏点击、页面内 switchTabTo(key, query)、登录回跳都汇到这里。
* query 用 nav 的内存透传(如 `?seg=mine`),由目标面板 onPanelShow 消费。
*/
const goTab = (key, query = "") => {
if (!TABS.some((t) => t.key === key)) return;
setTabQuery(query);
active.value = key;
showPanel(key);
};
onShow(() => {
if (!home.value?.brand_name) load();
onMounted(() => {
// 面板的 onMounted 早于本页 onMounted此时 ref 已就绪
registerTabController({ go: goTab, getActive: () => active.value });
// 从子页 / 登录页切过来nav 里可能压着待切 Tab登录回跳、reLaunch 冷启)
const pending = consumePendingTab();
goTab(pending?.key || active.value, pending?.query || "");
});
/** 主视觉大卡「开始探索」/ 精选「更多」→ hero 所属板块 */
const onStartExplore = () => goCatalog(hero.value.section_key);
const onMoreFeatured = () => goCatalog(hero.value.section_key);
/** 导览图放大(接口已给图,不做独立页) */
const onMapTap = () => {
const url = home.value?.guide_image_url;
if (!url) return;
previewImages([url], url);
};
onShow(() => {
// 从板块列表 / 详情 / 登录页等返回:让当前面板按需刷新(各家内部有幂等守卫)
showPanel(active.value);
});
</script>
<template>
<view class="relative h-screen w-full flex flex-col skin-home">
<view class="relative z-[60] shrink-0">
<view class="px-4 pb-[10px]">
<AppStatusBar />
<view class="flex items-center gap-[7px] mt-[6px] text-[10px] font-bold tracking-[2.4px] text-brand-deep">
<view class="w-[5px] h-[5px] rounded-full bg-brand shadow-[0_0_0_4px_rgba(0,181,120,0.14)]" />
<text>{{ brand.sub_name }}</text>
</view>
<view class="mt-[9px] text-display font-bold tracking-[-1px] leading-[1.1] text-ink">
{{ brandHead }}<text class="not-italic gradtext-brand">{{ brandTail }}</text>
</view>
<view class="mt-2 text-[11.5px] leading-[1.55] text-ink2 tracking-[0.1px]">
{{ brand.slogan }}
</view>
</view>
<view class="relative h-screen w-full overflow-hidden">
<!-- 三块面板叠放同屏 visibility 切换 script 头部的说明 -->
<view :class="['absolute inset-0', active === 'home' ? 'visible' : 'invisible']">
<HomePanel ref="homeRef" />
</view>
<view :class="['absolute inset-0', active === 'benefit' ? 'visible' : 'invisible']">
<BenefitPanel ref="benefitRef" />
</view>
<view :class="['absolute inset-0', active === 'mine' ? 'visible' : 'invisible']">
<MinePanel ref="mineRef" />
</view>
<scroll-view class="flex-1 min-h-0" scroll-y :show-scrollbar="false">
<view class="relative z-[1] px-4">
<!-- 主视觉大卡距固定区仅 2px贴紧品牌区让首屏更紧凑 -->
<view class="relative h-[180px] mt-[2px] rounded-hero overflow-hidden shadow-hero" @click="onStartExplore">
<image class="absolute inset-0 w-full h-full" :src="hero.image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-hero" />
<view
class="pointer-events-none absolute inset-0 rounded-hero bg-[radial-gradient(210px_140px_at_98%_110%,rgba(63,223,168,0.44),transparent_74%)]" />
<!-- 白字加投影hero 图片是亮绿山景光靠遮罩中段不足以拉开对比text-shadow 可继承 -->
<view
class="absolute inset-0 z-[2] flex flex-col justify-end items-start px-5 pb-[17px] [text-shadow:0_1px_3px_rgba(0,0,0,0.5),0_2px_12px_rgba(0,0,0,0.42)]">
<view
class="flex items-center gap-[7px] text-[9.5px] font-semibold tracking-[1.8px] text-[rgba(255,255,255,0.76)]">
<view class="block w-[14px] h-px bg-brand-lite" />
<text>{{ hero.eyebrow }}</text>
</view>
<view class="mt-[6px] text-[26px] font-bold text-white tracking-[0.6px] leading-[1.15]">{{ hero.title }}
</view>
<view class="mt-[7px] text-[12px] text-[rgba(255,255,255,0.9)] tracking-[0.3px]">{{ hero.subtitle }}</view>
<view
class="mt-[13px] h-[34px] px-[15px] rounded-full bg-white inline-flex items-center gap-[5px] text-[13px] font-semibold text-brand-deep shadow-[0_10px_24px_rgba(3,26,20,0.28)] [text-shadow:none]">
<text>{{ hero.button_text }}</text>
<AppIcon name="arrow" tone="brandDeep" :size="14" />
</view>
</view>
</view>
<!-- 玩转龙里左一通高 + 右二上下无数据整块不展示 -->
<template v-if="hasSections">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">玩转龙里</text>
</view>
<view class="flex gap-[10px] h-[226px] mt-[2px]">
<SectionTile class="flex-[1.06] min-w-0" :section="entryOf('stay')" variant="tall" />
<view class="flex-1 min-w-0 flex flex-col gap-[10px]">
<SectionTile :section="entryOf('food')" variant="short" />
<SectionTile :section="entryOf('specialty')" variant="short" />
</view>
</view>
</template>
<!-- 全域导览无数据整块不展示 -->
<template v-if="hasGuide">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">全域导览</text>
</view>
<view class="relative h-[112px] rounded-tile overflow-hidden mt-[2px] shadow-elev2" @click="onMapTap">
<image class="absolute inset-0 w-full h-full" :src="home.guide_image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-map" />
<view
class="pointer-events-none absolute inset-0 rounded-tile bg-[radial-gradient(200px_130px_at_88%_112%,rgba(63,223,168,0.4),transparent_72%)]" />
<view class="absolute inset-0 z-[2] flex flex-col justify-center items-start px-[18px]">
<text class="text-white text-[16px] font-semibold">{{ home.guide_title }}</text>
<text class="text-[rgba(255,255,255,0.85)] text-hint mt-1">{{ home.guide_subtitle }}</text>
</view>
</view>
</template>
<!-- 精选推荐无卡片整块不展示卡片自身完成跳转页面不再重复绑定 tap -->
<template v-if="hasFeatured">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">精选推荐</text>
<view class="flex items-center gap-[2px] text-[11.5px] text-ink3" @click="onMoreFeatured">
<text>更多</text>
<AppIcon name="chev" tone="ink3" :size="14" />
</view>
</view>
<view v-for="(item, i) in featured" :key="item.id" :class="i ? 'mt-[10px]' : ''">
<ContentCard :item="item" />
</view>
</template>
<view v-for="(line, i) in footerLines" :key="i"
:class="i ? 'mx-1 text-[10px] leading-[1.7] text-ink3 text-center' : 'mt-5 mx-1 text-[10px] leading-[1.7] text-ink3 text-center'">
{{ line }}
</view>
</view>
<!-- 底部留白 = TabBar 78px + 18px 间隔避免最后一条内容贴住 TabBar -->
<view class="h-[96px]" />
</scroll-view>
<AppTabBar active="home" />
<AppTabBar :active="active" @change="goTab" />
</view>
</template>

View File

@@ -1,9 +1,65 @@
/**
* 导航工具登录回跳、Tab 切换、返回兜底
* 本项目未使用原生 tabBarTab 栏为自绘组件),因此 Tab 之间用 redirectTo 切换。
* ---------------------------------------------------------------------------
* 三个 Tab 不是三个页面,而是主包壳页 pages/home/index.vue 里的三块面板views/*
* 同屏常驻、切 Tab 只切面板可见性 —— Tab 栏因此只渲染一次,切 Tab 不会闪烁、不重建、不重复请求。
* 壳在 onMounted 时用 registerTabController({ go, getActive }) 把切换器交给本模块,
* 页面内部(如「我的页 → 我的权益」)调的 switchTabTo 即转为调它,不再走路由。
*
* ⚠️ 面板不是页面,没有 query 可用 → Tab 间参数用内存透传setTabQuery / consumeTabQuery
* ⚠️ 壳未挂载时切 Tab子页 / 登录页 / 冷启)→ 记 pendingTab 并 reLaunch 回壳,由壳 onMounted 消费。
*/
import { ROUTES, STORAGE_KEYS } from "@/constant";
/** 壳内面板 key */
const TAB_KEYS = ["home", "benefit", "mine"];
/** 壳注册进来的 Tab 切换器 */
let tabCtl = { go: null, getActive: () => "home" };
export const registerTabController = (ctl) => {
tabCtl = {
go: (ctl && ctl.go) || null,
getActive: (ctl && ctl.getActive) || (() => "home"),
};
};
/** Tab 间透传的参数 */
let tabQuery = "";
/** 壳尚未就绪时的待切 Tab */
let pendingTab = null;
export const setTabQuery = (query) => {
tabQuery = query || "";
};
/** 取出并清空透传参数(仅在目标面板 onPanelShow 调用一次) */
export const consumeTabQuery = () => {
const query = tabQuery;
tabQuery = "";
return query;
};
/** 取出并清空待切 Tab仅在壳 onMounted 调用一次) */
export const consumePendingTab = () => {
const pending = pendingTab;
pendingTab = null;
return pending;
};
/** query 串取参数 */
const pickParam = (query, key) => {
const matched = (query || "").match(new RegExp(`(?:^|&)${key}=([^&]*)`));
return matched ? matched[1] : "";
};
/** query 串去掉某个参数 */
const dropParam = (query, key) =>
(query || "")
.split("&")
.filter((kv) => kv && !new RegExp(`^${key}=`).test(kv))
.join("&");
/** 当前页面路径(含参数) */
export const currentRoute = () => {
const pages = getCurrentPages();
@@ -18,6 +74,13 @@ export const currentRoute = () => {
return `/${page.route}${query ? `?${query}` : ""}`;
};
/** 壳页地址补上当前 Tab否则登录后只能落回主页 */
const withTab = (url) => {
const [path, query = ""] = (url || "").split("?");
if (path !== ROUTES.HOME || /(?:^|&)tab=/.test(query)) return url;
return `${path}?tab=${tabCtl.getActive()}${query ? `&${query}` : ""}`;
};
/** 是否已登录,未登录则跳登录页并带上 redirect */
export const requireLogin = (silent = false) => {
const token = uni.getStorageSync(STORAGE_KEYS.ACCESS_TOKEN) || "";
@@ -31,28 +94,28 @@ export const requireLogin = (silent = false) => {
/** 跳登录页redirect 为登录后的回跳地址 */
export const goLogin = (redirect) => {
const target = redirect || currentRoute();
const target = withTab(redirect || currentRoute());
const url = `${ROUTES.LOGIN}${target ? `?redirect=${encodeURIComponent(target)}` : ""}`;
uni.navigateTo({ url });
};
/** 登录成功后的回跳 */
/** 登录成功后的回跳:目标是壳页 → 切回原 Tab含原分段其余页面走 redirectTo */
export const goAfterLogin = (redirect) => {
if (redirect) {
const url = decodeURIComponent(redirect);
const pages = getCurrentPages();
const top = pages.length ? `/${pages[pages.length - 1].route}` : "";
if (top && url.startsWith(top)) {
uni.navigateBack();
return;
}
uni.redirectTo({
url,
if (!redirect) {
uni.navigateBack({
fail: () => uni.reLaunch({ url: ROUTES.HOME }),
});
return;
}
uni.navigateBack({
const url = decodeURIComponent(redirect);
const [path, query = ""] = url.split("?");
if (path === ROUTES.HOME) {
const rest = dropParam(query, "tab");
switchTabTo(pickParam(query, "tab") || tabCtl.getActive(), rest ? `?${rest}` : "");
return;
}
uni.redirectTo({
url,
fail: () => uni.reLaunch({ url: ROUTES.HOME }),
});
};
@@ -67,18 +130,21 @@ export const goBack = () => {
}
};
/** Tab 切换 */
/**
* 切 Tab已在壳页 → 交由壳切面板(不重建、不重复请求);
* 不在壳页(子页 / 登录页 / 冷启)→ 记待办并 reLaunch 回壳,由壳 onMounted 消费。
* extraQuery 形如 "?seg=mine",由目标面板 onPanelShow 取用。
*/
export const switchTabTo = (key, extraQuery = "") => {
const map = {
home: ROUTES.HOME,
benefit: ROUTES.BENEFIT,
mine: ROUTES.MINE,
};
const url = `${map[key] || ROUTES.HOME}${extraQuery}`;
if (!TAB_KEYS.includes(key)) return;
const pages = getCurrentPages();
const top = pages.length ? `/${pages[pages.length - 1].route}` : "";
if (top === url.split("?")[0]) return;
uni.redirectTo({ url });
if (top === ROUTES.HOME && tabCtl.go) {
tabCtl.go(key, extraQuery);
return;
}
pendingTab = { key, query: extraQuery };
uni.reLaunch({ url: ROUTES.HOME });
};
/** 跳板块列表页 */

View File

@@ -1,6 +1,9 @@
<script setup>
/**
* Tab 2 · 权益
* Tab 2 · 权益面板
* 由主包壳页面 pages/home/index.vue 承载与主页 / 我的同屏共存 Tab 只切可见性
* 生命周期组件里没有页面级 onShow改由壳调 onPanelShow()切到本面板 + 页面 onShow
*
* 分段一权益中心页头 27px 渐变标题 + 数据行 + 白底分段控件 + 类别筛选 + 权益卡列表
* 分段二我的权益状态子分段全部 / 可使用 / 已失效+ 同款权益卡点卡即使用确认弹窗
*
@@ -11,11 +14,10 @@
* 领取传 benefit_id公开模板 id使用传 user_benefit_id已领取实例 id写操作由接口层带幂等键
*/
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { BENEFIT_SUB_TABS, BENEFIT_TYPES, ROUTES } from "@/constant";
import { getAccessToken } from "@/constant/token";
import { useBenefitStore } from "@/store";
import { goLogin, requireLogin } from "@/utils/nav";
import { consumeTabQuery, goLogin, requireLogin } from "@/utils/nav";
/** 接口不可领原因 → 提示文案login_required / phone_required 走登录与绑定引导,不在此列) */
const CLAIM_REASON_TEXT = {
@@ -55,21 +57,28 @@ const subTabs = computed(() =>
BENEFIT_SUB_TABS.map((t) => ({ ...t, count: counts.value[t.key] ?? 0 }))
);
onLoad((options) => {
if (options?.seg === "mine") seg.value = "mine";
});
onShow(() => {
/** 壳在「切到本面板」与「页面 onShow」时调 */
const onPanelShow = () => {
// seg=mine nav query
// 退
const query = consumeTabQuery();
if (getAccessToken()) {
if (/seg=mine/.test(query)) seg.value = "mine";
} else if (seg.value === "mine") {
seg.value = "center";
}
// forcestore Tab /
// 使 force
store.loadAll();
});
};
defineExpose({ onPanelShow });
const onSegChange = (key) => {
// ****退
// redirect seg
// redirect tab/seg
if (key === "mine" && !getAccessToken()) {
goLogin(`${ROUTES.BENEFIT}?seg=mine`);
goLogin(`${ROUTES.HOME}?tab=benefit&seg=mine`);
return;
}
seg.value = key;
@@ -246,7 +255,6 @@ const onConfirmUse = async () => {
<!-- 底部留白 = TabBar 78px + 18px 间隔避免最后一条内容贴住 TabBar -->
<view class="h-[96px]" />
</scroll-view>
<AppTabBar active="benefit" />
<!-- 使用弹窗确认 / 成功 / 失败 三态 -->
<BenefitDialog :visible="dialogVisible" :benefit="activeBenefit" :state="dialogState" :used-at="usedAt"

179
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,179 @@
<script setup>
/**
* Tab 1 · 主页(面板)
* 由主包壳页面 pages/home/index.vue 承载,与权益 / 我的同屏共存;
* 切 Tab 只切可见性,本组件不重建、不重复请求。
* 生命周期:不能再用页面级 onShow组件里没有改为壳在「显示本面板」与
* 「页面 onShow」时调用 onPanelShow();首次加载由壳的 onMounted 派发。
*
* 数据来自 GET /v1/homepage。
* 纵向顺序:
* 品牌开场区(吸顶固定)→ 主视觉大卡180px→ 玩转龙里(左一通高 + 右二上下)
* → 全域导览 → 精选推荐 → 免责声明
* 无顶栏、无搜索、不展示任何权益入口。
*/
import { computed, ref } from "vue";
import contentApi from "@/api/content";
import { goCatalog, previewImages } from "@/utils/nav";
const home = ref({});
const featured = ref([]);
const brandName = computed(() => home.value?.brand_name || "");
/** 品牌名末两字走品牌色(「发现龙里」→ 发现 + 龙里) */
const brandHead = computed(() => brandName.value.slice(0, -2));
const brandTail = computed(() => brandName.value.slice(-2));
const brand = computed(() => home.value?.brand || {});
const hero = computed(() => home.value?.hero || {});
const entries = computed(() => home.value?.entries || []);
const footerLines = computed(() => home.value?.footer_lines || []);
/** 玩转龙里:有板块数据才展示整块 */
const hasSections = computed(() => entries.value.length > 0);
/** 全域导览:有图或有标题才展示整块 */
const hasGuide = computed(() => !!(home.value?.guide_image_url || home.value?.guide_title));
/** 精选推荐:有卡片才展示整块(含标题行与「更多」) */
const hasFeatured = computed(() => featured.value.length > 0);
const entryOf = (key) => entries.value.find((e) => e.section_key === key) || {};
let loading = false;
const load = async () => {
if (loading) return;
loading = true;
try {
const data = await contentApi.getHomepage();
home.value = data || {};
featured.value = data?.featured?.items || [];
} catch (e) {
home.value = {};
featured.value = [];
} finally {
loading = false;
}
};
/** 壳在「切到本面板」与「页面 onShow」时调已有数据不重拉 */
const onPanelShow = () => {
if (!home.value?.brand_name) load();
};
// 三块面板在壳里同时挂载,故这里**不自动加载**(否则一进 App 就连打三个接口);
// 首次加载由壳的 onMounted 派发到当前面板。
defineExpose({ onPanelShow });
/** 主视觉大卡「开始探索」/ 精选「更多」→ hero 所属板块 */
const onStartExplore = () => goCatalog(hero.value.section_key);
const onMoreFeatured = () => goCatalog(hero.value.section_key);
/** 导览图放大(接口已给图,不做独立页) */
const onMapTap = () => {
const url = home.value?.guide_image_url;
if (!url) return;
previewImages([url], url);
};
</script>
<template>
<view class="relative h-screen w-full flex flex-col skin-home">
<view class="relative z-[60] shrink-0">
<view class="px-4 pb-[10px]">
<AppStatusBar />
<view class="flex items-center gap-[7px] mt-[6px] text-[10px] font-bold tracking-[2.4px] text-brand-deep">
<view class="w-[5px] h-[5px] rounded-full bg-brand shadow-[0_0_0_4px_rgba(0,181,120,0.14)]" />
<text>{{ brand.sub_name }}</text>
</view>
<view class="mt-[9px] text-display font-bold tracking-[-1px] leading-[1.1] text-ink">
{{ brandHead }}<text class="not-italic gradtext-brand">{{ brandTail }}</text>
</view>
<view class="mt-2 text-[11.5px] leading-[1.55] text-ink2 tracking-[0.1px]">
{{ brand.slogan }}
</view>
</view>
</view>
<scroll-view class="flex-1 min-h-0" scroll-y :show-scrollbar="false">
<view class="relative z-[1] px-4">
<!-- 主视觉大卡距固定区仅 2px贴紧品牌区让首屏更紧凑 -->
<view class="relative h-[180px] mt-[2px] rounded-hero overflow-hidden shadow-hero" @click="onStartExplore">
<image class="absolute inset-0 w-full h-full" :src="hero.image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-hero" />
<view
class="pointer-events-none absolute inset-0 rounded-hero bg-[radial-gradient(210px_140px_at_98%_110%,rgba(63,223,168,0.44),transparent_74%)]" />
<!-- 白字加投影hero 图片是亮绿山景光靠遮罩中段不足以拉开对比text-shadow 可继承 -->
<view
class="absolute inset-0 z-[2] flex flex-col justify-end items-start px-5 pb-[17px] [text-shadow:0_1px_3px_rgba(0,0,0,0.5),0_2px_12px_rgba(0,0,0,0.42)]">
<view
class="flex items-center gap-[7px] text-[9.5px] font-semibold tracking-[1.8px] text-[rgba(255,255,255,0.76)]">
<view class="block w-[14px] h-px bg-brand-lite" />
<text>{{ hero.eyebrow }}</text>
</view>
<view class="mt-[6px] text-[26px] font-bold text-white tracking-[0.6px] leading-[1.15]">{{ hero.title }}
</view>
<view class="mt-[7px] text-[12px] text-[rgba(255,255,255,0.9)] tracking-[0.3px]">{{ hero.subtitle }}</view>
<view
class="mt-[13px] h-[34px] px-[15px] rounded-full bg-white inline-flex items-center gap-[5px] text-[13px] font-semibold text-brand-deep shadow-[0_10px_24px_rgba(3,26,20,0.28)] [text-shadow:none]">
<text>{{ hero.button_text }}</text>
<AppIcon name="arrow" tone="brandDeep" :size="14" />
</view>
</view>
</view>
<!-- 玩转龙里左一通高 + 右二上下无数据整块不展示 -->
<template v-if="hasSections">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">玩转龙里</text>
</view>
<view class="flex gap-[10px] h-[226px] mt-[2px]">
<SectionTile class="flex-[1.06] min-w-0" :section="entryOf('stay')" variant="tall" />
<view class="flex-1 min-w-0 flex flex-col gap-[10px]">
<SectionTile :section="entryOf('food')" variant="short" />
<SectionTile :section="entryOf('specialty')" variant="short" />
</view>
</view>
</template>
<!-- 全域导览无数据整块不展示 -->
<template v-if="hasGuide">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">全域导览</text>
</view>
<view class="relative h-[112px] rounded-tile overflow-hidden mt-[2px] shadow-elev2" @click="onMapTap">
<image class="absolute inset-0 w-full h-full" :src="home.guide_image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-map" />
<view
class="pointer-events-none absolute inset-0 rounded-tile bg-[radial-gradient(200px_130px_at_88%_112%,rgba(63,223,168,0.4),transparent_72%)]" />
<view class="absolute inset-0 z-[2] flex flex-col justify-center items-start px-[18px]">
<text class="text-white text-[16px] font-semibold">{{ home.guide_title }}</text>
<text class="text-[rgba(255,255,255,0.85)] text-hint mt-1">{{ home.guide_subtitle }}</text>
</view>
</view>
</template>
<!-- 精选推荐无卡片整块不展示卡片自身完成跳转页面不再重复绑定 tap -->
<template v-if="hasFeatured">
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">精选推荐</text>
<view class="flex items-center gap-[2px] text-[11.5px] text-ink3" @click="onMoreFeatured">
<text>更多</text>
<AppIcon name="chev" tone="ink3" :size="14" />
</view>
</view>
<view v-for="(item, i) in featured" :key="item.id" :class="i ? 'mt-[10px]' : ''">
<ContentCard :item="item" />
</view>
</template>
<view v-for="(line, i) in footerLines" :key="i"
:class="i ? 'mx-1 text-[10px] leading-[1.7] text-ink3 text-center' : 'mt-5 mx-1 text-[10px] leading-[1.7] text-ink3 text-center'">
{{ line }}
</view>
</view>
<!-- 底部留白 = TabBar 78px + 18px 间隔避免最后一条内容贴住 TabBar -->
<view class="h-[96px]" />
</scroll-view>
</view>
</template>

View File

@@ -1,12 +1,14 @@
<script setup>
/**
* Tab 3 · 我的
* Tab 3 · 我的面板
* 由主包壳页面 pages/home/index.vue 承载与主页 / 权益同屏共存 Tab 只切可见性
* 生命周期组件里没有页面级 onShow改由壳调 onPanelShow()切到本面板 + 页面 onShow
*
* 头部三态未登录 / 已登录未绑手机号 / 已登录已绑手机号手机号中间四位打码
* 功能列表未登录时照常可见点击即引导登录
* 列表用数组驱动渲染避免 7 段结构重复
*/
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { CUSTOMER_SERVICE_PHONE } from "@/constant";
import contentApi from "@/api/content";
import { useBenefitStore, useUserStore } from "@/store";
@@ -47,16 +49,22 @@ const avaCls = computed(() =>
: "w-[62px] h-[62px] rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-alt"
);
onShow(() => {
/** 壳在「切到本面板」与「页面 onShow」时调 */
const onPanelShow = () => {
userStore.restore();
//
userStore.syncMe();
// store
if (userStore.isLogin) benefitStore.loadMine();
contentApi.getCustomerService().then((res) => {
service.value = res;
});
});
// key
if (!service.value) {
contentApi.getCustomerService().then((res) => {
service.value = res;
});
}
};
defineExpose({ onPanelShow });
/** 头部点击:未登录 → 登录页;已登录未绑手机号 → 绑手机号抽屉;已绑定 → 修改昵称 */
const onHeaderTap = () => {
@@ -170,93 +178,94 @@ const saveNickname = async () => {
</script>
<template>
<view class="relative min-h-screen w-full overflow-hidden">
<view class="relative h-screen w-full flex flex-col overflow-hidden">
<AppStatusBar />
<!-- 头部 -->
<view class="bg-white px-5 pt-[6px] pb-[22px]">
<view class="text-[20px] font-semibold pt-[6px] pb-5 text-ink">我的</view>
<scroll-view scroll-y class="flex-1 min-h-0">
<!-- 头部 -->
<view class="bg-white px-5 pt-[6px] pb-[22px]">
<view class="text-[20px] font-semibold pt-[6px] pb-5 text-ink">我的</view>
<view class="flex items-center gap-[14px]">
<button
v-if="isLogin"
class="btn-reset w-[62px] h-[62px] shrink-0"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
<view :class="avaCls">
<image v-if="avatar" :src="avatar" mode="aspectFill" class="w-full h-full" />
<AppIcon v-else name="user" tone="brand" :size="30" />
</view>
</button>
<view v-else :class="avaCls" @click="onHeaderTap">
<AppIcon name="user" tone="ink3" :size="30" />
</view>
<view class="flex-1 min-w-0" @click="onHeaderTap">
<view class="text-[18px] font-semibold text-ink">{{ isLogin ? nickname : "点击登录" }}</view>
<view class="mt-[5px] flex items-center gap-[5px] text-cap text-ink2">
<template v-if="!isLogin">
<text>登录后可领取权益管理我的权益</text>
</template>
<template v-else-if="!userStore.phoneBound">
<text>绑定手机号</text>
<AppIcon name="chev" tone="ink3" :size="14" />
</template>
<template v-else>
<text>{{ maskedPhone || "手机号已绑定" }}</text>
</template>
</view>
</view>
<text
v-if="isLogin && userStore.phoneBound"
class="text-[10px] font-semibold px-[7px] py-[2px] rounded-full bg-brand-soft text-brand-deep"
>
已实名
</text>
<text
v-else-if="isLogin"
class="text-[10px] font-semibold px-[7px] py-[2px] rounded-full bg-[#F1F3F5] text-ink3"
>
未绑定
</text>
<AppIcon v-else name="chev" tone="ink3" :size="18" />
</view>
</view>
<!-- 功能列表分组渲染退出登录单独一组 -->
<view
v-for="group in groups"
:key="group.key"
class="mt-[14px] mx-[14px] bg-white rounded-card overflow-hidden border-[0.5px] border-line shadow-elev1"
>
<view
v-for="(cell, i) in group.cells"
:key="cell.key"
class="flex items-center gap-3 h-14 px-[15px]"
:class="i ? 'border-t-[0.5px] border-line' : ''"
@click="cell.onTap"
>
<view class="w-[30px] h-[30px] rounded-[9px] bg-alt shrink-0 flex items-center justify-center">
<AppIcon :name="cell.icon" tone="ink2" :size="18" />
</view>
<text class="flex-1 text-body text-ink">{{ cell.label }}</text>
<view class="flex items-center gap-[6px] text-cap text-ink3">
<text
v-if="cell.badge"
class="min-w-[18px] h-[18px] px-[5px] rounded-full bg-accent text-white text-hint font-semibold flex items-center justify-center"
<view class="flex items-center gap-[14px]">
<button
v-if="isLogin"
class="btn-reset w-[62px] h-[62px] shrink-0"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
{{ cell.badge }}
<view :class="avaCls">
<image v-if="avatar" :src="avatar" mode="aspectFill" class="w-full h-full" />
<AppIcon v-else name="user" tone="brand" :size="30" />
</view>
</button>
<view v-else :class="avaCls" @click="onHeaderTap">
<AppIcon name="user" tone="ink3" :size="30" />
</view>
<view class="flex-1 min-w-0" @click="onHeaderTap">
<view class="text-[18px] font-semibold text-ink">{{ isLogin ? nickname : "点击登录" }}</view>
<view class="mt-[5px] flex items-center gap-[5px] text-cap text-ink2">
<template v-if="!isLogin">
<text>登录后可领取权益管理我的权益</text>
</template>
<template v-else-if="!userStore.phoneBound">
<text>绑定手机号</text>
<AppIcon name="chev" tone="ink3" :size="14" />
</template>
<template v-else>
<text>{{ maskedPhone || "手机号已绑定" }}</text>
</template>
</view>
</view>
<text
v-if="isLogin && userStore.phoneBound"
class="text-[10px] font-semibold px-[7px] py-[2px] rounded-full bg-brand-soft text-brand-deep"
>
已实名
</text>
<text v-if="cell.text">{{ cell.text }}</text>
<AppIcon name="chev" tone="ink3" :size="14" />
<text
v-else-if="isLogin"
class="text-[10px] font-semibold px-[7px] py-[2px] rounded-full bg-[#F1F3F5] text-ink3"
>
未绑定
</text>
<AppIcon v-else name="chev" tone="ink3" :size="18" />
</view>
</view>
</view>
<view class="h-[78px]" />
<AppTabBar active="mine" />
<!-- 功能列表分组渲染退出登录单独一组 -->
<view
v-for="group in groups"
:key="group.key"
class="mt-[14px] mx-[14px] bg-white rounded-card overflow-hidden border-[0.5px] border-line shadow-elev1"
>
<view
v-for="(cell, i) in group.cells"
:key="cell.key"
class="flex items-center gap-3 h-14 px-[15px]"
:class="i ? 'border-t-[0.5px] border-line' : ''"
@click="cell.onTap"
>
<view class="w-[30px] h-[30px] rounded-[9px] bg-alt shrink-0 flex items-center justify-center">
<AppIcon :name="cell.icon" tone="ink2" :size="18" />
</view>
<text class="flex-1 text-body text-ink">{{ cell.label }}</text>
<view class="flex items-center gap-[6px] text-cap text-ink3">
<text
v-if="cell.badge"
class="min-w-[18px] h-[18px] px-[5px] rounded-full bg-accent text-white text-hint font-semibold flex items-center justify-center"
>
{{ cell.badge }}
</text>
<text v-if="cell.text">{{ cell.text }}</text>
<AppIcon name="chev" tone="ink3" :size="14" />
</view>
</view>
</view>
<view class="h-[78px]" />
</scroll-view>
<!-- 绑定手机号底部抽屉 -->
<view