feat: tab的调整
This commit is contained in:
1 parent
9475a3cc3a
commit
f93b9f034f
8 files changed
+439
-300
No files matched your search
@@ -0,0 +1,263 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Tab 2 · 权益(面板)
|
||||
* 由主包壳页面 pages/home/index.vue 承载,与主页 / 我的同屏共存;切 Tab 只切可见性。
|
||||
* 生命周期:组件里没有页面级 onShow,改由壳调 onPanelShow()(切到本面板 + 页面 onShow)。
|
||||
*
|
||||
* 分段一「权益中心」:页头 27px 渐变标题 + 数据行 + 白底分段控件 + 类别筛选 + 权益卡列表
|
||||
* 分段二「我的权益」:状态子分段(全部 / 可使用 / 已失效)+ 同款权益卡(点卡即使用确认弹窗)
|
||||
*
|
||||
* 布局:页头(标题 / 数据行 / 分段控件)与分类栏合并为一个吸顶块,随页面滚动固定不动,
|
||||
* 只有下方权益卡列表滚动。页头三块不随 seg 变化。
|
||||
*
|
||||
* 核心口径:权益不绑定具体商品、可多次使用。
|
||||
* 领取传 benefit_id(公开模板 id),使用传 user_benefit_id(已领取实例 id),写操作由接口层带幂等键。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { BENEFIT_SUB_TABS, BENEFIT_TYPES, ROUTES } from "@/constant";
|
||||
import { getAccessToken } from "@/constant/token";
|
||||
import { useBenefitStore } from "@/store";
|
||||
import { consumeTabQuery, goLogin, requireLogin } from "@/utils/nav";
|
||||
|
||||
/** 接口不可领原因 → 提示文案(login_required / phone_required 走登录与绑定引导,不在此列) */
|
||||
const CLAIM_REASON_TEXT = {
|
||||
already_claimed: "该权益已领取,可在「我的权益」使用",
|
||||
claim_limit_reached: "该权益今日名额已发完",
|
||||
claim_ended: "该权益领取已结束",
|
||||
benefit_expired: "该权益已失效",
|
||||
};
|
||||
|
||||
/** 接口不可用原因 → 提示文案 */
|
||||
const USE_REASON_TEXT = {
|
||||
not_started: "该权益尚未生效",
|
||||
expired: "该权益已失效",
|
||||
usage_limit_reached: "该权益使用次数已用尽",
|
||||
voided: "该权益已作废",
|
||||
};
|
||||
|
||||
const store = useBenefitStore();
|
||||
|
||||
const seg = ref("center");
|
||||
const dialogVisible = ref(false);
|
||||
const activeBenefit = ref(null);
|
||||
/** 使用弹窗三态:confirm 确认使用 → success 成功(带权益信息)/ fail 失败(仅一行说明) */
|
||||
const dialogState = ref("confirm");
|
||||
/** 本次使用时间(接口 used_at,RFC3339) */
|
||||
const usedAt = ref("");
|
||||
/** 失败说明(接口业务 message;为空走统一兜底文案) */
|
||||
const failText = ref("");
|
||||
/** 提交中:确认按钮防重复点击 */
|
||||
const submitting = ref(false);
|
||||
|
||||
const centerList = computed(() => store.filteredCenter);
|
||||
const mineList = computed(() => store.filteredMine);
|
||||
const counts = computed(() => store.counts);
|
||||
|
||||
const subTabs = computed(() =>
|
||||
BENEFIT_SUB_TABS.map((t) => ({ ...t, count: counts.value[t.key] ?? 0 }))
|
||||
);
|
||||
|
||||
/** 壳在「切到本面板」与「页面 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";
|
||||
}
|
||||
// 不传 force:store 内按缓存标记短路,切 Tab / 从详情页返回不再重拉
|
||||
// 领取与使用内部会显式 force 刷新,数据不会脏
|
||||
store.loadAll();
|
||||
};
|
||||
|
||||
defineExpose({ onPanelShow });
|
||||
|
||||
const onSegChange = (key) => {
|
||||
// 我的权益需登录:未登录直接跳登录页且**不切分段**(用户没登录就退回权益中心),
|
||||
// redirect 带 tab/seg,登录成功后回壳并落到「我的权益」并拉数据
|
||||
if (key === "mine" && !getAccessToken()) {
|
||||
goLogin(`${ROUTES.HOME}?tab=benefit&seg=mine`);
|
||||
return;
|
||||
}
|
||||
seg.value = key;
|
||||
if (key !== "mine") return;
|
||||
store.loadMine();
|
||||
};
|
||||
|
||||
/** 权益中心:领取(入参是公开模板 id;不可领的原因由接口 claim_unavailable_reason 给出) */
|
||||
const onClaim = async (benefit) => {
|
||||
// 游客 / 未绑手机号 → 走登录与绑定引导;其余不可领原因直接提示
|
||||
if (benefit.can_claim === false && !["login_required", "phone_required"].includes(benefit.reason)) {
|
||||
uni.showToast({ title: CLAIM_REASON_TEXT[benefit.reason] || "该权益暂不可领取", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!requireLogin()) return;
|
||||
await store.claim(benefit.id);
|
||||
uni.showToast({ title: "领取成功", icon: "none" });
|
||||
setTimeout(() => {
|
||||
seg.value = "mine";
|
||||
}, 600);
|
||||
};
|
||||
|
||||
/** 接口错误 → 失败说明:只有后端给了业务 message 才用它,其余走兜底文案 */
|
||||
const failMessage = (e) => {
|
||||
const d = (e && e.data) || {};
|
||||
return d.message || d.msg || (d.error && d.error.message) || "";
|
||||
};
|
||||
|
||||
const resetDialog = () => {
|
||||
dialogState.value = "confirm";
|
||||
usedAt.value = "";
|
||||
failText.value = "";
|
||||
submitting.value = false;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false;
|
||||
resetDialog();
|
||||
};
|
||||
|
||||
/** 我的权益:点击卡片 → 使用确认弹窗(能否使用以接口 can_use 为准) */
|
||||
const onCardTap = (benefit) => {
|
||||
if (benefit.can_use === false) {
|
||||
uni.showToast({ title: USE_REASON_TEXT[benefit.reason] || "该权益暂不可使用", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!requireLogin()) return;
|
||||
resetDialog();
|
||||
activeBenefit.value = benefit;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
/** 确认使用:入参是 user_benefit_id(已领取实例 id),仅记录一次,权益保持可用 */
|
||||
const onConfirmUse = async () => {
|
||||
const target = activeBenefit.value;
|
||||
if (!target || submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await store.use(target.user_benefit_id || target.id);
|
||||
usedAt.value = (res && res.used_at) || new Date().toISOString();
|
||||
dialogState.value = "success";
|
||||
} catch (e) {
|
||||
failText.value = failMessage(e);
|
||||
dialogState.value = "fail";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="relative h-screen w-full flex flex-col skin-benefit">
|
||||
<!-- 固定区(页头 + 分类栏):**不给任何背景**,直接透出页面背景。
|
||||
页面背景 skin-benefit 铺在本根节点上(h-screen = 恰好一屏,不随内容滚动),
|
||||
固定区与下方滚动区共用同一张背景 —— 颜色天然一致。
|
||||
原因:任何不透明/半透明背景(哪怕取渐变顶端基色)都必然与「渐变 + 柔光晕」
|
||||
对不上,滚起来就会显出一块异色。
|
||||
滚动改由下方独立的 scroll-view 承担,内容永远不会进入本区域,所以无需遮挡。
|
||||
⚠️ 本区已在 flex 流里固定,**不要再加 sticky**(那会要求它必须有背景)。 -->
|
||||
<view class="relative z-[60] shrink-0">
|
||||
<!-- 页头三块(标题 / 数据行 / 分段控件)固定不变:切换分段只换下方内容,
|
||||
headline 只在 loadCenter 赋值,切「我的权益」不会被改写 -->
|
||||
<view class="px-[18px] pb-[13px]">
|
||||
<AppStatusBar />
|
||||
|
||||
<view class="mt-2 text-h1 font-bold text-ink tracking-[-0.9px]">
|
||||
权益<text class="not-italic gradtext-brand">中心</text>
|
||||
</view>
|
||||
|
||||
<view class="mt-[9px] flex items-center gap-[7px] text-[11.5px] text-ink2">
|
||||
<view class="w-[5px] h-[5px] rounded-full bg-brand shadow-[0_0_0_4px_rgba(0,181,120,0.14)]" />
|
||||
<text>{{ store.headline }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="mt-5 flex rounded-full p-[3px] bg-white border-[0.5px] border-[rgba(255,255,255,0.9)] shadow-[0_1px_2px_rgba(8,30,22,0.04),0_10px_24px_rgba(8,30,22,0.08)]">
|
||||
<view class="flex-1 h-[34px] rounded-full flex items-center justify-center text-[13px]"
|
||||
:class="seg === 'center' ? 'grad-brand text-white font-semibold shadow-[0_6px_16px_rgba(0,181,120,0.3)]' : 'text-ink2'"
|
||||
@click="onSegChange('center')">
|
||||
权益中心
|
||||
</view>
|
||||
<view class="flex-1 h-[34px] rounded-full flex items-center justify-center text-[13px]"
|
||||
:class="seg === 'mine' ? 'grad-brand text-white font-semibold shadow-[0_6px_16px_rgba(0,181,120,0.3)]' : 'text-ink2'"
|
||||
@click="onSegChange('mine')">
|
||||
我的权益
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分类栏(权益中心)/ 状态子分段(我的权益):同一位置互斥,紧贴页头、不留上间距 -->
|
||||
<!-- ⚠️ 四个坑:
|
||||
① scroll-x 必须开 enable-flex:WebView 下不开则节点上的 display:flex 不生效,
|
||||
直接子项会退化成块级竖排(官方文档:横向滚动需打开 enable-flex 以兼容 WebView)
|
||||
② 不要在 scroll-view 上用 gap-*:小程序里 flex 容器的 gap 实测不生效,
|
||||
改用子项 margin-right(100% 可靠)
|
||||
③ **必须给 scroll-view 显式高度 h-7**(与 chip 同用 rpx 刻度):横向 scroll-view
|
||||
的内部滚动层高度会比 chip 高出一截(实测约 16px),这截空白出在组件内部、
|
||||
不在任何 margin 上 —— 页面上看就是「分类栏底部莫名有一大段间隔」,调下方
|
||||
容器的负 margin 怎么调都调不干净。用 h-7 锁高后,只裁掉内部层「底部」那截空白。
|
||||
④ **配套绝不能加 items-center**:默认 align-items:stretch 时裁的是内部层底部空白,
|
||||
chips 完整;一旦改成 items-center,内部层转为垂直居中 → **上下对称裁切**,
|
||||
chips 会被削掉顶和底。另配 text-[0px](font-size:0)把内部层可能存在的行盒
|
||||
高度一并消掉。⚠️ chip 自己带 text-[12px],不会被 text-[0px] 连累。
|
||||
(写法注意:**单位不能省** —— 只写数字不带 px 会被 Tailwind 当成颜色值,
|
||||
生成 `color:0` 这条无效声明,必须写成 text-[0px]。) -->
|
||||
<scroll-view v-if="seg === 'center'" class="flex flex-row text-[0px] h-8 px-[14px] overflow-hidden" scroll-x
|
||||
enable-flex :show-scrollbar="false">
|
||||
<view v-for="type in BENEFIT_TYPES" :key="type.key"
|
||||
class="shrink-0 h-7 mr-[7px] px-[13px] rounded-full flex items-center text-[12px] border-[0.5px] border-[rgba(20,24,28,0.04)]"
|
||||
:class="store.filterType === type.key ? 'bg-brand-soft text-brand-deep font-semibold' : 'bg-alt text-ink2'"
|
||||
@click="store.setFilterType(type.key)">
|
||||
{{ type.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-else class="flex gap-[18px] px-[18px]">
|
||||
<view v-for="tab in subTabs" :key="tab.key" class="relative pt-2 pb-[11px] text-[13.5px]"
|
||||
:class="store.subTab === tab.key ? 'text-ink font-semibold deco-underline' : 'text-ink2'"
|
||||
@click="store.setSubTab(tab.key)">
|
||||
{{ tab.label }} {{ tab.count }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 滚动区:**独立滚动容器**(不是页面滚动),高度 = 一屏 − 固定区,
|
||||
这样固定区与滚动区共用根节点上那张静态背景,头尾颜色完全一致。
|
||||
末尾 h-[96px] 留白是为 fixed 定位的 TabBar 让位(TabBar 高 78px + 18px 间隔)。 -->
|
||||
<scroll-view class="flex-1 min-h-0" scroll-y :show-scrollbar="false">
|
||||
<!-- 分段一 · 权益中心 -->
|
||||
<template v-if="seg === 'center'">
|
||||
<!-- -mt-[7px] 抵掉 BenefitCard 自带 mt-[11px]:分类栏下方间距 11px → **4px** -->
|
||||
<view class="px-[14px] -mt-[7px]">
|
||||
<BenefitCard v-for="item in centerList" :key="item.id" :benefit="item" mode="center" @action="onClaim" />
|
||||
</view>
|
||||
<EmptyBox v-if="!centerList.length" text="该类别暂无可用权益" />
|
||||
</template>
|
||||
|
||||
<!-- 分段二 · 我的权益 -->
|
||||
<template v-else>
|
||||
<view class="px-[14px]">
|
||||
<BenefitCard v-for="item in mineList" :key="item.id" :benefit="item" mode="mine" @action="onCardTap" />
|
||||
</view>
|
||||
<EmptyBox v-if="!mineList.length" text="还没有领取权益" :show-icon="false">
|
||||
<view class="mt-6 flex justify-center">
|
||||
<view
|
||||
class="h-9 px-[18px] rounded-full flex items-center border-[0.5px] border-brand text-cap font-semibold text-brand-deep"
|
||||
@click="onSegChange('center')">
|
||||
去权益中心看看
|
||||
</view>
|
||||
</view>
|
||||
</EmptyBox>
|
||||
</template>
|
||||
|
||||
<!-- 底部留白 = TabBar 高 78px + 18px 间隔,避免最后一条内容贴住 TabBar -->
|
||||
<view class="h-[96px]" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 使用弹窗:确认 / 成功 / 失败 三态 -->
|
||||
<BenefitDialog :visible="dialogVisible" :benefit="activeBenefit" :state="dialogState" :used-at="usedAt"
|
||||
:fail-text="failText" :submitting="submitting" @close="closeDialog" @confirm="onConfirmUse" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Tab 3 · 我的(面板)
|
||||
* 由主包壳页面 pages/home/index.vue 承载,与主页 / 权益同屏共存;切 Tab 只切可见性。
|
||||
* 生命周期:组件里没有页面级 onShow,改由壳调 onPanelShow()(切到本面板 + 页面 onShow)。
|
||||
*
|
||||
* 头部三态:未登录 / 已登录未绑手机号 / 已登录已绑手机号(手机号中间四位打码)
|
||||
* 功能列表未登录时照常可见(点击即引导登录)。
|
||||
* 列表用数组驱动渲染,避免 7 段结构重复。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { CUSTOMER_SERVICE_PHONE } from "@/constant";
|
||||
import contentApi from "@/api/content";
|
||||
import { useBenefitStore, useUserStore } from "@/store";
|
||||
import { maskPhone } from "@/utils";
|
||||
import {
|
||||
goAbout,
|
||||
goAccountCancel,
|
||||
goAgreement,
|
||||
goLogin,
|
||||
makeCall,
|
||||
requireLogin,
|
||||
switchTabTo,
|
||||
} from "@/utils/nav";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const benefitStore = useBenefitStore();
|
||||
|
||||
const bindVisible = ref(false);
|
||||
const binding = ref(false);
|
||||
const avatarUploading = ref(false);
|
||||
const logoutVisible = ref(false);
|
||||
const nickVisible = ref(false);
|
||||
const nickInput = ref("");
|
||||
/** 客服信息(公共内容接口;取不到回落本地常量) */
|
||||
const service = ref(null);
|
||||
|
||||
const isLogin = computed(() => userStore.isLogin);
|
||||
const nickname = computed(() => userStore.nickName || "微信用户");
|
||||
const avatar = computed(() => userStore.avatarUrl);
|
||||
const maskedPhone = computed(() => maskPhone(userStore.phone));
|
||||
const usableCount = computed(() => benefitStore.usableCount);
|
||||
const servicePhone = computed(() => service.value?.phone || CUSTOMER_SERVICE_PHONE);
|
||||
|
||||
/** 头像圆底:已登录用品牌浅底,未登录用中性灰底 */
|
||||
const avaCls = computed(() =>
|
||||
isLogin.value
|
||||
? "w-[62px] h-[62px] rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-brand-soft"
|
||||
: "w-[62px] h-[62px] rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-alt"
|
||||
);
|
||||
|
||||
/** 壳在「切到本面板」与「页面 onShow」时调 */
|
||||
const onPanelShow = () => {
|
||||
userStore.restore();
|
||||
// 资料以服务端为准(幂等:本会话已同步过则复用本地态,不再打接口)
|
||||
userStore.syncMe();
|
||||
// 未登录时「我的权益」必为空,不发请求;已登录才拉(store 内同样有缓存守卫)
|
||||
if (userStore.isLogin) benefitStore.loadMine();
|
||||
// 面板常驻,客服号只在首次进入时取一次(接口缺该 key 时失败不缓存,故用本地态挡重复请求)
|
||||
if (!service.value) {
|
||||
contentApi.getCustomerService().then((res) => {
|
||||
service.value = res;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ onPanelShow });
|
||||
|
||||
/** 头部点击:未登录 → 登录页;已登录未绑手机号 → 绑手机号抽屉;已绑定 → 修改昵称 */
|
||||
const onHeaderTap = () => {
|
||||
if (!isLogin.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!userStore.phoneBound) {
|
||||
bindVisible.value = true;
|
||||
return;
|
||||
}
|
||||
nickInput.value = userStore.nickName || "";
|
||||
nickVisible.value = true;
|
||||
};
|
||||
|
||||
const openMyBenefit = () => switchTabTo("benefit", "?seg=mine");
|
||||
|
||||
const onMyBenefit = () => {
|
||||
if (!requireLogin()) return;
|
||||
openMyBenefit();
|
||||
};
|
||||
|
||||
const onContactService = () => makeCall(servicePhone.value);
|
||||
|
||||
const onLogoutTap = () => {
|
||||
logoutVisible.value = true;
|
||||
};
|
||||
|
||||
const doLogout = async () => {
|
||||
logoutVisible.value = false;
|
||||
await userStore.logout();
|
||||
benefitStore.reset();
|
||||
uni.showToast({ title: "已退出登录", icon: "none" });
|
||||
};
|
||||
|
||||
/** 主功能列表:5 项固定,「账户注销」仅登录后出现 */
|
||||
const cells = computed(() => {
|
||||
const list = [
|
||||
{ key: "benefit", icon: "ticket", label: "我的权益", badge: isLogin.value ? usableCount.value : 0, onTap: onMyBenefit },
|
||||
{ key: "privacy", icon: "shield", label: "隐私政策", onTap: () => goAgreement("privacy") },
|
||||
{ key: "terms", icon: "doc", label: "用户协议", onTap: () => goAgreement("terms") },
|
||||
{ key: "about", icon: "info", label: "关于我们", onTap: goAbout },
|
||||
{ key: "service", icon: "headset", label: "联系客服", text: servicePhone.value, onTap: onContactService },
|
||||
];
|
||||
if (isLogin.value) list.push({ key: "cancel", icon: "trash", label: "账户注销", onTap: goAccountCancel });
|
||||
return list;
|
||||
});
|
||||
|
||||
/** 分组渲染:主列表一组,退出登录单独成组(仅登录后出现) */
|
||||
const groups = computed(() => {
|
||||
const list = [{ key: "main", cells: cells.value }];
|
||||
if (isLogin.value) {
|
||||
list.push({
|
||||
key: "logout",
|
||||
cells: [{ key: "logout", icon: "logout", label: "退出登录", onTap: onLogoutTap }],
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
/**
|
||||
* 微信一键绑定手机号:getPhoneNumber 回调的 code 交给服务端换手机号
|
||||
* POST /v1/me/phone { code } → UserView
|
||||
*/
|
||||
const onGetPhone = async (e) => {
|
||||
const code = e?.detail?.code || "";
|
||||
if (!code) {
|
||||
// 用户拒绝授权 / 平台未下发 code
|
||||
uni.showToast({ title: "已取消授权", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (binding.value) return;
|
||||
binding.value = true;
|
||||
try {
|
||||
await userStore.bindPhone(code);
|
||||
bindVisible.value = false;
|
||||
uni.showToast({ title: "绑定成功", icon: "none" });
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || "绑定失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 选择微信头像:先上传到服务端拿 image_url,再写入资料 */
|
||||
const onChooseAvatar = async (e) => {
|
||||
const url = e?.detail?.avatarUrl;
|
||||
if (!url) return;
|
||||
if (avatarUploading.value) return;
|
||||
avatarUploading.value = true;
|
||||
try {
|
||||
await userStore.uploadAvatar(url);
|
||||
uni.showToast({ title: "头像已更新", icon: "none" });
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || "头像更新失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
avatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveNickname = async () => {
|
||||
const name = (nickInput.value || "").trim();
|
||||
if (!name) {
|
||||
uni.showToast({ title: "昵称不能为空", icon: "none" });
|
||||
return;
|
||||
}
|
||||
await userStore.updateProfile({ nickName: name });
|
||||
nickVisible.value = false;
|
||||
uni.showToast({ title: "已保存", icon: "none" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="relative h-screen w-full flex flex-col overflow-hidden">
|
||||
<AppStatusBar />
|
||||
|
||||
<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"
|
||||
>
|
||||
{{ 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
|
||||
v-if="bindVisible"
|
||||
class="fixed inset-0 z-[200] flex items-end bg-[rgba(3,20,15,0.52)] backdrop-blur-[7px]"
|
||||
@click="bindVisible = false"
|
||||
>
|
||||
<view class="w-full bg-white rounded-t-dialog pt-6 px-[22px] pb-[30px] text-center shadow-sheet" @click.stop>
|
||||
<view class="text-title font-semibold text-ink">绑定手机号</view>
|
||||
<view class="mt-[10px] text-cap leading-[1.7] text-ink2">用于领取权益、使用与账号找回</view>
|
||||
<view
|
||||
class="mt-4 h-[52px] rounded-[16px] bg-alt flex items-center justify-center text-[13px] text-ink2"
|
||||
>
|
||||
点下方按钮,授权微信手机号
|
||||
</view>
|
||||
<button
|
||||
class="btn-reset mt-[14px] h-[50px] rounded-full grad-brand text-white flex items-center justify-center text-[16px] font-semibold shadow-glowbrand"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="onGetPhone"
|
||||
>
|
||||
微信一键绑定
|
||||
</button>
|
||||
<view class="mt-[6px] h-11 flex items-center justify-center text-[16px] text-ink3" @click="bindVisible = false">
|
||||
稍后再说
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 修改昵称 -->
|
||||
<AppDialog
|
||||
:visible="nickVisible"
|
||||
title="修改昵称"
|
||||
icon="user"
|
||||
confirm-text="保存"
|
||||
@close="nickVisible = false"
|
||||
@confirm="saveNickname"
|
||||
>
|
||||
<view class="mt-4 h-12 px-[14px] rounded-[14px] bg-alt border-[0.5px] border-line2 flex items-center">
|
||||
<input
|
||||
v-model="nickInput"
|
||||
class="flex-1 text-body text-ink"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
placeholder-class="text-ink3"
|
||||
/>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 退出登录二次确认 -->
|
||||
<AppDialog
|
||||
:visible="logoutVisible"
|
||||
title="退出当前账号?"
|
||||
icon="logout"
|
||||
confirm-text="退出登录"
|
||||
cancel-text="取消"
|
||||
@close="logoutVisible = false"
|
||||
@confirm="doLogout"
|
||||
>
|
||||
<view class="mt-3 text-[11px] leading-[1.7] text-ink3 text-center">
|
||||
退出后需重新登录才能领取与使用权益,已领取的权益不会丢失。
|
||||
</view>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
Reference in new issue
Block a user