174 lines
7.6 KiB
Vue
174 lines
7.6 KiB
Vue
<script setup>
|
||
/**
|
||
* 权益卡(全站唯一组件 · 4 处复用)
|
||
* 使用位置:权益中心 / 我的权益 / 详情页「到店权益」
|
||
* 四行固定顺序:类型 / 价格 / 规格·适用 / 有效期·名额(v11 价格表达)
|
||
* 所有权益元素一律票根橙,不按类型分色;灰化态与 cta 态走「互斥类串」,不复制结构。
|
||
*/
|
||
import { computed } from "vue";
|
||
import { BENEFIT_STATUS, BENEFIT_TYPE_ICON, BENEFIT_TYPE_LABEL } from "@/constant";
|
||
import { discountLabel, dotDate, priceText } from "@/utils";
|
||
|
||
const props = defineProps({
|
||
benefit: { type: Object, required: true },
|
||
/** center 权益中心 | mine 我的权益 | detail 详情页到店权益 */
|
||
mode: { type: String, default: "center" },
|
||
});
|
||
|
||
const emit = defineEmits(["action", "tap"]);
|
||
|
||
const b = computed(() => props.benefit || {});
|
||
const status = computed(() => b.value.status);
|
||
|
||
const isOff = computed(
|
||
() => status.value === BENEFIT_STATUS.EXPIRED || status.value === BENEFIT_STATUS.SOLD_OUT
|
||
);
|
||
const isClaimed = computed(() => status.value === BENEFIT_STATUS.CLAIMED);
|
||
|
||
const typeLabel = computed(() => BENEFIT_TYPE_LABEL[b.value.type] || "权益");
|
||
const typeIcon = computed(() => BENEFIT_TYPE_ICON[b.value.type] || "gift");
|
||
|
||
const deal = computed(() => priceText(b.value.price?.deal));
|
||
const original = computed(() => priceText(b.value.price?.original));
|
||
const pct = computed(() => discountLabel(b.value.price?.deal, b.value.price?.original));
|
||
const spec = computed(() => b.value.price?.spec || "");
|
||
const hasPrice = computed(() => !!deal.value);
|
||
|
||
const slogan = computed(() =>
|
||
Array.isArray(b.value.slogan) ? b.value.slogan : b.value.slogan ? [b.value.slogan] : []
|
||
);
|
||
|
||
const validText = computed(() => {
|
||
const { valid_to: to, valid_days: days } = b.value;
|
||
if ((isClaimed.value || status.value === BENEFIT_STATUS.EXPIRED) && to) {
|
||
return `有效期至 ${dotDate(to)}`;
|
||
}
|
||
if (days) return `有效期:${days} 天`;
|
||
if (to) return `有效期至 ${dotDate(to)}`;
|
||
return "";
|
||
});
|
||
|
||
/** meta 行第二项:随状态变化 */
|
||
const secondMeta = computed(() => {
|
||
const left = Number(b.value.stock_left);
|
||
switch (status.value) {
|
||
case BENEFIT_STATUS.SOLD_OUT:
|
||
return { text: "今日名额已发完", icon: "people", ok: false };
|
||
case BENEFIT_STATUS.EXPIRED:
|
||
return { text: "已失效", icon: "clock", ok: false };
|
||
case BENEFIT_STATUS.CLAIMED:
|
||
return { text: "可多次使用", icon: "check", ok: true };
|
||
default:
|
||
if (!Number.isNaN(left) && left > 0 && left <= 20) {
|
||
return { text: `仅剩 ${left} 个名额`, icon: "people", ok: false };
|
||
}
|
||
return { text: "剩余名额充足", icon: "people", ok: true };
|
||
}
|
||
});
|
||
|
||
const ctaText = computed(() => {
|
||
if (status.value === BENEFIT_STATUS.SOLD_OUT) return "已抢光";
|
||
if (status.value === BENEFIT_STATUS.EXPIRED) return "已失效";
|
||
if (isClaimed.value) return "去使用";
|
||
return "立即领取";
|
||
});
|
||
|
||
/* ---- 灰化态互斥类串(同属性只出现其一,不依赖 CSS 顺序) ---- */
|
||
const rootStateCls = computed(() =>
|
||
isOff.value ? "border-line2 shadow-elev1" : "border-[rgba(255,138,61,0.3)] shadow-ccard"
|
||
);
|
||
const sideStateCls = computed(() => (isOff.value ? "bg-[#F8FAFB]" : "grad-cardside"));
|
||
const kindStateCls = computed(() => (isOff.value ? "text-ink3" : "text-accent"));
|
||
const kindIconCls = computed(() =>
|
||
isOff.value ? "bg-[#BFC7CE]" : "grad-accent shadow-[0_4px_10px_rgba(255,138,61,0.34)]"
|
||
);
|
||
const priceNumCls = computed(() => (isOff.value ? "text-[#A7B0B8]" : "gradtext-accent"));
|
||
const wasCls = computed(() => (isOff.value ? "text-[#B6BEC5]" : "text-ink3"));
|
||
const pctCls = computed(() =>
|
||
isOff.value ? "bg-[#EDF1F4] text-ink3" : "bg-[rgba(255,138,61,0.14)] text-accent-deep"
|
||
);
|
||
const specCls = computed(() =>
|
||
isOff.value
|
||
? "bg-[#EDF1F4] text-ink3 border-transparent"
|
||
: "bg-[rgba(255,138,61,0.13)] text-accent-deep border-[rgba(255,138,61,0.3)]"
|
||
);
|
||
const scopeCls = computed(() => (isOff.value ? "text-ink3" : "text-ink2"));
|
||
const coverImgCls = computed(() => (isOff.value ? "grayscale-[0.6] brightness-[0.96]" : ""));
|
||
const ctaStateCls = computed(() =>
|
||
isOff.value ? "bg-[#C7CED4]" : "grad-accent shadow-[0_8px_18px_rgba(255,138,61,0.4)]"
|
||
);
|
||
|
||
const onTap = () => {
|
||
emit("tap", b.value);
|
||
emit("action", b.value);
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<view class="relative flex h-[130px] bg-white border-[0.5px] rounded-card mt-[11px]" :class="rootStateCls"
|
||
hover-class="opacity-90" @click="onTap">
|
||
<!-- 左:信息区(四行固定顺序) -->
|
||
<view class="flex-1 min-w-0 rounded-l-card px-[14px] pt-[13px] pb-3 flex flex-col" :class="sideStateCls">
|
||
<!-- ① 类型 -->
|
||
<view class="flex items-center gap-[6px] text-[13px] font-semibold whitespace-nowrap" :class="kindStateCls">
|
||
<view class="w-[21px] h-[21px] rounded-full shrink-0 flex items-center justify-center" :class="kindIconCls">
|
||
<AppIcon :name="typeIcon" tone="white" :size="12.5" />
|
||
</view>
|
||
<text>{{ typeLabel }}</text>
|
||
</view>
|
||
|
||
<!-- ② 价格:优惠价 + 划线原价 + 折扣标(右端) -->
|
||
<view v-if="hasPrice" class="flex items-baseline gap-[6px] mt-2 leading-none whitespace-nowrap">
|
||
<text class="text-[15px] font-bold" :class="priceNumCls">¥</text>
|
||
<text class="text-display font-extrabold tracking-[-1px]" :class="priceNumCls">{{ deal }}</text>
|
||
<text v-if="original" class="text-[11.5px] font-medium line-through" :class="wasCls">¥{{ original }}</text>
|
||
<text v-if="pct" class="ml-auto text-[10px] font-bold tracking-[0.2px] rounded-[6px] px-[6px] py-[3px]"
|
||
:class="pctCls">
|
||
{{ pct }}
|
||
</text>
|
||
</view>
|
||
|
||
<!-- ③ 规格胶囊 + 适用范围 -->
|
||
<view class="flex items-center gap-[6px] mt-[7px] text-[11px] whitespace-nowrap" :class="scopeCls">
|
||
<text v-if="spec" class="shrink-0 text-hint font-semibold rounded-[6px] px-[6px] py-[2px] border-[0.5px]"
|
||
:class="specCls">
|
||
{{ spec }}
|
||
</text>
|
||
<view class="min-w-0 flex-1 line-clamp-1">{{ b.scope_desc || b.merchant_name || "" }}</view>
|
||
</view>
|
||
|
||
<!-- ④ 有效期 + 第二项随态 -->
|
||
<view class="flex items-center gap-2 mt-auto text-[10px] text-ink3">
|
||
<view v-if="validText" class="flex items-center gap-[3.5px] shrink-0 whitespace-nowrap">
|
||
<AppIcon name="clock" tone="ink3" :size="12" />
|
||
<text>{{ validText }}</text>
|
||
</view>
|
||
<view class="flex items-center gap-[3.5px] shrink-0 whitespace-nowrap"
|
||
:class="secondMeta.ok ? 'text-[#00A46C]' : ''">
|
||
<AppIcon :name="secondMeta.icon" :tone="secondMeta.ok ? 'brand' : 'ink3'" :size="12" />
|
||
<text>{{ secondMeta.text }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 右:配图区(62%/38% 撕口) -->
|
||
<view
|
||
class="relative w-[38%] min-w-[112px] shrink-0 overflow-hidden rounded-r-card border-l border-dashed border-[rgba(255,138,61,0.45)]">
|
||
<image class="absolute inset-0 w-full h-full" :class="coverImgCls" :src="b.image_url" mode="aspectFill" />
|
||
<view class="absolute inset-0 scrim-cover" />
|
||
|
||
<view
|
||
class="absolute top-[11px] right-[11px] z-[2] text-right text-[11.5px] font-bold leading-[1.5] text-white tracking-[0.6px] [rotate:-8deg] [text-shadow:0_1px_7px_rgba(0,0,0,0.5)]">
|
||
<text v-for="(line, i) in slogan" :key="i" class="block">{{ line }}</text>
|
||
</view>
|
||
|
||
<view class="absolute left-0 right-0 bottom-3 z-[2] flex justify-center">
|
||
<view class="h-[30px] px-4 rounded-full text-white text-cap font-semibold flex items-center whitespace-nowrap"
|
||
:class="ctaStateCls">
|
||
{{ ctaText }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|