feat:mock 的对接了首页、分类、商家列表和详情的接口

This commit is contained in:
zoujing committed 2026-09-17 15:51:58 +08:00
1 parent 87cf7dfbaa
commit d723eb1232
20 files changed
+3948 -333

No files matched your search

+7
View File
@@ -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,
};
+83 -99
View File
@@ -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,
};
+8 -6
View File
@@ -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 "";
});
+4 -4
View File
@@ -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);
}
};
</script>
@@ -37,10 +37,10 @@ const onTap = () => {
{{ item.description }}
</view>
<view v-if="showAddress && item.address_text"
<view v-if="showAddress && item.address"
class="mt-auto pt-[5px] flex items-center gap-[3px] text-[10px] text-ink3">
<AppIcon name="pin" tone="ink3" :size="11" />
<view class="min-w-0 flex-1 line-clamp-1">{{ item.address_text }}</view>
<view class="min-w-0 flex-1 line-clamp-1">{{ item.address }}</view>
</view>
</view>
</view>
+12 -12
View File
@@ -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);
</script>
<template>
@@ -103,7 +103,7 @@ const onTap = () => goCatalog(props.section.key);
<image class="w-full h-full" :src="url" mode="aspectFill" />
</swiper-item>
</swiper>
<image v-else class="absolute inset-0 w-full h-full" :src="section.coverImage" mode="aspectFill" />
<image v-else class="absolute inset-0 w-full h-full" :src="section.image_url" mode="aspectFill" />
<!-- 遮罩:文字锚顶部 → 顶部最重 -->
<view class="absolute inset-0" :class="isTall ? 'scrim-tile-tall' : 'scrim-tile'" />
+3
View File
@@ -30,6 +30,9 @@ export const VERSION = "1.0.0";
/** 生产网关(服务地址查询接口固定挂在这里) */
export const BASE_URL_PRO = "https://biz.nianxx.cn";
/** 内容接口(/v1/*)地址:商家权益平台,与旧网关是两套后端,不走 versionManager 换址 */
export const BASE_URL_V1 = "https://longli.nianxx.cn";
/** 测试网关(拉不到服务地址时的兜底) */
export const BASE_URL_DEV = "https://onefeel.brother7.cn/ingress";
+23 -21
View File
@@ -26,18 +26,22 @@ export const BENEFIT_STATUS = {
SOLD_OUT: "sold_out",
};
/** 权益类型 → 展示名(用于筛选 chip 与卡片类型行) */
/** 权益类型 → 展示名(用于筛选 chip 与卡片类型行);键与接口 section_key 同一套 */
export const BENEFIT_TYPES = [
{ key: "all", label: "全部" },
{ key: "ticket", label: "景区游玩" },
{ key: "lodging", label: "住宿" },
{ key: "scenic", label: "景区游玩" },
{ key: "stay", label: "住宿" },
{ key: "food", label: "餐饮" },
{ key: "specialty", label: "特产" },
];
/**
* 权益类型 → 展示名 / 图标(键 = 接口 section_key)。
* 详情页权益取 `section_key`,权益中心取 `type`,两处取值同一套键。
*/
export const BENEFIT_TYPE_LABEL = {
ticket: "景区 / 游玩权益",
lodging: "住宿权益",
scenic: "景区 / 游玩权益",
stay: "住宿权益",
food: "餐饮权益",
specialty: "特产权益",
general: "通用权益",
@@ -45,8 +49,8 @@ export const BENEFIT_TYPE_LABEL = {
/** 权益类型 → 卡片类型行图标 */
export const BENEFIT_TYPE_ICON = {
ticket: "peak",
lodging: "bed",
scenic: "peak",
stay: "bed",
food: "food",
specialty: "ticket",
general: "gift",
@@ -59,22 +63,20 @@ export const BENEFIT_SUB_TABS = [
{ key: "invalid", label: "已失效" },
];
/** 内容集合 → 详情页介绍标题(v0.9:按内容类型命名) */
export const INTRO_TITLE = {
attractions: "景区介绍",
attraction_subprojects: "景区介绍",
accommodations: "酒店介绍",
foods: "美食介绍",
specialties: "特产介绍",
/** 板块 key → 详情页介绍卡标题(接口不提供,前端按 section_key 派生) */
export const SECTION_INTRO_TITLE = {
scenic: "景区介绍",
stay: "酒店介绍",
food: "美食介绍",
specialty: "特产介绍",
};
/** 集合 → 所属板块 key */
export const SECTION_OF_COLLECTION = {
attractions: "tourism",
attraction_subprojects: "tourism",
accommodations: "lodging",
foods: "food",
specialties: "specialties",
/** 板块 key → 列表页大标题(接口不提供,前端自持) */
export const SECTION_TITLE = {
scenic: "龙里票根经济",
stay: "旅居服务",
food: "全域美食",
specialty: "龙里特产",
};
/** 运营信息 */
+7 -7
View File
@@ -2,7 +2,7 @@
{
"id": "benefit-001",
"title": "龙里水乡单人票权益",
"type": "ticket",
"type": "scenic",
"merchant_id": "attraction-001",
"merchant_name": "龙里水乡旅游生态城",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/01.jpg",
@@ -23,7 +23,7 @@
{
"id": "benefit-002",
"title": "龙里住宿周末双床房权益",
"type": "lodging",
"type": "stay",
"merchant_id": "accommodation-002",
"merchant_name": "云从朵花温泉度假中心",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-002/01.jpg",
@@ -86,7 +86,7 @@
{
"id": "benefit-005",
"title": "龙里水乡双人票权益",
"type": "ticket",
"type": "scenic",
"merchant_id": "attraction-001",
"merchant_name": "龙里水乡旅游生态城",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/02.jpg",
@@ -275,7 +275,7 @@
{
"id": "benefit-101",
"title": "龙里水乡单人票权益",
"type": "ticket",
"type": "scenic",
"merchant_id": "attraction-001",
"merchant_name": "龙里水乡旅游生态城",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/01.jpg",
@@ -296,7 +296,7 @@
{
"id": "benefit-102",
"title": "龙里住宿双床房权益",
"type": "lodging",
"type": "stay",
"merchant_id": "accommodation-001",
"merchant_name": "云河酒店",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-001/01.jpg",
@@ -338,7 +338,7 @@
{
"id": "benefit-104",
"title": "龙里住宿大床房权益",
"type": "lodging",
"type": "stay",
"merchant_id": "accommodation-005",
"merchant_name": "龙里伯爵钻石酒店",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-005/01.jpg",
@@ -359,7 +359,7 @@
{
"id": "benefit-201",
"title": "云河酒店到店住宿权益",
"type": "lodging",
"type": "stay",
"merchant_id": "accommodation-001",
"merchant_name": "云河酒店",
"image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-001/03.jpg",
+47
View File
@@ -0,0 +1,47 @@
{
"items": [
{
"id": "category-scenic-comprehensive",
"name": "综合景区",
"sort_order": 1,
"merchant_count": 3
},
{
"id": "category-scenic-natural",
"name": "自然景观",
"sort_order": 2,
"merchant_count": 8
},
{
"id": "category-scenic-rural-culture",
"name": "乡村文旅",
"sort_order": 3,
"merchant_count": 3
},
{
"id": "category-scenic-themed-town",
"name": "主题小镇",
"sort_order": 4,
"merchant_count": 1
},
{
"id": "category-scenic-landmark",
"name": "地标观光",
"sort_order": 5,
"merchant_count": 1
},
{
"id": "category-scenic-recreation",
"name": "游乐体验",
"sort_order": 6,
"merchant_count": 1
},
{
"id": "category-scenic-performance",
"name": "演艺体验",
"sort_order": 7,
"merchant_count": 1
}
],
"total": 7
}
+84
View File
@@ -0,0 +1,84 @@
{
"brand_name": "发现龙里",
"brand": {
"sub_name": "GuiZhou · QianNan",
"slogan": "山野、风味与票根优惠 · 一屏抵达黔南门户"
},
"hero": {
"eyebrow": "贵州 · 黔南 · 龙里",
"title": "龙里票根经济",
"subtitle": "领取龙里旅游优惠",
"button_text": "开始探索",
"image_url": "https://longli-gov.oss-cn-chengdu.aliyuncs.com/zhinian/images/3b9cf9369473c4aade3fc9fb2a362dab5f2ca2f8276e1b03ca7e77f1255f904a.jpg",
"section_key": "scenic"
},
"entries": [
{
"section_key": "stay",
"title": "旅居服务",
"subtitle": "酒店 · 民宿别院 · 旅居公寓 · 露营基地",
"image_url": "https://longli-gov.oss-cn-chengdu.aliyuncs.com/zhinian/images/cc9291832424b8e7f6dea3d6fa4dabcc5bc1e65c636fb075ed8451409a6ce083.jpg",
"image_urls": [
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-001/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-003/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-020/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/accommodations/accommodation-010/01.jpg"
]
},
{
"section_key": "food",
"title": "全域美食",
"subtitle": "特色菜肴 · 地方小吃 · 甜品饮品",
"image_url": "https://longli-gov.oss-cn-chengdu.aliyuncs.com/zhinian/images/acad9559e515737f1e892e050179a3fed3be7d936c350306b8b50e04a04ccc63.jpg",
"image_urls": [
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-001/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-006/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-015/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-003/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-005/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-018/01.jpg"
]
},
{
"section_key": "specialty",
"title": "龙里特产",
"subtitle": "刺梨制品 · 地方食品 · 农产品",
"image_url": "https://longli-gov.oss-cn-chengdu.aliyuncs.com/zhinian/images/a77a9d427d8ee61348c65bef07f77558976f610451b31777d742c3a18a102dc3.png",
"image_urls": [
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/specialties/specialty-001/01.png",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/specialties/specialty-008/01.jpg",
"https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/specialties/specialty-010/01.jpg"
]
}
],
"featured": {
"total": 3,
"items": [
{
"id": "attraction-002",
"name": "龙里油画大草原景区",
"description": "龙里油画大草原景区位于龙里县城南10公里,以湿地花海游览体验为主的山下片区,以登山体验步道为主体的山中片区,以草原游览体验观赏为主的山顶片区,是集休闲娱乐、运动养生、家庭度假、亲子体验观光旅游为一体的山地度假综合体。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-002/01.jpg"
},
{
"id": "attraction-003",
"name": "双龙镇·巫山峡谷",
"description": "双龙镇·巫山峡谷旅游景区是以峡谷风光为主,包含双龙镇旅游休闲度假区及巫山峡谷旅游体验区,是集购物、康体、养生、娱乐、餐饮、客栈等多元业态体验为一体的综合式休闲度假中心。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-003/01.jpg"
},
{
"id": "food-002",
"name": "龙里辣子鸡",
"description": "龙里辣子鸡选用农家天然优质放养三黄鸡,配以辣椒、姜、葱等佐料调配而成,属纯天然食品,色、香、味俱全,肉质嫩脆、香辣可口,吃一次就会爱上!",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/foods/food-002/01.jpg"
}
]
},
"guide_title": "龙里全域旅游导览图",
"guide_subtitle": "景区 · 旅居 · 美食 · 特产 一图总览",
"guide_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/maps/tourism-overview-map.png",
"footer_lines": [
"内容依据龙里县官方旅游指南整理",
"营业信息以现场公示为准"
]
}
File diff suppressed because it is too large. Load diff
+167
View File
@@ -0,0 +1,167 @@
{
"items": [
{
"id": "attraction-001",
"category_id": "category-scenic-comprehensive",
"name": "龙里水乡旅游生态城",
"description": "龙里水乡旅游生态城位于厦蓉高速龙里收费站左侧,她以水域为载体、以建筑为特色,是以文化、体育、康养、研学、美食等多种业态融合打造的旅游综合休闲体验项目。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/01.jpg",
"address": "厦蓉高速龙里收费站左侧",
"sort_order": 1
},
{
"id": "attraction-003",
"category_id": "category-scenic-comprehensive",
"name": "双龙镇·巫山峡谷",
"description": "双龙镇·巫山峡谷旅游景区是以峡谷风光为主,包含双龙镇旅游休闲度假区及巫山峡谷旅游体验区,是集购物、康体、养生、娱乐、餐饮、客栈等多元业态体验为一体的综合式休闲度假中心。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-003/01.jpg",
"address": "龙里县双龙镇·巫山峡谷旅游景区",
"sort_order": 3
},
{
"id": "attraction-006",
"category_id": "category-scenic-comprehensive",
"name": "云从朵花温泉度假中心",
"description": "云从朵花是龙里首个综合性温泉度假酒店,获评国家3A级景区、贵州金汤级温泉度假地。酒店占地200亩,配有99间主楼客房、25栋野奢客房及48个特色汤池,集康养、休闲、商务于一体。温泉为天然矿热泉,出水温度71℃,富含氟、偏硅酸、锶等20余种微量元素,三项核心指标均超国家医疗标准,被誉为“蓝宝石”氟泉与“美人泉”偏硅酸泉,康养理疗效果极佳。酒店配套完善,野奢客房带私汤,依山揽景,是高端温泉度假优选。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-006/01.jpg",
"address": "贵州省黔南州龙里县龙山镇贵龙纵线比孟村鸡场堡组云从朵花温泉酒店",
"sort_order": 6
},
{
"id": "attraction-002",
"category_id": "category-scenic-natural",
"name": "龙里油画大草原景区",
"description": "龙里油画大草原景区位于龙里县城南10公里,以湿地花海游览体验为主的山下片区,以登山体验步道为主体的山中片区,以草原游览体验观赏为主的山顶片区,是集休闲娱乐、运动养生、家庭度假、亲子体验观光旅游为一体的山地度假综合体。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-002/01.jpg",
"address": "龙里县城南10公里",
"sort_order": 2
},
{
"id": "attraction-004",
"category_id": "category-scenic-natural",
"name": "龙架山国家森林公园",
"description": "龙架山国家森林公园是国家3A级旅游景区,距离龙里县城1.5公里,总规划面积6100.9公顷。公园依山傍水、环境优美,整个园区森林壮阔、林海苍翠、四季如画,春看花、夏感风、秋看叶、冬观雪,素有“省会贵阳市的后花园”的美誉。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-004/01.jpg",
"address": "距龙里县城1.5公里",
"sort_order": 4
},
{
"id": "attraction-007",
"category_id": "category-scenic-natural",
"name": "龙里大草原",
"description": "龙里大草原属独特的高山台地草原,位于龙里县龙山镇。该景区气温常年在20℃左右,被誉为天然空调、绿色氧吧,是贵州东线和南线旅游的第一站。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-007/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县龙山镇龙里大草原",
"sort_order": 7
},
{
"id": "attraction-009",
"category_id": "category-scenic-natural",
"name": "贾托山风景区",
"description": "贾托山景区海拔1775米,是龙里境内最高的山峰,在山顶上能看见龙里、贵定、都匀、贵阳等县市的风景,以梯田景观、康养度假为主,是一个非常值得一去的户外旅游观景台。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-009/01.jpg",
"address": "龙里县贾托山风景区",
"sort_order": 9
},
{
"id": "attraction-011",
"category_id": "category-scenic-natural",
"name": "莲花湿地公园",
"description": "龙里莲花湿地公园位于厦蓉高速龙里收费站右侧,她“湖光山色、江山如画”,公园依托朵花河沿河自然山水、田园风光,巧妙布局喷泉、石雕、步道,漫步园内,小桥流水、泉湖照影、鸟语花香,令人赏心悦目。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-011/01.jpg",
"address": "厦蓉高速龙里收费站右侧",
"sort_order": 11
},
{
"id": "attraction-014",
"category_id": "category-scenic-natural",
"name": "龙湖花海",
"description": "龙湖花海生态观光园位于素有“根雕艺术之乡”美誉的龙里县醒狮镇大岩村小岩组,毗邻贵阳渔洞峡水库,与贵阳市永乐乡、东风镇、偏坡乡相接壤。距镇政府所在地4公里,距离省城贵阳市约20公里,平均海拔1150米。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-014/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县醒狮镇大岩村小岩组",
"sort_order": 14
},
{
"id": "attraction-015",
"category_id": "category-scenic-natural",
"name": "莲花四季花海",
"description": "莲花社区四季花海坐落于龙里县城西南约3公里的莲花村,景区以四季花卉观赏为核心,常年种植马鞭花、格桑花、三角梅等花卉,形成连绵的花海景观。园内配套设施丰富,设有29项无动力儿童游乐设施、露营基地及河滩烧烤区。游客可参与溯溪、垂钓等自然体验活动,节庆期间还能欣赏山歌对唱、加入篝火晚会等民俗项目。餐饮方面提供烤全羊、柴火鸡、龙里肉饼鸡等地方特色美食。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-015/01.jpg",
"address": "龙里县城西南约3公里的莲花村",
"sort_order": 15
},
{
"id": "attraction-016",
"category_id": "category-scenic-natural",
"name": "姊妹湖花海",
"description": "龙里姊妹湖花海位于贵州省黔南布依族苗族自治州龙里县龙里草原景区西侧200米,姊妹湖拥有浪漫的紫色花海和观赏日落的绝妙视角,吸引了许多户外爱好者过来游览和露营扎寨,在花的海洋里肆意奔跑、在自己临时搭建的小天地里晒太阳、玩桌游、搞烧烤,与朋友享受惬意时光,尽情享受姊妹湖花海带来的假日美好。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-016/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县龙里草原景区西侧200米",
"sort_order": 16
},
{
"id": "attraction-008",
"category_id": "category-scenic-rural-culture",
"name": "十里刺梨沟景区",
"description": "十里刺梨沟景区位于谷脚镇茶香村,以十里连片“维C之王”刺梨种植而得名,获“中国山地自行车最美赛道”称号。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-008/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县谷脚镇茶香村",
"sort_order": 8
},
{
"id": "attraction-010",
"category_id": "category-scenic-rural-culture",
"name": "孔雀寨景区",
"description": "孔雀寨景区位于湾滩河镇中部区域,距龙里县城49公里,全寨依山傍水,风景优美,地势开阔,清澈的湾滩河从寨前蜿蜒而过,是一个历史悠久、文化底蕴深厚、民风淳朴、民族风情浓郁的布依族村寨。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-010/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县湾滩河镇孔雀寨",
"sort_order": 10
},
{
"id": "attraction-013",
"category_id": "category-scenic-rural-culture",
"name": "赶场村",
"description": "赶场村定位为“贵州民俗文化好吃村”,其秉承“传承工匠精神,做好乡村美食”的宗旨,集合了贵州特色名小吃、特色美食、现场制作的优质土特产、少数民族文化演艺、非遗文化体验,以及400余间精品民宿客栈等,让游客在赶场村能“一村吃遍贵州,一村带走贵州”,体验“美食的世界,歌舞的海洋”。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-013/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县赶场村",
"sort_order": 13
},
{
"id": "attraction-012",
"category_id": "category-scenic-themed-town",
"name": "龙门镇",
"description": "龙门镇是贵州第一个以“武侠文化”为主题定位的小镇,独特的文化定位,不仅有利于丰富贵州的旅游文化主题,提供更独特的旅游体验,也有助于促进民间武术、养生文化的切磋与发扬,更能为贵州增添一张“侠文化”旅游名片。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-012/01.jpg",
"address": "贵州省黔南布依族苗族自治州龙里县龙门镇",
"sort_order": 12
},
{
"id": "attraction-005",
"category_id": "category-scenic-landmark",
"name": "龙里河大桥",
"description": "全长1260米,为主跨528米的双塔双索面叠合梁斜拉桥,桥塔高265米、245米,桥面距谷底280米,是世界首座高山峡谷景观斜拉桥。桥面宽28米,双向四车道,设玻璃步道与人行道。大桥配备观光电梯、360°景观平台、528米玻璃步道,打造立体观光体验;还设有蹦极、攀岩、七彩滑道、火车咖啡馆、亲子迷宫等业态,集观景、休闲、极限运动于一体,可尽览朵花大峡谷与龙里大草原美景。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-005/01.jpg",
"address": "龙里河大桥",
"sort_order": 5
},
{
"id": "attraction-subproject-001",
"category_id": "category-scenic-recreation",
"name": "飞越丛林",
"description": "建成的“飞越丛林”森林体育探险项目,是目前西南地区最大、设施最齐全的森林探险公园,“绿、蓝、橙、红、黑”五条树上线路囊括穿梭林间无限乐趣,适宜亲子、家庭、情侣、朋友全年龄人群畅玩体验,别具一格的“儿童蛛网”同孩子一起分享绝对安全的树上快乐时光。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attraction_subprojects/attraction-subproject-001/01.jpg",
"address": "厦蓉高速龙里收费站左侧龙里水乡旅游生态城内",
"sort_order": 1
},
{
"id": "attraction-subproject-002",
"category_id": "category-scenic-performance",
"name": "龙乡水里·贵秀",
"description": "《龙乡水里·贵秀》是龙里水乡旅游生态城打造的一场大型原创室内实景演艺秀。从生命起源、历史变迁、民风民俗、歌舞文化传承等极具贵州代表性的文化符号,以出其不意的艺术呈现方式注入:“序《文明曙光》、《山水传奇》、《奢香夫人》、《铁血忠魂》、《云顶霓裳》、《盛世贵州》”六幕场景,为你揭开夜郎故土的神秘面纱,领略贵黔大地波澜壮阔而又瑰丽多姿的山水与人文传奇。",
"main_image_url": "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attraction_subprojects/attraction-subproject-002/01.jpg",
"address": "厦蓉高速龙里收费站左侧龙里水乡旅游生态城内",
"sort_order": 2
}
],
"total": 18
}
+42 -36
View File
@@ -1,49 +1,64 @@
<script setup>
/**
* 二级 · 板块列表页(需求 §5 · v10 定稿)
* 250px 头图 + 白面板上浮 30px;面板内「左侧 86px 分组导航 + 右侧卡片列表」双列。
* 分组导航带数量角标;列表按 type_order → sort_within_type 排序;不展示 tags;无搜索。
* 二级 · 板块列表页(需求 §5)
* 数据链路:GET /v1/categories?section_key= → 左列分类导航;
* GET /v1/merchants?section_key= → 全部商家,前端按 category_id 归组到各分类下。
* 250px 头图 + 白面板上浮 30px;面板内「左侧 86px 分类导航 + 右侧卡片列表」双列。
* 头图与大标题**前端自持**:头图取首页同 section_key 的板块卡图,大标题走 SECTION_TITLE。
*/
import { computed, ref } from "vue";
import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import contentApi from "@/api/content";
import { REGION_TEXT } from "@/constant";
import { REGION_TEXT, SECTION_TITLE } from "@/constant";
import { previewImages } from "@/utils/nav";
const sectionKey = ref("");
const section = ref(null);
const activeIndex = ref(0);
const categories = ref([]);
const merchants = ref([]);
const cover = ref("");
const bannerImages = ref([]);
const activeIndex = ref(0);
const scrollIntoView = ref("");
const groups = computed(() => section.value?.groups || []);
const title = computed(() => section.value?.title || "");
const cover = computed(() => section.value?.coverImage || "");
/** 左列分类 + 右列该分类下的商家卡片 */
const groups = computed(() =>
categories.value.map((c) => ({
id: c.id,
name: c.name,
items: merchants.value.filter((m) => m.category_id === c.id),
}))
);
const title = computed(() => SECTION_TITLE[sectionKey.value] || "");
onLoad(async (options) => {
sectionKey.value = options?.section || "tourism";
const [data, banners] = await Promise.all([
contentApi.getSection(sectionKey.value),
sectionKey.value = options?.section || "scenic";
const [cats, list, banners, coverUrl] = await Promise.all([
contentApi.getCategories(sectionKey.value),
contentApi.getMerchants({ section_key: sectionKey.value }),
contentApi.getSectionBannerImages(sectionKey.value),
contentApi.getSectionCover(sectionKey.value),
]);
section.value = data;
categories.value = cats?.items || [];
merchants.value = list?.items || [];
bannerImages.value = banners || [];
cover.value = coverUrl || "";
if (groups.value.length) {
scrollIntoView.value = `g-${groups.value[0].groupId}`;
scrollIntoView.value = `g-${groups.value[0].id}`;
}
});
/** 点左侧分组:右侧列表滚动到对应锚点 */
/** 点左侧分类:右侧列表滚动到对应锚点 */
const onGroupTap = (index) => {
activeIndex.value = index;
const group = groups.value[index];
if (group) scrollIntoView.value = "";
setTimeout(() => {
scrollIntoView.value = `g-${group.groupId}`;
if (group) scrollIntoView.value = `g-${group.id}`;
}, 0);
};
/** 点头图预览:优先看板块全景图集,没有图集时退回预览头图本身 */
/** 点头图预览:优先看板块图集,没有图集时退回预览头图本身 */
const onCoverPreview = () => {
const list = bannerImages.value.length
? bannerImages.value
@@ -77,35 +92,26 @@ onShareAppMessage(() => ({
</view>
</view>
<!-- 内容面板:分组导航 + 卡片列表(双列) -->
<!-- 内容面板:分类导航 + 卡片列表(双列) -->
<view
class="relative -mt-[30px] rounded-t-panel bg-white h-[calc(100vh_-_220px)] min-h-[460px] overflow-hidden shadow-pan"
>
class="relative -mt-[30px] rounded-t-panel bg-white h-[calc(100vh_-_220px)] min-h-[460px] overflow-hidden shadow-pan">
<view class="flex h-full">
<scroll-view class="w-[86px] shrink-0 bg-alt overflow-hidden pt-3" scroll-y :show-scrollbar="false">
<view
v-for="(group, i) in groups"
:key="group.groupId"
<view v-for="(group, i) in groups" :key="group.id"
class="h-16 flex items-center justify-center text-cap relative text-center leading-[1.3]"
:class="i === activeIndex ? 'bg-white text-brand-deep font-semibold rounded-r-[14px] deco-mark' : 'text-ink2'"
@click="onGroupTap(i)"
>
<text>{{ group.type }}</text>
@click="onGroupTap(i)">
<text>{{ group.name }}</text>
<text class="ml-[3px] text-[10px]" :class="i === activeIndex ? 'text-brand' : 'text-ink3'">
{{ group.items.length }}
({{ group.items.length }})
</text>
</view>
</scroll-view>
<scroll-view
class="flex-1 min-w-0 bg-white px-3 pt-1 pb-5"
scroll-y
:show-scrollbar="false"
:scroll-into-view="scrollIntoView"
:scroll-with-animation="true"
>
<view v-for="group in groups" :id="`g-${group.groupId}`" :key="group.groupId">
<view class="text-[16px] font-semibold mt-3 mb-[9px] text-ink">{{ group.type }}</view>
<scroll-view class="flex-1 min-w-0 bg-white px-3 pt-1 pb-5" scroll-y :show-scrollbar="false"
:scroll-into-view="scrollIntoView" :scroll-with-animation="true">
<view v-for="group in groups" :id="`g-${group.id}`" :key="group.id">
<view class="text-[16px] font-semibold mt-3 mb-[9px] text-ink">{{ group.name }}</view>
<view v-for="(item, i) in group.items" :key="item.id" :class="i ? 'mt-[10px]' : ''">
<ContentCard :item="item" show-address :thumb-size="100" />
</view>
+39 -46
View File
@@ -1,35 +1,40 @@
<script setup>
/**
* 三级 · 内容详情页(需求 §6 · v9 信息精简)
* 三级 · 商家详情页(需求 §6)
* 数据来自 GET /v1/merchants/:id/detail —— 一次返回「商家 + 到店权益 + 同类推荐」。
* 区块顺序:
* 头图 swiper → 白面板【名称 + 类型徽标 / 营业时间 / 地址条(内嵌复制钮)】
* → 介绍卡(标题按内容类型命名)→ 联系方式 → 到店权益(品牌染色卡)
* → 介绍卡(标题按 section_key 命名)→ 联系方式 → 到店权益(品牌染色卡)
* → 附近同类推荐 → 底部留白
* 不展示:tags 标签、设施服务、地图导航(数据无经纬度)。
*/
import { computed, ref } from "vue";
import { onLoad, onPageScroll, onShareAppMessage } from "@dcloudio/uni-app";
import contentApi from "@/api/content";
import benefitApi from "@/api/benefit";
import { BENEFIT_STATUS, INTRO_TITLE } from "@/constant";
import { BENEFIT_STATUS, SECTION_INTRO_TITLE } from "@/constant";
import { useBenefitStore } from "@/store";
import { copyText, goDetail, makeCall, previewImages, requireLogin } from "@/utils/nav";
import { unique } from "@/utils";
const benefitStore = useBenefitStore();
const collection = ref("");
const itemId = ref("");
const item = ref(null);
const merchant = ref(null);
const benefits = ref([]);
const related = ref([]);
const relatedBenefits = ref([]);
const current = ref(0);
const images = computed(() => item.value?.images || []);
const badge = computed(() => item.value?.badge || "");
const introTitle = computed(() => INTRO_TITLE[collection.value] || "介绍");
const addressText = computed(() => item.value?.address_text || "");
const contact = computed(() => item.value?.contact || null);
const businessHours = computed(() => item.value?.business_hours || "");
const images = computed(() => {
if (!merchant.value) return [];
return unique(
[merchant.value.main_image_url, ...(merchant.value.gallery_image_urls || [])].filter(Boolean)
);
});
const badge = computed(() => merchant.value?.type_label || "");
const introTitle = computed(() => SECTION_INTRO_TITLE[merchant.value?.section_key] || "介绍");
const addressText = computed(() => merchant.value?.address || "");
const phone = computed(() => merchant.value?.contact_phone || "");
const businessHours = computed(() => merchant.value?.business_hours || "");
/**
* 卡片通用底:白卡 + 22px 圆角 + 0.5px 描边 + 双侧柔影
@@ -40,23 +45,18 @@ const CARD_PROMO =
"bg-white rounded-hero p-[18px] mx-3 mt-3 border-[0.5px] border-[rgba(0,181,120,0.2)] shadow-block grad-promo";
const load = async () => {
const data = await contentApi.getItem(collection.value, itemId.value);
if (!data) {
const data = await contentApi.getMerchantDetail(itemId.value);
if (!data || !data.merchant) {
uni.showToast({ title: "内容不存在", icon: "none" });
setTimeout(() => uni.navigateBack(), 800);
return;
}
item.value = data;
const [rel, benefits] = await Promise.all([
contentApi.getRelatedItems(collection.value, data.type_code, data.id, 3),
benefitApi.getRelatedBenefits(data.id),
]);
related.value = rel || [];
relatedBenefits.value = benefits || [];
merchant.value = data.merchant;
benefits.value = data.benefits?.items || [];
related.value = data.related_merchants || [];
};
onLoad(async (options) => {
collection.value = options?.collection || "";
itemId.value = options?.id || "";
await load();
});
@@ -86,10 +86,7 @@ const onPreview = (index) => previewImages(images.value, images.value[index]);
const onCopyAddress = () => copyText(addressText.value, "地址已复制");
const onCall = () => {
if (!contact.value) return;
makeCall(contact.value.display || contact.value.raw);
};
const onCall = () => makeCall(phone.value);
/** 到店权益领取 */
const onClaimBenefit = async (benefit) => {
@@ -108,27 +105,24 @@ const onClaimBenefit = async (benefit) => {
};
/** 附近推荐点击 */
const goRelated = (rec) => goDetail(rec.collection, rec.id);
const goRelated = (rec) => goDetail(rec.id);
onShareAppMessage(() => ({
title: item.value?.name || "龙里文旅",
path: `/pages-sub/detail/index?collection=${collection.value}&id=${itemId.value}`,
title: merchant.value?.name || "龙里文旅",
path: `/pages-sub/detail/index?id=${itemId.value}`,
imageUrl: images.value[0] || "",
}));
</script>
<template>
<view class="relative min-h-screen w-full overflow-hidden skin-detail">
<template v-if="item">
<!-- 导航栏:fixed 固定,不再随页面滚走。
底色是单独一层「主色浅底」,opacity 随 scrollTop 0 → 1 渐显;
滚满后切深色字(浅底上白字不可读),并收掉玻璃圆底。 -->
<template v-if="merchant">
<view class="fixed left-0 right-0 top-0 z-[60]">
<view class="absolute inset-0 bg-brand-soft" :style="{ opacity: navProgress }" />
<view class="relative">
<AppStatusBar />
<AppNavBar :theme="navSolid ? 'dark' : 'light'" bare :glass="!navSolid" :title="item.name"
:subtitle="item.type" show-back />
<AppNavBar :theme="navSolid ? 'dark' : 'light'" bare :glass="!navSolid" :title="merchant.name"
:subtitle="merchant.type_label" show-back />
</view>
</view>
@@ -160,16 +154,15 @@ onShareAppMessage(() => ({
<!-- 白面板上浮 -->
<view class="relative z-[4] -mt-[34px] rounded-t-panel bg-white pt-7 px-4 pb-[18px] shadow-wcard">
<view class="flex items-center gap-[9px]">
<text class="text-[24px] font-bold tracking-[-0.6px] text-ink">{{ item.name }}</text>
<text class="text-[24px] font-bold tracking-[-0.6px] text-ink">{{ merchant.name }}</text>
<text v-if="badge"
class="shrink-0 text-hint font-semibold px-2 py-1 rounded-[7px] bg-[rgba(0,181,120,0.1)] text-brand-deep border-[0.5px] border-[rgba(0,181,120,0.2)]">
{{ badge }}
</text>
</view>
<view v-if="item.name_en" class="mt-px text-[11.5px] text-ink3">{{ item.name_en }}</view>
<view v-if="merchant.english_name" class="mt-px text-[11.5px] text-ink3">{{ merchant.english_name }}</view>
<!-- 营业时间(字段待运营方补充,一期 mock) -->
<view v-if="businessHours"
class="mt-2 flex items-center gap-[5px] text-[12px] font-medium text-brand-deep tracking-[0.1px]">
<AppIcon name="clock" tone="brand" :size="12" />
@@ -189,23 +182,23 @@ onShareAppMessage(() => ({
</view>
</view>
<!-- 介绍卡(标题按内容类型命名) -->
<view v-if="item.description" :class="CARD">
<!-- 介绍卡(标题按板块命名) -->
<view v-if="merchant.description" :class="CARD">
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
{{ introTitle }}
</view>
<view class="mt-[9px] text-[13.5px] leading-[1.9] text-[#4A5560]">{{ item.description }}</view>
<view class="mt-[9px] text-[13.5px] leading-[1.9] text-[#4A5560]">{{ merchant.description }}</view>
</view>
<!-- 联系方式 -->
<view v-if="contact" :class="CARD">
<view v-if="phone" :class="CARD">
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
联系方式
</view>
<view class="mt-3 flex items-center justify-between gap-3">
<view class="flex flex-col gap-[3px] min-w-0">
<text class="text-[11px] text-ink3 tracking-[0.2px]">预订 / 咨询电话</text>
<text class="text-title font-bold text-ink tracking-[0.3px]">{{ contact.display || contact.raw }}</text>
<text class="text-title font-bold text-ink tracking-[0.3px]">{{ phone }}</text>
</view>
<view
class="shrink-0 w-11 h-11 rounded-full flex items-center justify-center grad-call shadow-[0_8px_18px_rgba(0,181,120,0.32)]"
@@ -216,11 +209,11 @@ onShareAppMessage(() => ({
</view>
<!-- 到店权益(品牌染色卡) -->
<view v-if="relatedBenefits.length" :class="CARD_PROMO">
<view v-if="benefits.length" :class="CARD_PROMO">
<view class="flex items-center gap-2 text-[16px] font-bold tracking-[-0.3px] text-ink deco-titledot">
到店权益
</view>
<BenefitCard v-for="benefit in relatedBenefits" :key="benefit.id" :benefit="benefit" mode="detail"
<BenefitCard v-for="benefit in benefits" :key="benefit.id" :benefit="benefit" mode="detail"
@action="onClaimBenefit" />
</view>
+52 -75
View File
@@ -1,6 +1,7 @@
<script setup>
/**
* Tab 1 · 主页
* 数据来自 GET /v1/homepage(mock 阶段读 src/mock/v1/homepage.json)。
* 纵向顺序:
* 品牌开场区(吸顶固定)→ 主视觉大卡(180px)→ 玩转龙里(左一通高 + 右二上下)
* → 全域导览 → 精选推荐 → 免责声明
@@ -9,108 +10,88 @@
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import contentApi from "@/api/content";
import { goCatalog, goDetail, previewImages } from "@/utils/nav";
import { goCatalog, previewImages } from "@/utils/nav";
const sections = ref([]);
const home = ref({});
const featured = ref([]);
const mapAsset = ref(null);
/** 旅居卡轮播图集:住宿 4 个分组各取一张代表图 */
const lodgingCovers = ref([]);
const byKey = (key) => sections.value.find((s) => s.key === key) || {};
const brandName = computed(() => home.value?.brand_name || "");
/** 品牌名末两字走品牌色(「发现龙里」→ 发现 + 龙里) */
const brandHead = computed(() => brandName.value.slice(0, -2));
const brandTail = computed(() => brandName.value.slice(-2));
const brand = computed(() => home.value?.brand || {});
const hero = computed(() => home.value?.hero || {});
const entries = computed(() => home.value?.entries || []);
const footerLines = computed(() => home.value?.footer_lines || []);
const heroImage = computed(() => byKey("tourism").coverImage || "");
const tileLodging = computed(() => byKey("lodging"));
const tileFood = computed(() => byKey("food"));
const tileSpecialties = computed(() => byKey("specialties"));
const mapUrl = computed(() => mapAsset.value?.oss_url || "");
const entryOf = (key) => entries.value.find((e) => e.section_key === key) || {};
const load = async () => {
const [list, feat, map, covers] = await Promise.all([
contentApi.getSections(),
contentApi.getFeaturedItems(),
contentApi.getOverviewMapAsset(),
contentApi.getSectionGroupCovers("lodging"),
]);
sections.value = list || [];
featured.value = feat || [];
mapAsset.value = map;
lodgingCovers.value = covers || [];
const data = await contentApi.getHomepage();
home.value = data || {};
featured.value = data?.featured?.items || [];
};
load();
onShow(() => {
if (!sections.value.length) load();
if (!home.value?.brand_name) load();
});
/** 主视觉大卡「开始探索」→ 票根经济板块 */
const onStartExplore = () => goCatalog("tourism");
const onMoreFeatured = () => goCatalog("tourism");
/** 主视觉大卡「开始探索」/ 精选「更多」→ hero 所属板块 */
const onStartExplore = () => goCatalog(hero.value.section_key);
const onMoreFeatured = () => goCatalog(hero.value.section_key);
/** 导览图放大(素材已有,不做独立页) */
/** 导览图放大(接口已给图,不做独立页) */
const onMapTap = () => {
if (!mapUrl.value) return;
previewImages([mapUrl.value], mapUrl.value);
const url = home.value?.guide_image_url;
if (!url) return;
previewImages([url], url);
};
const onFeaturedTap = (item) => goDetail(item.collection, item.id);
</script>
<template>
<view class="relative h-screen w-full flex flex-col skin-home">
<!-- 固定区(品牌开场区,含状态栏占位):**不给任何背景**,直接透出页面背景。
页面背景 skin-home 铺在本根节点上(h-screen = 恰好一屏,不随内容滚动),
固定区与下方滚动区共用同一张背景 —— 颜色天然一致。
原因:任何不透明/半透明背景(哪怕取渐变顶端基色)都必然与「渐变 + 柔光晕」
对不上,滚起来就会显出一块异色。
滚动改由下方独立的 scroll-view 承担,内容永远不会进入本区域,所以无需遮挡。
⚠️ 本区已在 flex 流里固定,**不要再加 sticky**(那会要求它必须有背景)。 -->
<view class="relative z-[60] shrink-0">
<view class="px-4 pb-[10px]">
<AppStatusBar />
<view class="flex items-center gap-[7px] mt-[6px] text-[10px] font-bold tracking-[2.4px] text-brand-deep">
<view class="w-[5px] h-[5px] rounded-full bg-brand shadow-[0_0_0_4px_rgba(0,181,120,0.14)]" />
<text>GuiZhou · QianNan</text>
<text>{{ brand.sub_name }}</text>
</view>
<view class="mt-[9px] text-display font-bold tracking-[-1px] leading-[1.1] text-ink">
发现<text class="not-italic gradtext-brand">龙里</text>
{{ brandHead }}<text class="not-italic gradtext-brand">{{ brandTail }}</text>
</view>
<view class="mt-2 text-[11.5px] leading-[1.55] text-ink2 tracking-[0.1px]">
山野、风味与票根优惠 · 一屏抵达黔南门户
{{ brand.slogan }}
</view>
</view>
</view>
<!-- 滚动区:**独立滚动容器**(不是页面滚动),高度 = 一屏 − 固定区,
这样固定区与滚动区共用根节点上那张静态背景,头尾颜色完全一致。
末尾 h-[96px] 留白是为 fixed 定位的 TabBar 让位(TabBar 高 78px + 18px 间隔)。 -->
<scroll-view class="flex-1 min-h-0" scroll-y :show-scrollbar="false">
<view class="relative z-[1] px-4">
<!-- 主视觉大卡:距固定区仅 2px(贴紧品牌区,让首屏更紧凑) -->
<view class="relative h-[180px] mt-[2px] rounded-hero overflow-hidden shadow-hero" @click="onStartExplore">
<image class="absolute inset-0 w-full h-full" :src="heroImage" mode="aspectFill" />
<image class="absolute inset-0 w-full h-full" :src="hero.image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-hero" />
<view
class="pointer-events-none absolute inset-0 rounded-hero bg-[radial-gradient(210px_140px_at_98%_110%,rgba(63,223,168,0.44),transparent_74%)]"
/>
class="pointer-events-none absolute inset-0 rounded-hero bg-[radial-gradient(210px_140px_at_98%_110%,rgba(63,223,168,0.44),transparent_74%)]" />
<!-- 白字加投影:hero 图片是亮绿山景,光靠遮罩中段不足以拉开对比(text-shadow 可继承) -->
<view
class="absolute inset-0 z-[2] flex flex-col justify-end items-start px-5 pb-[17px] [text-shadow:0_1px_3px_rgba(0,0,0,0.5),0_2px_12px_rgba(0,0,0,0.42)]"
>
<view class="flex items-center gap-[7px] text-[9.5px] font-semibold tracking-[1.8px] text-[rgba(255,255,255,0.76)]">
<view class="block w-[14px] h-px bg-brand-lite" />
<text>贵州 · 黔南 · 龙里</text>
</view>
<view class="mt-[6px] text-[26px] font-bold text-white tracking-[0.6px] leading-[1.15]">龙里票根经济</view>
<view class="mt-[7px] text-[12px] text-[rgba(255,255,255,0.9)] tracking-[0.3px]">领取龙里旅游优惠</view>
class="absolute inset-0 z-[2] flex flex-col justify-end items-start px-5 pb-[17px] [text-shadow:0_1px_3px_rgba(0,0,0,0.5),0_2px_12px_rgba(0,0,0,0.42)]">
<view
class="mt-[13px] h-[34px] px-[15px] rounded-full bg-white inline-flex items-center gap-[5px] text-[13px] font-semibold text-brand-deep shadow-[0_10px_24px_rgba(3,26,20,0.28)] [text-shadow:none]"
>
<text>开始探索</text>
class="flex items-center gap-[7px] text-[9.5px] font-semibold tracking-[1.8px] text-[rgba(255,255,255,0.76)]">
<view class="block w-[14px] h-px bg-brand-lite" />
<text>{{ hero.eyebrow }}</text>
</view>
<view class="mt-[6px] text-[26px] font-bold text-white tracking-[0.6px] leading-[1.15]">{{ hero.title }}
</view>
<view class="mt-[7px] text-[12px] text-[rgba(255,255,255,0.9)] tracking-[0.3px]">{{ hero.subtitle }}</view>
<view
class="mt-[13px] h-[34px] px-[15px] rounded-full bg-white inline-flex items-center gap-[5px] text-[13px] font-semibold text-brand-deep shadow-[0_10px_24px_rgba(3,26,20,0.28)] [text-shadow:none]">
<text>{{ hero.button_text }}</text>
<AppIcon name="arrow" tone="brandDeep" :size="14" />
</view>
</view>
@@ -120,16 +101,11 @@ const onFeaturedTap = (item) => goDetail(item.collection, item.id);
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">玩转龙里</text>
</view>
<!-- 「玩转龙里」左一通高 + 右二上下。
⚠️ 高度由 SectionTile 自持(tall 226 / short 108,见组件内 sizeCls):
小程序里自定义组件是「宿主 → 根节点」两层,父级传的尺寸类**只落在宿主节点**上,
传不进组件内部,所以高度交给组件自己;这里父级只管**宽度比例**。
右列 108×2 + gap 10 = 226,与外层容器自洽。 -->
<view class="flex gap-[10px] h-[226px] mt-[2px]">
<SectionTile class="flex-[1.06] min-w-0" :section="tileLodging" variant="tall" :covers="lodgingCovers" />
<SectionTile class="flex-[1.06] min-w-0" :section="entryOf('stay')" variant="tall" />
<view class="flex-1 min-w-0 flex flex-col gap-[10px]">
<SectionTile :section="tileFood" variant="short" />
<SectionTile :section="tileSpecialties" variant="short" />
<SectionTile :section="entryOf('food')" variant="short" />
<SectionTile :section="entryOf('specialty')" variant="short" />
</view>
</view>
@@ -138,18 +114,17 @@ const onFeaturedTap = (item) => goDetail(item.collection, item.id);
<text class="text-title font-semibold text-ink">全域导览</text>
</view>
<view class="relative h-[112px] rounded-tile overflow-hidden mt-[2px] shadow-elev2" @click="onMapTap">
<image class="absolute inset-0 w-full h-full" :src="mapUrl" mode="aspectFill" />
<image class="absolute inset-0 w-full h-full" :src="home.guide_image_url" mode="aspectFill" />
<view class="absolute inset-0 scrim-map" />
<view
class="pointer-events-none absolute inset-0 rounded-tile bg-[radial-gradient(200px_130px_at_88%_112%,rgba(63,223,168,0.4),transparent_72%)]"
/>
class="pointer-events-none absolute inset-0 rounded-tile bg-[radial-gradient(200px_130px_at_88%_112%,rgba(63,223,168,0.4),transparent_72%)]" />
<view class="absolute inset-0 z-[2] flex flex-col justify-center items-start px-[18px]">
<text class="text-white text-[16px] font-semibold">龙里全域旅游导览图</text>
<text class="text-[rgba(255,255,255,0.85)] text-hint mt-1">景区 · 旅居 · 美食 · 特产 一图总览</text>
<text class="text-white text-[16px] font-semibold">{{ home.guide_title }}</text>
<text class="text-[rgba(255,255,255,0.85)] text-hint mt-1">{{ home.guide_subtitle }}</text>
</view>
</view>
<!-- 精选推荐 -->
<!-- 精选推荐(卡片自身完成跳转,页面不再重复绑定 tap) -->
<view class="flex items-baseline justify-between mt-5 mb-[10px] mx-[2px]">
<text class="text-title font-semibold text-ink">精选推荐</text>
<view class="flex items-center gap-[2px] text-[11.5px] text-ink3" @click="onMoreFeatured">
@@ -158,11 +133,13 @@ const onFeaturedTap = (item) => goDetail(item.collection, item.id);
</view>
</view>
<view v-for="(item, i) in featured" :key="item.id" :class="i ? 'mt-[10px]' : ''">
<ContentCard :item="item" @tap="onFeaturedTap" />
<ContentCard :item="item" />
</view>
<view class="mt-5 mx-1 text-[10px] leading-[1.7] text-ink3 text-center">内容依据龙里县官方旅游指南整理</view>
<view class="mx-1 text-[10px] leading-[1.7] text-ink3 text-center">营业信息以现场公示为准</view>
<view v-for="(line, i) in footerLines" :key="i"
:class="i ? 'mx-1 text-[10px] leading-[1.7] text-ink3 text-center' : 'mt-5 mx-1 text-[10px] leading-[1.7] text-ink3 text-center'">
{{ line }}
</view>
</view>
<!-- 底部留白 = TabBar 高 78px + 18px 间隔,避免最后一条内容贴住 TabBar -->
+24 -17
View File
@@ -1,24 +1,31 @@
/**
* 内容接口(/v1/*)—— 商家权益平台
* ---------------------------------------------------------------------------
* 与旧网关(biz.nianxx.cn)是两套后端,差异:
* - 无 clientId 头;鉴权只靠 Authorization: Bearer <token>
* - 成功响应即业务对象本身,没有 { code, data, message } 包装
* - 失败返回 { error: { code, message } },401 表示登录失效
* - 分页只有 limit / offset(limit 1–100,默认 20),未声明的 query 会 400
* - 时间为 RFC3339 UTC,展示前需转北京时间
*/
import { BASE_URL_V1 } from "@/config/env";
import request from "../base/request";
/**
* 内容(板块 / 目录 / 条目 / 素材)接口
* 字段口径见 需求说明 §2 / §5 / §6
*/
const V1 = `${BASE_URL_V1}/v1`;
/** 获取全部板块(4 个:票根经济 / 旅居服务 / 全域美食 / 龙里特产) */
export const fetchSections = () => request.get("/longli/content/sections");
/** /v1 通道:不带 clientId */
const V1_CONFIG = { noClientId: true };
/** 获取板块详情(含分组与条目,已过滤 enabled=false 与空分组) */
export const fetchSection = (sectionKey) => request.get("/longli/content/section", { key: sectionKey });
/** 首页聚合(品牌 / hero / 板块卡 / 精选推荐 / 导览 / 页脚) */
export const fetchHomepage = () => request.get(`${V1}/homepage`, {}, V1_CONFIG);
/** 获取条目详情 */
export const fetchItem = (collection, id) => request.get("/longli/content/item", { collection, id });
/** 板块分类导航(section_key = scenic / stay / food / specialty) */
export const fetchCategories = (sectionKey) =>
request.get(`${V1}/categories`, { section_key: sectionKey }, V1_CONFIG);
/** 获取同类型推荐(排除自身) */
export const fetchRelatedItems = (params) => request.get("/longli/content/related", params);
/** 商家列表:传 section_key 一次取全(前端按 category_id 归组),或传 category_id 取单分类 */
export const fetchMerchants = (params = {}) =>
request.get(`${V1}/merchants`, { limit: 100, ...params }, V1_CONFIG);
/** 首页精选推荐(一期为运营固定位,二期由后端下发) */
export const fetchFeaturedItems = () => request.get("/longli/content/featured");
/** 素材(导览图等) */
export const fetchAssets = (params = {}) => request.get("/longli/content/assets", params);
/** 商家详情:一次返回商家 + 权益 + 同类推荐 */
export const fetchMerchantDetail = (id) => request.get(`${V1}/merchants/${id}/detail`, {}, V1_CONFIG);
+7 -4
View File
@@ -26,7 +26,7 @@ const isOnLoginPage = () => {
};
function request(url, args = {}, method = "POST", customConfig = {}) {
const { noToken = false, silent = false, token: explicitToken, ...rest } = customConfig;
const { noToken = false, noClientId = false, silent = false, token: explicitToken, ...rest } = customConfig;
const env = getEnvConfig();
let fullUrl = url;
@@ -36,7 +36,7 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
const header = {
...defaultHeader,
clientId: env.clientId,
...(noClientId ? {} : { clientId: env.clientId }),
...(rest.header || {}),
};
@@ -62,7 +62,8 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
success: (res) => {
const { statusCode = 0, data } = res;
if (statusCode === 424) {
// 旧网关用 424、/v1 用 401 表示登录失效,处理一致:清 token → 广播退出 → 跳登录
if (statusCode === 401 || statusCode === 424) {
clearLoginState();
uni.$emit(NOTICE_EVENT_LOGOUT);
if (!isOnLoginPage()) setTimeout(() => goLogin(), 500);
@@ -74,7 +75,9 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
}
if (!silent) {
const msg = (data && (data.message || data.msg)) || `请求失败(${statusCode})`;
const msg =
(data && (data.message || data.msg || (data.error && data.error.message))) ||
`请求失败(${statusCode})`;
uni.showToast({ title: msg, icon: "none" });
}
reject(res);
+1 -1
View File
@@ -13,7 +13,7 @@ export const useBenefitStore = defineStore("benefit", {
myList: [],
/** 页头数据行文案 */
headline: "",
/** 权益中心筛选类型 all | ticket | lodging | food | specialty */
/** 权益中心筛选类型 all | scenic | stay | food | specialty */
filterType: "all",
/** 我的权益子分段 all | usable | invalid */
subTab: "all",
+2 -2
View File
@@ -53,8 +53,8 @@ export const maskPhone = (phone) => {
return `${p.slice(0, 3)}****${p.slice(-4)}`;
};
/** 2026-09-12 → 2026.09.12 */
export const dotDate = (str) => String(str || "").replace(/-/g, ".");
/** 2026-09-12 / 2026-09-12T02:00:00Z → 2026.09.12(接口时间为 RFC3339 UTC,只取日期段) */
export const dotDate = (str) => String(str || "").slice(0, 10).replace(/-/g, ".");
/** 有效期区间文案:2026.09.12 - 09.18 */
export const dateRangeText = (from, to) => {
+3 -3
View File
@@ -86,9 +86,9 @@ export const goCatalog = (sectionKey) => {
uni.navigateTo({ url: `${ROUTES.CATALOG}?section=${sectionKey}` });
};
/** 跳内容详情页 */
export const goDetail = (collection, id) => {
uni.navigateTo({ url: `${ROUTES.DETAIL}?collection=${collection}&id=${id}` });
/** 跳内容详情页(接口按商家 id 直取详情,不再需要 collection) */
export const goDetail = (id) => {
uni.navigateTo({ url: `${ROUTES.DETAIL}?id=${id}` });
};
/** 跳协议页 type: privacy | terms */