264 lines
13 KiB
Vue
264 lines
13 KiB
Vue
<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>
|