Files
longli-mp/src/views/benefit/index.vue
T
2026-09-18 17:18:08 +08:00

264 lines
13 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.
<script setup>
/**
* Tab 2 · 权益(面板)
* 由主包壳页面 pages/index/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>