古刹巡礼详细页面开发

This commit is contained in:
WindowBird 2025-08-14 10:50:12 +08:00
parent 5303ae03d8
commit 00dcc597f3
6 changed files with 512 additions and 256 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ node_modules/
unpackage/ unpackage/
.DS_Store. .DS_Store.
/config/dev.js /config/dev.js
.idea/

151
README_ancientTourById.md Normal file
View File

@ -0,0 +1,151 @@
# 古刹巡礼详情页面使用说明
## 新增API接口
### 1. 获取巡礼详情 - `/app/article/tourById`
**接口地址:** `GET /app/article/tourById`
**请求参数:**
- `id` (string): 巡礼ID
**返回数据:**
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"title": "寒山寺",
"content": "<p>内容内容内容...</p>",
"createTime": "2025-07-21 17:33:32",
"coverUrl": "https://example.com/image.jpg"
}
}
```
### 2. 获取相关文章 - `/app/article/relevant`
**接口地址:** `GET /app/article/relevant`
**请求参数:**
- `articleId` (string): 当前文章ID
**返回数据:**
```json
{
"msg": "操作成功",
"code": 200,
"data": [
{
"id": "25",
"title": "我是标题",
"subtitle": "我是副标题",
"createTime": "2025-07-21 17:33:32"
}
]
}
```
## 页面功能
### 主要特性
1. **文章详情展示**
- 显示文章标题
- 显示创建时间
- 显示封面图片
- 支持HTML内容渲染
2. **相关阅读推荐**
- 显示相关文章列表
- 支持点击跳转到相关文章
- 显示文章标题、副标题和时间
3. **响应式设计**
- 适配不同屏幕尺寸
- 现代化的卡片式布局
- 优雅的加载状态
### 使用方法
#### 1. 页面跳转
```javascript
// 跳转到指定巡礼详情页
uni.navigateTo({
url: `/pages/ancient/ancientTourById?id=24`
});
```
#### 2. 获取数据
```javascript
import { getTourById, getRelevantArticles } from '@/api/article/article.js';
// 获取巡礼详情
const tourDetail = await getTourById('24');
// 获取相关文章
const relevantArticles = await getRelevantArticles('24');
```
### 页面结构
```
页面布局:
├── 自定义导航栏
├── 测试按钮(开发时显示)
├── 文章头部信息
│ ├── 文章标题
│ └── 创建时间
├── 封面图片
├── 文章内容
│ └── HTML内容渲染
└── 相关阅读区域
├── 区域标题
└── 相关文章列表
├── 文章标题
├── 副标题
└── 创建时间
```
### 样式特点
1. **卡片式设计**
- 白色背景卡片
- 圆角边框
- 阴影效果
2. **颜色搭配**
- 主色调:白色背景
- 文字颜色:深灰色
- 辅助色:浅灰色
3. **交互效果**
- 点击反馈
- 平滑过渡动画
- 加载状态提示
### 开发说明
1. **API接口已添加到 `api/article/article.js`**
2. **页面路径:`pages/ancient/ancientTourById.vue`**
3. **支持参数传递:通过 `options.id` 获取巡礼ID默认为24**
4. **测试功能:页面包含测试按钮,方便开发调试**
### 注意事项
1. **HTML内容安全**
- 使用 `rich-text` 组件渲染HTML
- 添加基本样式确保显示效果
2. **错误处理**
- API调用失败时显示友好提示
- 相关文章获取失败不影响主页面
3. **性能优化**
- 使用计算属性处理HTML内容
- 合理的时间格式化处理
4. **用户体验**
- 加载状态提示
- 点击反馈效果
- 响应式布局适配

View File

@ -77,3 +77,33 @@ export function getTempleTours(params = {}) {
params params
}) })
} }
/**
* 根据ID获取古刹巡礼详情
* @param {string} id - 巡礼ID
* @returns {Promise} 返回巡礼详情数据包含titlecontentcreateTimecoverUrl
*/
export function getTourById(id) {
return request({
url: '/app/article/tourById',
method: 'GET',
params: {
id: id
}
})
}
/**
* 获取相关文章列表
* @param {string} articleId - 当前文章ID
* @returns {Promise} 返回相关文章列表包含titlesubtitlecreateTime
*/
export function getRelevantArticles(articleId) {
return request({
url: '/app/article/relevant',
method: 'GET',
params: {
articleId: articleId
}
})
}

View File

@ -49,7 +49,7 @@ export default {
onSearch() { onSearch() {
// //
if (this.value.trim()) { if (this.value.trim()) {
this.$emit('search', this.value) this.$emit('search', this.value)
} }
} }
} }

View File

@ -1,25 +1,65 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 使用自定义导航栏组件 --> <!-- 使用自定义导航栏组件 -->
<custom-navbar title="走进平山" <custom-navbar :title="tourDetail.title || '古刹巡礼'"
ref="customNavbar" ref="customNavbar"
/> />
<view class="header" :style="{ backgroundColor: CommonEnum.BASE_COLOR }"> <tile-grid/>
<image class="temple-image" :src="templeInfo.imgUrl" mode="aspectFill"></image>
<view class="temple-info"> <!-- 测试按钮开发时使用 -->
<text class="temple-desc">{{ stripHtmlTags(templeInfo.content) || '暂无描述' }}</text> <view class="test-buttons" v-if="!tourId">
<button class="test-btn" @click="testWithId('24')">测试寒山寺</button>
<button class="test-btn" @click="testWithId('25')">测试万佛塔</button>
</view>
<!-- 主要内容区域 -->
<view class="content-container">
<!-- 文章头部信息 -->
<view class="article-header">
<text class="article-title">{{ tourDetail.title || '加载中...' }}</text>
<text class="article-time">{{ formatTime(tourDetail.createTime) }}</text>
</view> </view>
<!-- 联系信息区域 --> <!-- 封面图片 -->
<view class="contact-info" v-if="templeInfo.phone || templeInfo.address"> <view class="cover-image-container" v-if="tourDetail.coverUrl">
<view class="contact-item" v-if="templeInfo.phone"> <image class="cover-image"
<text class="contact-label">电话</text> :src="tourDetail.coverUrl"
<text class="contact-value" @click="callPhone">{{ templeInfo.phone }}</text> mode="aspectFill"
@error="handleImageError">
</image>
</view>
<!-- 文章内容 -->
<view class="article-content" v-if="tourDetail.content">
<rich-text :nodes="processedContent"></rich-text>
</view>
<!-- 加载状态 -->
<view class="loading-container" v-if="loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 相关阅读区域 -->
<view class="relevant-section" v-if="relevantArticles.length > 0">
<view class="section-title">
<text class="title-text">相关阅读</text>
</view> </view>
<view class="contact-item" v-if="templeInfo.address" @click="openMap"> <view class="relevant-list">
<text class="contact-label">地址</text> <view class="relevant-item"
<text class="contact-value">{{ templeInfo.address }}</text> v-for="(item, index) in relevantArticles"
<image class="nav-arrow" :src="CommonEnum.NAV_ARROW" mode="aspectFit"></image> :key="index"
@click="goToArticle(item)">
<view class="item-content">
<text class="item-title">{{ item.title }}</text>
<view class="item-meta">
<text class="item-subtitle" v-if="item.subtitle">{{ item.subtitle }}</text>
<text class="item-time">{{ formatTime(item.createTime) }}</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow">></text>
</view>
</view>
</view> </view>
</view> </view>
</view> </view>
@ -31,8 +71,9 @@ import {
CommonEnum CommonEnum
} from '@/enum/common.js' } from '@/enum/common.js'
import { import {
getTempleInfo getTourById,
} from '@/api/walkInto/walkInto.js' getRelevantArticles
} from '@/api/article/article.js'
import CustomNavbar from "../../components/custom-navbar/custom-navbar.vue"; import CustomNavbar from "../../components/custom-navbar/custom-navbar.vue";
export default { export default {
@ -42,215 +83,135 @@ export default {
data() { data() {
return { return {
CommonEnum, CommonEnum,
templeInfo: { tourId: '24', // ID24
id: '', tourDetail: {
name: '',
title: '', title: '',
content: '', content: '',
imgUrl: '', createTime: '',
address: '', coverUrl: ''
phone: '',
startTime: '',
endTime: '',
lon: null,
lat: null
}, },
relevantArticles: [], //
loading: false loading: false
} }
}, },
onLoad() { computed: {
this.fetchTempleInfo() // HTML
processedContent() {
if (!this.tourDetail.content) return '';
// HTML
let content = this.tourDetail.content;
//
content = `<div style="
font-size: 28rpx;
line-height: 1.8;
color: #333;
word-wrap: break-word;
word-break: break-all;
padding: 20rpx 0;
">${content}</div>`;
return content;
}
},
onLoad(options) {
// 使24
if (options.id) {
this.tourId = options.id;
}
//
this.fetchTourDetail();
this.fetchRelevantArticles();
}, },
methods: { methods: {
async fetchTempleInfo() { //
async fetchTourDetail() {
try { try {
this.loading = true this.loading = true;
const res = await getTempleInfo() const res = await getTourById(this.tourId);
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
this.templeInfo = res.data this.tourDetail = {
title: res.data.title || '',
content: res.data.content || '',
createTime: res.data.createTime || '',
coverUrl: res.data.coverUrl || ''
};
} else { } else {
uni.showToast({ uni.showToast({
title: res.msg || '获取寺庙信息失败', title: res.msg || '获取文章详情失败',
icon: 'none' icon: 'none'
}) });
} }
} catch (e) { } catch (error) {
console.error('获取寺庙信息失败:', e) console.error('获取文章详情失败:', error);
uni.showToast({ uni.showToast({
title: '网络错误', title: '网络错误',
icon: 'none' icon: 'none'
}) });
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// HTML //
stripHtmlTags(html) { async fetchRelevantArticles() {
if (!html) return ''; // try {
const res = await getRelevantArticles(this.tourId);
// HTML if (res.code === 200 && res.data) {
let text = html.replace(/<[^>]+>/g, ''); this.relevantArticles = res.data.map(item => ({
id: item.id,
// HTML title: item.title || '',
text = text.replace(/&nbsp;/g, ' '); subtitle: item.subtitle || '',
text = text.replace(/&amp;/g, '&'); createTime: item.createTime || ''
text = text.replace(/&lt;/g, '<'); }));
text = text.replace(/&gt;/g, '>'); }
text = text.replace(/&quot;/g, '"'); } catch (error) {
console.error('获取相关文章失败:', error);
// //
//
text = text.replace(/\s{2,}/g, '\n');
//
text = text.replace(/^\s+/gm, '');
//
text = text.replace(/\n\s*\n/g, '\n');
//
text = text.replace(/^/gm, ' ');
//
text = text.replace(/电话:.*$/gm, '');
text = text.replace(/地址:.*$/gm, '');
return text.trim();
},
//
callPhone() {
if (this.templeInfo.phone) {
uni.makePhoneCall({
phoneNumber: this.templeInfo.phone,
success: () => {
console.log('拨打电话成功')
},
fail: (err) => {
console.error('拨打电话失败:', err)
uni.showToast({
title: '拨打电话失败',
icon: 'none'
})
}
})
} }
}, },
// //
openMap() { formatTime(timeStr) {
if (this.templeInfo.address && this.templeInfo.lon && this.templeInfo.lat) { if (!timeStr) return '';
// #ifdef H5
// H5使
this.openMapByAddress()
// #endif
// #ifndef H5 try {
// H5使 const date = new Date(timeStr);
uni.openLocation({ const year = date.getFullYear();
latitude: parseFloat(this.templeInfo.lat), const month = String(date.getMonth() + 1).padStart(2, '0');
longitude: parseFloat(this.templeInfo.lon), const day = String(date.getDate()).padStart(2, '0');
name: this.templeInfo.name || '寺庙', const hours = String(date.getHours()).padStart(2, '0');
address: this.templeInfo.address, const minutes = String(date.getMinutes()).padStart(2, '0');
success: () => {
console.log('打开地图成功') return `${year}-${month}-${day} ${hours}:${minutes}`;
}, } catch (error) {
fail: (err) => { return timeStr;
console.error('打开地图失败:', err)
// 使
this.openMapByAddress()
}
})
// #endif
} else if (this.templeInfo.address) {
// 使
this.openMapByAddress()
} }
}, },
// 使 //
openMapByAddress() { goToArticle(article) {
// #ifdef H5 if (article.id) {
// H5 uni.navigateTo({
uni.showActionSheet({ url: `/pages/ancient/ancientTourById?id=${article.id}`
itemList: ['复制地址', '百度地图', '高德地图', '腾讯地图'], });
success: (res) => { }
switch (res.tapIndex) { },
case 0:
//
this.copyAddress()
break
case 1:
//
this.openBaiduMap()
break
case 2:
//
this.openGaodeMap()
break
case 3:
//
this.openTencentMap()
break
}
}
})
// #endif
// #ifndef H5 //
// H5 handleImageError() {
uni.showModal({ console.log('封面图片加载失败');
title: '提示', //
content: '是否复制地址到剪贴板?', },
success: (res) => {
if (res.confirm) {
this.copyAddress()
}
}
})
// #endif
},
// // 使
copyAddress() { testWithId(id) {
uni.setClipboardData({ this.tourId = id;
data: this.templeInfo.address, this.fetchTourDetail();
success: () => { this.fetchRelevantArticles();
uni.showToast({
title: '地址已复制',
icon: 'success',
duration: 2000
})
},
fail: () => {
uni.showToast({
title: '复制失败',
icon: 'none'
})
}
})
},
//
openBaiduMap() {
const address = encodeURIComponent(this.templeInfo.address)
const url = `https://api.map.baidu.com/geocoder?address=${address}&output=html&src=webapp.baidu.openAPIdemo`
window.open(url, '_blank')
},
//
openGaodeMap() {
const address = encodeURIComponent(this.templeInfo.address)
const url = `https://uri.amap.com/search?query=${address}`
window.open(url, '_blank')
},
//
openTencentMap() {
const address = encodeURIComponent(this.templeInfo.address)
const url = `https://apis.map.qq.com/uri/v1/search?keyword=${address}&referer=myapp`
window.open(url, '_blank')
} }
} }
} }
@ -258,87 +219,200 @@ export default {
<style lang="scss"> <style lang="scss">
.page { .page {
background: #F5F0E7; background: #F6F1E7;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.header { .content-container {
margin: 26rpx 28rpx;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 15rpx;
padding-bottom: 40rpx;
border-radius: 16rpx 16rpx 16rpx 16rpx;
}
.temple-image {
width: 610rpx;
height: 324rpx;
background: #D8D8D8; /* 灰色占位背景 */
border-radius: 12rpx 12rpx 12rpx 12rpx;
display: block;
margin: 34rpx auto;
object-fit: cover;
}
.temple-info {
width: 100%; width: 100%;
padding: 24rpx 42rpx; padding: 20rpx;
border-radius: 20rpx 20rpx 20rpx 20rpx; box-sizing: border-box;
} }
.temple-desc { /* 文章头部样式 */
.article-header {
background: #fff;
padding: 40rpx 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.article-title {
display: block; display: block;
font-weight: 400; font-size: 36rpx;
font-size: 28rpx; font-weight: bold;
color: #522510; color: #333;
line-height: 45rpx; line-height: 1.4;
text-align: left; margin-bottom: 20rpx;
font-style: normal;
text-transform: none;
white-space: pre-line; /* 保留换行符 */
word-wrap: break-word; /* 长单词换行 */
} }
/* 联系信息样式 */ .article-time {
.contact-info { display: block;
margin-top: 40rpx; font-size: 24rpx;
color: #999;
line-height: 1.2;
}
/* 封面图片样式 */
.cover-image-container {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.cover-image {
width: 100%;
height: 400rpx;
display: block;
}
/* 文章内容样式 */
.article-content {
background: #fff;
padding: 30rpx; padding: 30rpx;
border-radius: 16rpx; border-radius: 16rpx;
}
.contact-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx; margin-bottom: 20rpx;
line-height: 40rpx; box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
min-height: 200rpx;
} }
.contact-item:last-child { /* 加载状态样式 */
margin-bottom: 0; .loading-container {
background: #fff;
padding: 60rpx 30rpx;
border-radius: 16rpx;
text-align: center;
margin-bottom: 20rpx;
} }
.contact-label { .loading-text {
font-size: 28rpx; font-size: 28rpx;
min-width: 100rpx; color: #999;
color: #522510; }
/* 相关阅读区域样式 */
.relevant-section {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.section-title {
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
background: #f8f8f8;
}
.title-text {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.relevant-list {
padding: 0;
}
.relevant-item {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
transition: background-color 0.3s ease;
}
.relevant-item:last-child {
border-bottom: none;
}
.relevant-item:active {
background-color: #f5f5f5;
}
.item-content {
flex: 1;
margin-right: 20rpx;
}
.item-title {
display: block;
font-size: 28rpx;
font-weight: 500;
color: #333;
line-height: 1.4;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-subtitle {
font-size: 24rpx;
color: #666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 20rpx;
}
.item-time {
font-size: 22rpx;
color: #999;
flex-shrink: 0; flex-shrink: 0;
} }
.contact-value { .item-arrow {
font-size: 28rpx; width: 40rpx;
color: #522510; height: 40rpx;
flex: 1; display: flex;
word-wrap: break-word; align-items: center;
justify-content: center;
} }
.nav-arrow { .arrow {
width: 32rpx; font-size: 24rpx;
height: 32rpx; color: #ccc;
font-weight: bold;
}
/* 测试按钮样式 */
.test-buttons {
position: fixed;
top: 100rpx; /* 根据导航栏高度调整 */
left: 20rpx;
z-index: 10;
background-color: #fff;
padding: 20rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.test-btn {
margin-bottom: 10rpx;
background-color: #4CAF50;
color: #fff;
font-size: 28rpx;
padding: 15rpx 30rpx;
border-radius: 8rpx;
border: none;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
.test-btn:last-child {
margin-bottom: 0;
} }
</style> </style>

View File

@ -17,8 +17,8 @@ import {
// 环境配置 // 环境配置
const ENV_CONFIG = { const ENV_CONFIG = {
develop: { // 开发环境 develop: { // 开发环境
// baseUrl: 'http://192.168.2.136:4501', // baseUrl: 'http://192.168.2.136:4501',
baseUrl: 'https://testlu.chuangtewl.com/prod-api', baseUrl: 'https://testlu.chuangtewl.com/prod-api',
appId: 1 // TODO: 根据实际后端配置调整 appId: 1 // TODO: 根据实际后端配置调整
}, },
trial: { // 体验版 trial: { // 体验版