hookehuyr

feat(home): 联调首页接口并适配真实数据结构

- 更新首页 API 地址及 mock 处理器,对齐 Apifox 接口规范
- 重构首页页面,消费 `volunteer_top_banner`、`volunteer_bottom_banner`、`volunteer_home_icon` 字段
- 为顶部 banner 添加 Swiper 轮播支持,单图保持现状,多图自动切换
- 在 mock 环境下添加状态提示,便于后续真实数据联调
- 调整全局配置,移除默认 `f` 参数,改为各接口单独传递
- 更新组件类型声明,引入 NutUI Swiper 组件
- 同步更新 TODO 清单,标记已完成项并细化后续任务
# TODO 清单
## 未实现与待联调功能
1. 首页当前使用的还是假数据,还缺一个首页内容接口,需要替换成真实返回的 banner、导航和图片入口数据。
2. 底部导航栏的显示还需要联调,还缺一个底部导航配置接口,用来确认各 tab 的标题、显隐、跳转页和 WebView 地址。
3. 资讯页面还需要一个接口获取资讯信息列表,后续要和详情页、已读状态这条链路一起确认真实字段。
4. “应用”和“我的”页面还缺 2 个 WebView URL,需要分别提供可用地址后再完成联调。
5. 授权功能还没有联调,需要确认静默授权、授权失败跳转、授权成功回跳这条链路是否正常。
6. 支付确认页还没有联调,需要确认页面入参、金额展示、点击支付和状态提示是否正常。
7. 支付功能还没有联调,需要确认支付参数获取、小程序拉起支付、支付结果返回和回跳流程是否正常。
## 首页与内容接口
- [x] 首页页面已按 Apifox“首页”接口结构改造,当前消费 `volunteer_top_banner``volunteer_bottom_banner``volunteer_home_icon` 三组字段。
- [x] 首页 mock 已按真实接口字段结构补齐,当前图片和图标先复用现有页面假数据,避免真实接口到位后再次大范围改页面。
- [x] 首页页面已增加“当前为 mock 接口结构”的状态提示,后续真实接口出数后可直接去掉该提示。
- [ ] 首页真实接口还没有返回业务数据,后续需要把 mock 环境切到真实返回值联调。
- [ ] 首页接口正式 `client_id` 还未确认,当前继续沿用全局默认请求参数,待后端给出正式值后补配置。
- [ ] 继续确认 `volunteer_top_banner` 实际返回是一组还是多组;当前页面已兼容“单图保持现状,多图自动切 Swiper 轮播”。
- [ ] 确认首页接口里的 `title``color` 后续是否启用;当前已保留字段但页面暂未消费。
## 底部导航与 WebView
- [ ] 底部导航栏还需要联调真实配置接口,继续确认各 tab 的标题、显隐、跳转页和 WebView 地址。
- [ ] “应用”和“我的”页面还缺 2 个可用 WebView URL,提供后再完成联调。
## 资讯链路
- [ ] 资讯页面还需要真实接口获取列表数据,后续要和详情页、已读状态链路一起确认真实字段。
## 授权与支付
- [ ] 授权功能还没有完整联调,需要确认静默授权、授权失败跳转、授权成功回跳这条链路是否正常。
- [ ] 支付确认页还没有联调,需要确认页面入参、金额展示、点击支付和状态提示是否正常。
- [ ] 支付功能还没有联调,需要确认支付参数获取、小程序拉起支付、支付结果返回和回跳流程是否正常。
......
......@@ -9,6 +9,8 @@ declare module 'vue' {
export interface GlobalComponents {
AppTabbar: typeof import('./src/components/AppTabbar.vue')['default']
IndexNav: typeof import('./src/components/indexNav.vue')['default']
NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
......
import { fn, fetch } from './fn'
const Api = {
HOME_CONTENT: '/srv/?a=home&t=content',
HOME_CONTENT: '/srv/?a=app_list&t=volunteer&f=customize',
PAY_TEST: '/srv/?a=pay',
}
/**
* @description 获取首页内容
* - 当前先按 Apifox 首页接口结构联调
* - client_id 继续走全局默认参数,正式值待后端确认
*/
export const getHomeContentAPI = () => fn(fetch.get(Api.HOME_CONTENT))
/**
......
const banner_image = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner01@2x.png'
const activity_image_01 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner02@2x.png'
const activity_image_02 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner03@2x.png'
const top_banner_image = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner01@2x.png'
const top_banner_image_02 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner02@2x.png'
const top_banner_image_03 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner03@2x.png'
const bottom_banner_image_01 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner02@2x.png'
const bottom_banner_image_02 = 'https://cdn.ipadbiz.cn/jls_weapp/images/banner03@2x.png'
/**
* @description 首页接口 mock 样本
* - 字段结构对齐 Apifox“首页”接口
* - 当前图片与图标先复用现有首页假数据,等真实接口出数后可直接替换
*/
export const getHomeContentFixture = () => ({
banner: {
id: 'home-banner-01',
image_url: banner_image,
title: '苏州觉林寺',
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
link_title: '苏州觉林寺',
},
nav_list: [
volunteer_top_banner: [
{
id: 'map-guide',
id: 2770328,
title: '苏州觉林寺',
link: top_banner_image,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
},
{
id: 2770329,
title: '法讯动态',
link: top_banner_image_02,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
},
{
id: 2770331,
title: '共修活动',
link: top_banner_image_03,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=practice',
},
],
volunteer_bottom_banner: [
{
id: 2770330,
title: '新春祈福法会',
link: bottom_banner_image_01,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_spring',
},
{
id: 2770332,
title: '中秋月光茶会',
link: bottom_banner_image_02,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_moon',
},
{
id: 2770333,
title: '禅修静心体验',
link: bottom_banner_image_01,
value: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_meditation',
},
{
id: 2770334,
title: '法物流通',
link: bottom_banner_image_02,
value: '',
},
],
volunteer_home_icon: [
{
id: 2770335,
title: '地图导览',
link: '/pages/map-guide/index',
icon: 'fa-odnoklassniki-square',
link_url: '',
page_url: '/pages/map-guide/index',
},
{
id: 'news',
id: 2770336,
title: '法讯-最新动态',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
icon: 'icon-jingxiuying',
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
link_title: '法讯-最新动态',
},
{
id: 'intro',
id: 2770337,
title: '寺院介绍',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=temple_intro',
icon: 'icon-jingxiuying',
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=temple_intro',
link_title: '寺院介绍',
},
{
id: 'practice',
id: 2770338,
title: '共修活动',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=practice',
icon: 'icon-jingxiuying',
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=practice',
link_title: '共修活动',
},
{
id: 'volunteer',
id: 2770339,
title: '义工报名',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=volunteer',
icon: 'icon-jingxiuying',
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=volunteer',
link_title: '义工报名',
},
{
id: 'contact',
id: 2770340,
title: '联系我们',
link: '',
icon: 'icon-jingxiuying',
link_url: '',
},
],
image_links: [
{
id: 'activity-01',
title: '新春祈福法会',
tag: '活动报名',
image_url: activity_image_01,
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_spring',
link_title: '新春祈福法会',
},
{
id: 'activity-02',
title: '中秋月光茶会',
tag: '活动报名',
image_url: activity_image_02,
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_moon',
link_title: '中秋月光茶会',
},
{
id: 'activity-03',
title: '禅修静心体验',
tag: '预约参加',
image_url: activity_image_01,
link_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=activity_meditation',
link_title: '禅修静心体验',
},
{
id: 'activity-04',
title: '法物流通',
tag: '查看详情',
image_url: activity_image_02,
link_url: '',
},
],
title: '苏州觉林寺',
color: '#d75b5b',
})
......
......@@ -3,9 +3,9 @@ import { buildMockSuccess } from '../shared/response'
export const homeMockHandlers = [
{
action: 'home',
type: 'content',
action: 'app_list',
type: 'volunteer',
method: 'GET',
handle: () => buildMockSuccess(getHomeContentFixture(), '首页内容获取成功 (mock)'),
handle: () => buildMockSuccess(getHomeContentFixture(), '首页内容获取成功(当前为 mock 结构)'),
},
]
......
<template>
<view class="index-page">
<view class="page-content">
<view
v-if="banner.image_url"
class="home-banner"
:class="{ clickable: hasLink(banner) }"
@tap="handleLinkedItemTap(banner)"
>
<image class="home-banner__image" :src="banner.image_url" mode="aspectFill" />
<view v-if="hasTopBanner" class="home-top-banner">
<view v-if="isMockHomeData" class="mock-state-badge">
mock
</view>
<nut-swiper
v-if="shouldUseTopBannerSwiper"
:auto-play="3000"
:loop="true"
:pagination-visible="true"
pagination-color="#ffffff"
pagination-unselected-color="rgba(255, 255, 255, 0.45)"
height="210"
>
<nut-swiper-item
v-for="item in topBanners"
:key="item.id"
>
<view
class="home-banner"
:class="{ clickable: hasLink(item) }"
@tap="handleLinkedItemTap(item)"
>
<image class="home-banner__image" :src="item.image_url" mode="aspectFill" />
</view>
</nut-swiper-item>
</nut-swiper>
<view
v-else
class="home-banner"
:class="{ clickable: hasLink(topBanners[0]) }"
@tap="handleLinkedItemTap(topBanners[0])"
>
<image class="home-banner__image" :src="topBanners[0].image_url" mode="aspectFill" />
</view>
</view>
<view class="nav-panel">
<view
v-for="item in nav_list"
v-for="item in homeIcons"
:key="item.id"
class="nav-entry"
:class="{ clickable: hasLink(item) }"
......@@ -30,7 +59,7 @@
<view class="image-link-list">
<view
v-for="item in image_links"
v-for="item in bottomBanners"
:key="item.id"
class="image-link-card"
:class="{ clickable: hasLink(item) }"
......@@ -46,42 +75,83 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import AppTabbar from '@/components/AppTabbar.vue'
import { getHomeContentAPI } from '@/api'
import { buildWebviewPreviewUrl } from '@/utils/webview'
import { isMockEnabled } from '@/utils/config'
const default_icon = 'icon-jingxiuying'
const banner = ref({})
const nav_list = ref([])
const image_links = ref([])
const defaultIcon = 'icon-jingxiuying'
const homeContent = ref({
volunteer_top_banner: [],
volunteer_bottom_banner: [],
volunteer_home_icon: [],
title: '',
color: '',
})
const topBanners = computed(() => homeContent.value.volunteer_top_banner || [])
const bottomBanners = computed(() => homeContent.value.volunteer_bottom_banner || [])
const homeIcons = computed(() => homeContent.value.volunteer_home_icon || [])
const hasTopBanner = computed(() => topBanners.value.some((item) => item?.image_url))
const shouldUseTopBannerSwiper = computed(() => topBanners.value.filter((item) => item?.image_url).length > 1)
const isMockHomeData = computed(() => isMockEnabled())
const getItemTargetUrl = (item) => String(item?.target_url || '').trim()
const hasLink = (item) => !!(item?.page_url || item?.link_url)
const hasLink = (item) => !!getItemTargetUrl(item)
const isInternalMiniProgramPath = (url) => String(url || '').startsWith('/pages/')
const getIconClass = (item) => {
const icon = item?.icon || default_icon
const font_class = icon.startsWith('fa-') ? 'fa' : 'iconfont'
return [font_class, icon]
const icon = item?.icon || defaultIcon
const fontClass = icon.startsWith('fa-') ? 'fa' : 'iconfont'
return [fontClass, icon]
}
const handleLinkedItemTap = (item) => {
if (!item) return
if (!item || !hasLink(item)) return
const targetUrl = getItemTargetUrl(item)
if (item.page_url) {
if (isInternalMiniProgramPath(targetUrl)) {
Taro.navigateTo({
url: item.page_url,
url: targetUrl,
})
return
}
if (item.link_url) {
Taro.navigateTo({
url: buildWebviewPreviewUrl(item.link_url, item.link_title || item.title || ''),
})
}
Taro.navigateTo({
url: buildWebviewPreviewUrl(targetUrl, item?.title || ''),
})
}
const normalizeHomeContent = (data = {}) => ({
volunteer_top_banner: Array.isArray(data.volunteer_top_banner)
? data.volunteer_top_banner.map((item) => ({
...item,
image_url: item?.link || '',
target_url: item?.value || '',
}))
: [],
volunteer_bottom_banner: Array.isArray(data.volunteer_bottom_banner)
? data.volunteer_bottom_banner.map((item) => ({
...item,
image_url: item?.link || '',
target_url: item?.value || '',
}))
: [],
volunteer_home_icon: Array.isArray(data.volunteer_home_icon)
? data.volunteer_home_icon.map((item) => ({
...item,
target_url: item?.link || '',
}))
: [],
title: data.title || '',
color: data.color || '',
})
const fetchHomeContent = async () => {
const response = await getHomeContentAPI()
......@@ -93,10 +163,7 @@ const fetchHomeContent = async () => {
return
}
const data = response?.data || {}
banner.value = data.banner || {}
nav_list.value = data.nav_list || []
image_links.value = data.image_links || []
homeContent.value = normalizeHomeContent(response?.data)
}
useLoad(() => {
......@@ -114,8 +181,28 @@ useLoad(() => {
box-sizing: border-box;
}
.home-banner {
.home-top-banner {
position: relative;
margin: -32rpx -32rpx 0;
}
.mock-state-badge {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 3;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.42);
font-size: 20rpx;
line-height: 1;
color: #ffffff;
letter-spacing: 1rpx;
text-transform: uppercase;
pointer-events: none;
}
.home-banner {
height: 420rpx;
overflow: hidden;
background: #e5e7eb;
......
/*
* @Description: API 环境配置
* 现在接口有的没有 f, 有的 f 不一样, 每个接口单独传 f 参数
* @Note: 当前环境只由构建配置控制;本地开发默认 mock,生产构建默认正式环境
*/
......@@ -9,7 +10,7 @@ export const API_ENVIRONMENTS = {
label: '正式环境',
baseURL: 'https://oa.onwall.cn',
requestDefaultParams: {
f: 'room',
// f: 'room',
client_id: '772428',
},
useMock: false,
......@@ -19,7 +20,7 @@ export const API_ENVIRONMENTS = {
label: '本地 Mock 环境',
baseURL: 'https://oa.onwall.cn',
requestDefaultParams: {
f: 'room',
// f: 'room',
client_id: '772428',
},
useMock: true,
......