diff --git a/src/api/benefit.js b/src/api/benefit.js index 619d735..883f924 100644 --- a/src/api/benefit.js +++ b/src/api/benefit.js @@ -1,24 +1,19 @@ /** * 权益数据访问层(唯一数据入口) * --------------------------------------------------------------------------- - * 页面与 store 只调用本文件,不直接 import mock。 - * USE_MOCK_CONTENT = true → 读 src/mock/v1/*(已是 /v1 接口响应形态,无需再适配); - * false → 走 src/request/api/benefit.js 的真实 /v1 通道。 + * 页面与 store 只调用本文件,底层走真实 /v1 通道(src/request/api/benefit.js)。 * * 接口口径: * - 公开权益 `PublicBenefitView`:status 恒为 published,用户领取态由 - * `already_claimed` / `can_claim` / `claim_unavailable_reason` / `user_benefit_id` 表达。 + * `already_claimed` / `can_claim` / `claim_unavailable_reason` / `user_benefit_id` 表达; + * 折扣在 `price.offer_label`(如「8折」),前缀在 `price.basis`(如「门市价」)。 * - 已领取实例 `UserBenefitView`:status = available | pending | expired | exhausted | voided, * `can_use` 直接驱动使用按钮;列表另带 `counts{all, available, invalid}`。 * - 领取用 benefit_id(模板 id),使用用 user_benefit_id(实例 id)。 * 出参统一转成卡片模型:四态 status + stock_left + reason, * 页面与组件只认这一套字段。 */ -import { USE_MOCK_CONTENT } from "@/config/env"; -import { BENEFIT_STATUS, STORAGE_KEYS } from "@/constant"; -import { clone } from "@/utils"; -import benefitsMock from "@/mock/v1/benefits.json"; -import meBenefitsMock from "@/mock/v1/me-benefits.json"; +import { BENEFIT_STATUS } from "@/constant"; import * as benefitApi from "@/request/api/benefit"; /* ---------------- 接口态 → 卡片态 ---------------- */ @@ -70,99 +65,13 @@ const toMineCard = (u) => ({ stock_left: null, }); -/* ---------------- mock 阶段:本地领取 / 使用状态 ---------------- */ - -/** - * 没有服务端时,领取与使用要能反映到列表上,故本地叠一层实例状态: - * { [user_benefit_id]: { benefit_id, claimed_at, use_count, last_used_at } } - */ -const readLocal = () => { - try { - return uni.getStorageSync(STORAGE_KEYS.BENEFIT_STATE) || {}; - } catch (e) { - return {}; - } -}; - -const writeLocal = (state) => uni.setStorageSync(STORAGE_KEYS.BENEFIT_STATE, state || {}); - -const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - -const findLocal = (benefitId) => - Object.entries(readLocal()).find(([, st]) => st.benefit_id === benefitId) || null; - -/** 套上本地领取状态后的公开权益(领取后按钮要变「去使用」) */ -const withLocalClaim = (b) => { - const hit = findLocal(b.id); - if (!hit) return b; - return { - ...b, - already_claimed: true, - can_claim: false, - claim_unavailable_reason: "already_claimed", - user_benefit_id: hit[0], - }; -}; - -const mockCatalog = () => (benefitsMock.items || []).map(withLocalClaim); - -/** 由模板 + 本地状态合成一条已领取实例(字段与 mock/v1/me-benefits.json 同一套) */ -const buildInstance = (uid, st) => { - const tpl = (benefitsMock.items || []).find((t) => t.id === st.benefit_id) || {}; - const expired = isPast(tpl.valid_until); - return { - id: uid, - merchant_id: tpl.merchant_id || "", - merchant_name: tpl.merchant_name || "", - benefit_id: st.benefit_id, - description: tpl.description || "", - image_url: tpl.image_url || "", - use_count: st.use_count || 0, - valid_from: st.claimed_at, - valid_until: tpl.valid_until || null, - claimed_at: st.claimed_at, - status: expired ? "expired" : "available", - can_use: !expired, - unavailable_reason: expired ? "expired" : "", - section_key: tpl.section_key || "", - price: tpl.price, - slogan: tpl.slogan, - scope_desc: tpl.scope_desc, - }; -}; - -/** 我的权益实例 = 本地新领取的 + 预置的(按领取时间倒序) */ -const mockMineInstances = () => { - const local = readLocal(); - const templates = benefitsMock.items || []; - const seededIds = new Set((meBenefitsMock.items || []).map((s) => s.id)); - const seeded = (meBenefitsMock.items || []).map((s) => ({ ...s, ...(local[s.id] || {}) })); - const created = Object.entries(local) - // 只认目录里真有的模板,避免历史遗留 / 异常 storage 造出脏实例 - .filter(([uid, st]) => !seededIds.has(uid) && templates.some((t) => t.id === st.benefit_id)) - .map(([uid, st]) => buildInstance(uid, st)); - return [...created, ...seeded].sort((a, b) => (a.claimed_at < b.claimed_at ? 1 : -1)); -}; - -const countsOf = (instances) => ({ - all: instances.length, - available: instances.filter((i) => i.status === "available").length, - invalid: instances.filter((i) => i.status === "expired" || i.status === "exhausted" || i.status === "voided") - .length, -}); - /* ---------------- 对外接口 ---------------- */ /** 权益中心:可领取 + 已抢光(已领取的归「我的权益」,不在中心重复出现) */ export const getBenefitList = async (params = {}) => { const type = params.type && params.type !== "all" ? params.type : ""; - if (!USE_MOCK_CONTENT) { - const res = await benefitApi.fetchBenefits(type ? { section_key: type } : {}); - return ((res && res.items) || []).map(toPublicCard).filter((b) => !b.already_claimed); - } - let list = mockCatalog().map(toPublicCard).filter((b) => !b.already_claimed); - if (type) list = list.filter((b) => b.section_key === type); - return clone(list); + const res = await benefitApi.fetchBenefits(type ? { section_key: type } : {}); + return ((res && res.items) || []).map(toPublicCard).filter((b) => !b.already_claimed); }; /** @@ -170,53 +79,25 @@ export const getBenefitList = async (params = {}) => { * status 固定取 all 由前端切页签,保证 counts 是「全部」口径(服务端 counts 不受页签影响)。 */ export const getMyBenefitList = async () => { - if (!USE_MOCK_CONTENT) { - const res = await benefitApi.fetchMyBenefits({ status: "all" }); - const items = ((res && res.items) || []).map(toMineCard); - const c = (res && res.counts) || {}; - return { - items, - total: (res && res.total) || items.length, - counts: { all: c.all || 0, usable: c.available || 0, invalid: c.invalid || 0 }, - }; - } - const instances = mockMineInstances(); - const c = countsOf(instances); - return { items: clone(instances.map(toMineCard)), total: c.all, counts: { all: c.all, usable: c.available, invalid: c.invalid } }; + const res = await benefitApi.fetchMyBenefits({ status: "all" }); + const items = ((res && res.items) || []).map(toMineCard); + const c = (res && res.counts) || {}; + return { + items, + total: (res && res.total) || items.length, + counts: { all: c.all || 0, usable: c.available || 0, invalid: c.invalid || 0 }, + }; }; /** 领取权益:入参是公开模板 id(benefit_id),返回领取后的实例 */ -export const claimBenefit = async (id) => { - if (!USE_MOCK_CONTENT) return toMineCard(await benefitApi.claimBenefit(id)); - const local = readLocal(); - const uid = `user-${id}`; - local[uid] = { benefit_id: id, claimed_at: nowIso(), use_count: 0, last_used_at: null }; - writeLocal(local); - return clone(toMineCard(buildInstance(uid, local[uid]))); -}; +export const claimBenefit = async (id) => toMineCard(await benefitApi.claimBenefit(id)); /** 记录一次使用:入参是 user_benefit_id;只累加次数与时间,不使权益失效 */ -export const useBenefit = async (userBenefitId) => { - if (!USE_MOCK_CONTENT) return benefitApi.useBenefit(userBenefitId); - const local = readLocal(); - const seed = local[userBenefitId] || mockMineInstances().find((i) => i.id === userBenefitId) || {}; - local[userBenefitId] = { - benefit_id: seed.benefit_id || "", - claimed_at: seed.claimed_at || nowIso(), - use_count: (seed.use_count || 0) + 1, - last_used_at: nowIso(), - }; - writeLocal(local); - return { id: `usage-${Date.now()}`, user_benefit_id: userBenefitId, used_at: nowIso() }; -}; - -/** 合并本地领取状态(详情页内嵌权益同用,保证领取后按钮一致) */ -export const applyLocalState = (list) => (list || []).map(withLocalClaim); +export const useBenefit = async (userBenefitId) => benefitApi.useBenefit(userBenefitId); export default { getBenefitList, getMyBenefitList, claimBenefit, useBenefit, - applyLocalState, }; diff --git a/src/api/content.js b/src/api/content.js index fdcd0d5..81116c2 100644 --- a/src/api/content.js +++ b/src/api/content.js @@ -10,7 +10,7 @@ */ import { AGREEMENT_DATE, CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_TIME } from "@/constant"; import { clone, cnDate, unique } from "@/utils"; -import { applyLocalState, toPublicCard } from "@/api/benefit"; +import { toPublicCard } from "@/api/benefit"; import * as contentApi from "@/request/api/content"; /** 首页聚合(品牌 / hero / 板块卡 / 精选推荐 / 导览 / 页脚) */ @@ -25,14 +25,13 @@ export const getMerchants = async (params = {}) => contentApi.fetchMerchants(par /** * 商家详情:一次返回商家 + 权益 + 同类推荐。 * 内嵌权益是接口的 PublicBenefitView(status 恒为 published), - * 出参前先叠本地领取状态,再统一转成卡片模型(四态 + 剩余名额),判定口径与权益中心同一套。 + * 出参前统一转成卡片模型(四态 + 剩余名额),判定口径与权益中心同一套。 */ export const getMerchantDetail = async (id) => { const data = await contentApi.fetchMerchantDetail(id); if (!data) return null; if (data.benefits) { - const items = applyLocalState(data.benefits.items || []); - data.benefits.items = items.map(toPublicCard); + data.benefits.items = (data.benefits.items || []).map(toPublicCard); data.benefits.total = data.benefits.items.length; } return data; diff --git a/src/components/BenefitCard/index.vue b/src/components/BenefitCard/index.vue index ae5e80d..f45147a 100644 --- a/src/components/BenefitCard/index.vue +++ b/src/components/BenefitCard/index.vue @@ -9,7 +9,7 @@ */ import { computed } from "vue"; import { BENEFIT_STATUS, BENEFIT_TYPE_ICON, BENEFIT_TYPE_LABEL } from "@/constant"; -import { discountLabel, dotDate, priceText, rateText } from "@/utils"; +import { basisText, discountLabel, dotDate, rateText, yuanText } from "@/utils"; import { goDetail } from "@/utils/nav"; const props = defineProps({ @@ -33,10 +33,11 @@ const typeKey = computed(() => b.value.section_key || b.value.type || ""); const typeLabel = computed(() => BENEFIT_TYPE_LABEL[typeKey.value] || "权益"); const typeIcon = computed(() => BENEFIT_TYPE_ICON[typeKey.value] || "gift"); -const deal = computed(() => priceText(b.value.price?.deal)); -const original = computed(() => priceText(b.value.price?.original)); +const deal = computed(() => yuanText(b.value.price?.deal)); +const original = computed(() => yuanText(b.value.price?.original)); const pct = computed(() => discountLabel(b.value.price?.deal, b.value.price?.original)); -const rate = computed(() => rateText(b.value.price?.rate)); +const rate = computed(() => rateText(b.value.price)); +const basis = computed(() => basisText(b.value.price)); const isRate = computed(() => !!rate.value); const spec = computed(() => b.value.price?.spec || ""); const hasPrice = computed(() => isRate.value || !!deal.value); @@ -47,12 +48,9 @@ const slogan = computed(() => const validText = computed(() => { const { valid_until: until, valid_days: days } = b.value; - if ((isClaimed.value || status.value === BENEFIT_STATUS.EXPIRED) && until) { - return `有效期至 ${dotDate(until)}`; - } - if (days) return `有效期:${days} 天`; if (until) return `有效期至 ${dotDate(until)}`; - return ""; + if (days) return `有效期:${days} 天`; + return "长期有效"; }); /** meta 行第二项:随状态变化 */ @@ -144,7 +142,7 @@ const onTap = () => {