From d723eb12323687baaa6cd89b4778ff0d60c232b9 Mon Sep 17 00:00:00 2001 From: zoujing Date: Thu, 17 Sep 2026 15:51:58 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9Amock=20=E7=9A=84=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E4=BA=86=E9=A6=96=E9=A1=B5=E3=80=81=E5=88=86=E7=B1=BB=E3=80=81?= =?UTF-8?q?=E5=95=86=E5=AE=B6=E5=88=97=E8=A1=A8=E5=92=8C=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/benefit.js | 7 + src/api/content.js | 182 +- src/components/BenefitCard/index.vue | 14 +- src/components/ContentCard/index.vue | 8 +- src/components/SectionTile/index.vue | 24 +- src/config/env.js | 3 + src/constant/index.js | 44 +- src/mock/benefits.json | 14 +- src/mock/v1/categories.json | 47 + src/mock/v1/homepage.json | 84 + src/mock/v1/merchant-details.json | 3333 ++++++++++++++++++++++++++ src/mock/v1/merchants.json | 167 ++ src/pages-sub/catalog/index.vue | 78 +- src/pages-sub/detail/index.vue | 85 +- src/pages/home/index.vue | 127 +- src/request/api/content.js | 41 +- src/request/base/request.js | 11 +- src/store/modules/benefit.js | 2 +- src/utils/index.js | 4 +- src/utils/nav.js | 6 +- 20 files changed, 3948 insertions(+), 333 deletions(-) create mode 100644 src/mock/v1/categories.json create mode 100644 src/mock/v1/homepage.json create mode 100644 src/mock/v1/merchant-details.json create mode 100644 src/mock/v1/merchants.json diff --git a/src/api/benefit.js b/src/api/benefit.js index 0658953..945e37c 100644 --- a/src/api/benefit.js +++ b/src/api/benefit.js @@ -42,6 +42,12 @@ const allWithState = () => { return (benefitsMock || []).map((b) => mergeState(b, local)); }; +/** 合并本地领取状态(内容层的到店权益也走这里,保证「领取后按钮变去使用」一致) */ +export const applyLocalState = (list) => { + const local = readLocalState(); + return (list || []).map((b) => mergeState(b, local)); +}; + /** 权益中心:可领取 + 已抢光(不含我的权益) */ export const getBenefitList = async (params = {}) => { if (!USE_MOCK_CONTENT) return benefitApi.fetchBenefits(params); @@ -133,4 +139,5 @@ export default { getRelatedBenefits, claimBenefit, useBenefit, + applyLocalState, }; diff --git a/src/api/content.js b/src/api/content.js index 93b6b48..a6ee03b 100644 --- a/src/api/content.js +++ b/src/api/content.js @@ -2,129 +2,113 @@ * 内容数据访问层(唯一数据入口) * --------------------------------------------------------------------------- * 页面只调用本文件,不直接 import mock。 - * USE_MOCK_CONTENT = true → 读 src/mock/*;false → 走 src/request/api/content.js。 - * mock 与接口字段同构,切换时调用方零改动。 + * USE_MOCK_CONTENT = true → 读 src/mock/v1/*(已是 /v1 接口响应形态,无需再适配); + * false → 走 src/request/api/content.js 的真实 /v1 通道。 + * 两条路返回的字段完全一致,切换只动这个开关。 + * + * 接口口径:一个页面链路由 4 个端点组成 + * GET /v1/homepage → GET /v1/categories?section_key= → GET /v1/merchants → GET /v1/merchants/:id/detail + * 商家归属靠 category_id(分类接口 items[].id),不再有 collection 这一层。 */ import { USE_MOCK_CONTENT } from "@/config/env"; -import { clone, imageListOf, unique } from "@/utils"; -import sectionsMock from "@/mock/sections.json"; -import catalogMock from "@/mock/catalog.json"; -import itemsMock from "@/mock/items.json"; -import assetsMock from "@/mock/assets.json"; +import { BENEFIT_STATUS } from "@/constant"; +import { clone, unique } from "@/utils"; +import { applyLocalState } from "@/api/benefit"; +import homepageMock from "@/mock/v1/homepage.json"; +import categoriesMock from "@/mock/v1/categories.json"; +import merchantsMock from "@/mock/v1/merchants.json"; +import detailsMock from "@/mock/v1/merchant-details.json"; import * as contentApi from "@/request/api/content"; /** - * 首页「精选推荐」运营位:一期写死 3 条(油画大草原 AAAA / 双龙镇·巫山峡谷 AAAA / 龙里辣子鸡), - * 二期由后端下发,页面无需改动。 + * 接口的权益只给「领取状态三件套」,页面四态与剩余名额由前端派生: + * already_claimed(优先级最高)→ claimed;claim_limit_reached → sold_out; + * benefit_expired → expired;其余 → available。 */ -const FEATURED_IDS = ["attraction-002", "attraction-003", "food-002"]; +const deriveBenefitStatus = (b) => { + if (b.already_claimed) return BENEFIT_STATUS.CLAIMED; + const reason = b.claim_unavailable_reason || ""; + if (reason === "claim_limit_reached") return BENEFIT_STATUS.SOLD_OUT; + if (reason === "benefit_expired") return BENEFIT_STATUS.EXPIRED; + return BENEFIT_STATUS.AVAILABLE; +}; -const allItems = () => Object.values(itemsMock); +/** PublicBenefitView → 权益卡模型(status 四态 + stock_left 派生) */ +const toCardBenefit = (b) => ({ + ...b, + status: deriveBenefitStatus(b), + stock_left: b.total_limit == null ? null : Math.max(0, (b.total_limit || 0) - (b.claim_count || 0)), +}); -const buildGroups = (section) => { - if (!section) return []; - return section.collections.flatMap((collection) => { - const groups = catalogMock[collection] || []; - return groups.map((group) => ({ - ...group, - collection, - groupId: `${collection}-${group.type_code}`, - items: group.item_ids.map((id) => itemsMock[id]).filter(Boolean), - })); - }); +/** 首页聚合(品牌 / hero / 板块卡 / 精选推荐 / 导览 / 页脚) */ +export const getHomepage = async () => { + if (!USE_MOCK_CONTENT) return contentApi.fetchHomepage(); + return clone(homepageMock); }; /** - * 板块封面兜底:`coverImage` 缺失时取该板块**首个分组首个条目**的主图(只读字段,不拼路径)。 - * - * ⚠️ 兜底必须抽出来给 `getSections` 与 `getSection` **共用**:两处口径一旦不一致,就会出现 - * 「首页板块卡有图、点进板块列表页头图空白」—— `sections.json` 里只有 `tourism` 自带 - * `coverImage`,`lodging` / `food` / `specialties` 三个都没这个字段。 + * 板块分类导航。 + * ⚠️ mock 阶段固定返回「龙里票根经济」(scenic)的数据,与传入 section_key 无关; + * 后端接口就绪后把 USE_MOCK_CONTENT 置 false 即按参数真实取数。 */ -const withCoverFallback = (section) => { - if (!section || section.coverImage) return section; - const firstGroup = (catalogMock[section.collections[0]] || [])[0]; - const firstId = firstGroup?.item_ids?.[0]; - return { ...section, coverImage: itemsMock[firstId]?.main_image_url || "" }; +export const getCategories = async (sectionKey) => { + if (!USE_MOCK_CONTENT) return contentApi.fetchCategories(sectionKey); + return clone(categoriesMock); }; -/** 板块列表(4 个) */ -export const getSections = async () => { - if (!USE_MOCK_CONTENT) return contentApi.fetchSections(); - return clone(sectionsMock.map(withCoverFallback)); +/** 商家列表:section_key 一次取全,前端按 category_id 归组到分类下 */ +export const getMerchants = async (params = {}) => { + if (!USE_MOCK_CONTENT) return contentApi.fetchMerchants(params); + return clone(merchantsMock); }; -/** 板块详情:定义 + 分组(含条目)+ 素材;封面同样走兜底,供列表页头图使用 */ -export const getSection = async (sectionKey) => { - if (!USE_MOCK_CONTENT) return contentApi.fetchSection(sectionKey); - const section = sectionsMock.find((s) => s.key === sectionKey); - if (!section) return null; - const groups = buildGroups(section); - const supportAssets = (assetsMock || []).filter((a) => section.supportAssetIds.includes(a.entity_id)); - return clone({ ...withCoverFallback(section), groups, supportAssets }); +/** + * 商家详情:一次返回商家 + 权益 + 同类推荐。 + * 内嵌权益是接口的 PublicBenefitView(status 恒为 published),出参前统一转成卡片模型。 + */ +export const getMerchantDetail = async (id) => { + let data; + if (USE_MOCK_CONTENT) { + const hit = (detailsMock.items || []).find((d) => d.id === id); + data = hit ? clone(hit) : null; + } else { + data = await contentApi.fetchMerchantDetail(id); + } + if (!data) return null; + if (data.benefits) { + const items = (data.benefits.items || []).map(toCardBenefit); + // mock 阶段再叠一层本地领取状态,领取后按钮才能变「去使用」 + data.benefits.items = USE_MOCK_CONTENT ? applyLocalState(items) : items; + data.benefits.total = items.length; + } + return data; }; -/** 条目详情 */ -export const getItem = async (collection, id) => { - if (!USE_MOCK_CONTENT) return contentApi.fetchItem(collection, id); - const item = itemsMock[id]; - if (!item) return null; - if (collection && item.collection !== collection) return null; - return clone({ ...item, images: imageListOf(item) }); +/** + * 板块头图:**前端自持**,接口不提供。 + * 取首页同 section_key 的板块卡图;scenic 由 hero 代表(entries 里不含 scenic)。 + */ +export const getSectionCover = async (sectionKey) => { + const home = await getHomepage(); + const entry = (home?.entries || []).find((e) => e.section_key === sectionKey); + return entry?.image_url || home?.hero?.image_url || ""; }; -/** 同类型推荐(排除自身,最多 limit 条) */ -export const getRelatedItems = async (collection, typeCode, excludeId, limit = 3) => { - if (!USE_MOCK_CONTENT) return contentApi.fetchRelatedItems({ collection, type_code: typeCode, exclude_id: excludeId, limit }); - return clone( - allItems() - .filter((it) => it.collection === collection && it.type_code === typeCode && it.id !== excludeId) - .slice(0, limit) - ); -}; - -/** 首页精选推荐 */ -export const getFeaturedItems = async () => { - if (!USE_MOCK_CONTENT) return contentApi.fetchFeaturedItems(); - return clone(FEATURED_IDS.map((id) => itemsMock[id]).filter(Boolean)); -}; - -/** 首页「全域导览」通栏图(来源 assets,不拼路径) */ -export const getOverviewMapAsset = async () => { - if (!USE_MOCK_CONTENT) return contentApi.fetchAssets({ entity_id: "tourism-overview-map" }); - return clone((assetsMock || []).find((a) => a.entity_id === "tourism-overview-map") || null); -}; - -/** 板块头图轮播图集(主图优先,其次素材图) */ +/** 头图预览图集:头图 + 该板块商家主图,去重取 3 张(无图集时页面回落预览头图本身) */ export const getSectionBannerImages = async (sectionKey) => { - const section = await getSection(sectionKey); - if (!section) return []; - const urls = [section.coverImage, ...section.groups.flatMap((g) => g.items.map((it) => it.main_image_url))]; + const [cover, list] = await Promise.all([ + getSectionCover(sectionKey), + getMerchants({ section_key: sectionKey }), + ]); + const urls = [cover, ...((list && list.items) || []).map((m) => m.main_image_url)]; return unique(urls.filter(Boolean)).slice(0, 3); }; -/** - * 首页板块卡轮播图集:**每个分组取一张代表图**(组内首个有主图的条目)。 - * 与 getSectionBannerImages 的区别:那个是「组内逐个条目」取前 3 张, - * 可能全落在同一个分组里;首页旅居卡要的是「酒店 / 民宿别院 / 旅居公寓 / 露营基地」 - * 四个分组各一张,张数 = 分组数。 - */ -export const getSectionGroupCovers = async (sectionKey) => { - const section = await getSection(sectionKey); - if (!section) return []; - const urls = section.groups - .map((g) => g.items.find((it) => it.main_image_url)?.main_image_url) - .filter(Boolean); - return unique(urls); -}; - export default { - getSections, - getSection, - getItem, - getRelatedItems, - getFeaturedItems, - getOverviewMapAsset, + getHomepage, + getCategories, + getMerchants, + getMerchantDetail, + getSectionCover, getSectionBannerImages, - getSectionGroupCovers, }; diff --git a/src/components/BenefitCard/index.vue b/src/components/BenefitCard/index.vue index ece8e48..815d7fc 100644 --- a/src/components/BenefitCard/index.vue +++ b/src/components/BenefitCard/index.vue @@ -25,8 +25,10 @@ const isOff = computed( ); 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"); +/** 权益类型键:详情页走接口的 section_key,权益中心仍是本地 type,两者取其一 */ +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)); @@ -39,12 +41,12 @@ const slogan = computed(() => ); const validText = computed(() => { - const { valid_to: to, valid_days: days } = b.value; - if ((isClaimed.value || status.value === BENEFIT_STATUS.EXPIRED) && to) { - return `有效期至 ${dotDate(to)}`; + 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 (to) return `有效期至 ${dotDate(to)}`; + if (until) return `有效期至 ${dotDate(until)}`; return ""; }); diff --git a/src/components/ContentCard/index.vue b/src/components/ContentCard/index.vue index 2d1e11c..6af9e44 100644 --- a/src/components/ContentCard/index.vue +++ b/src/components/ContentCard/index.vue @@ -18,8 +18,8 @@ const emit = defineEmits(["tap"]); const onTap = () => { emit("tap", props.item); - if (props.item?.collection && props.item?.id) { - goDetail(props.item.collection, props.item.id); + if (props.item?.id) { + goDetail(props.item.id); } }; @@ -37,10 +37,10 @@ const onTap = () => { {{ item.description }} - - {{ item.address_text }} + {{ item.address }} diff --git a/src/components/SectionTile/index.vue b/src/components/SectionTile/index.vue index 1ec3725..b676ceb 100644 --- a/src/components/SectionTile/index.vue +++ b/src/components/SectionTile/index.vue @@ -15,24 +15,24 @@ import { computed, ref } from "vue"; import { goCatalog } from "@/utils/nav"; const props = defineProps({ + /** 首页 /v1/homepage 的 entries[i]:section_key / title / subtitle / image_url / image_urls */ section: { type: Object, required: true }, variant: { type: String, default: "short" }, - /** - * 卡内轮播图集。首页旅居卡传「每个分组一张代表图」(4 张), - * 传空则回落为板块封面单图(右二半高卡就是这种)。 - */ - covers: { type: Array, default: () => [] }, }); const isTall = computed(() => props.variant === "tall"); -/** 轮播图集:有 covers 用 covers,否则单张封面 —— 统一成一个数组,模板只判长度 */ +/** 轮播图集:接口给 image_urls(旅居卡 = 各分类代表图),单张或无则回落 image_url */ const images = computed(() => { - if (props.covers?.length) return props.covers; - return props.section?.coverImage ? [props.section.coverImage] : []; + const list = props.section?.image_urls; + if (Array.isArray(list) && list.length) return list; + return props.section?.image_url ? [props.section.image_url] : []; }); -/** ≥2 张才起轮播:单张时 swiper 没有意义,还会白白多一层节点 */ -const hasSlider = computed(() => images.value.length > 1); +/** + * ≥2 张才起轮播,且**只有通高卡轮播**(首页旅居卡 = 四个住宿分类各一张)。 + * 右二半高卡保持单图 —— 与设计稿一致,接口即使给了多张也不轮。 + */ +const hasSlider = computed(() => isTall.value && images.value.length > 1); const activeIndex = ref(0); /** 取模防越界:图集长度变化(如热更新/接口返回变少)时进度点不会全部熄灭 */ @@ -76,7 +76,7 @@ const arrowCls = computed(() => : "right-3 bottom-3 w-8 h-8 border-[rgba(255,255,255,0.46)]" ); -const onTap = () => goCatalog(props.section.key); +const onTap = () => goCatalog(props.section.section_key);