hookehuyr

feat(product-detail): 添加收藏功能和附件下载功能

- 头部热卖标签旁添加收藏按钮,支持切换收藏状态
- 相关附件列表添加下载功能,点击下载图标打开文件
- Mock 不同文档类型展示(PDF/Word/PPT/Excel),便于测试
- 实现智能提示系统:Office 文档同页面仅首次打开时提示
- 附件下方显示灰色提示文字,引导用户手动保存
- 完整的错误处理和网络异常提示
- 统一使用"打开中..."作为加载提示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -13,15 +13,30 @@
:src="bannerImage"
mode="aspectFill"
/>
<div class="absolute top-[32rpx] right-[32rpx] bg-red-500 text-white text-[24rpx] px-[16rpx] py-[8rpx] rounded-full shadow-sm">
热卖
<div class="absolute top-[32rpx] right-[32rpx] flex gap-[16rpx]">
<!-- Hot Tag -->
<div class="bg-red-500 text-white text-[24rpx] px-[16rpx] py-[8rpx] rounded-full shadow-sm">
热卖
</div>
<!-- Favorite Button -->
<div
class="w-[64rpx] h-[48rpx] rounded-full shadow-md flex items-center justify-center"
:class="isCollected ? 'bg-[#F59E0B]' : 'bg-white'"
@tap="toggleCollect"
>
<IconFont
:name="isCollected ? 'StarFill' : 'Star'"
:size="20"
:color="isCollected ? 'white' : '#9CA3AF'"
/>
</div>
</div>
</div>
<!-- Product Header -->
<div class="relative mt-[-40rpx] bg-white rounded-t-[40rpx] px-[40rpx] pt-[48rpx] pb-[40rpx] z-10">
<h1 class="text-[#1F2937] text-[44rpx] font-bold mb-[24rpx]">终身寿险尊享版</h1>
<div class="flex flex-wrap gap-[16rpx]">
<div class="px-[16rpx] py-[6rpx] bg-red-50 rounded-[8rpx]">
<span class="text-red-600 text-[24rpx]">收益率3.5%</span>
......@@ -38,8 +53,8 @@
<!-- Stats Grid -->
<div class="px-[32rpx] mt-[24rpx]">
<div class="grid grid-cols-2 gap-[24rpx]">
<div
v-for="(item, index) in stats"
<div
v-for="(item, index) in stats"
:key="index"
class="bg-white rounded-[24rpx] p-[32rpx] border border-gray-100"
>
......@@ -72,19 +87,21 @@
<div class="bg-white rounded-[32rpx] p-[40rpx]">
<h2 class="text-[#1F2937] text-[32rpx] font-bold mb-[32rpx]">相关附件</h2>
<div class="flex flex-col gap-[24rpx]">
<div
v-for="(file, index) in files"
<div
v-for="(file, index) in files"
:key="index"
class="flex items-center justify-between p-[24rpx] bg-gray-50 rounded-[16rpx]"
class="flex flex-col p-[24rpx] bg-gray-50 rounded-[16rpx]"
>
<div class="flex items-center flex-1 mr-[24rpx]">
<IconFont name="Order" size="24" color="#EF4444" customClass="mr-[24rpx]" />
<div class="flex flex-col">
<span class="text-[#1F2937] text-[28rpx] font-medium mb-[4rpx] line-clamp-1">{{ file.name }}</span>
<span class="text-[#9CA3AF] text-[24rpx]">{{ file.size }}</span>
<div class="flex items-center justify-between mb-[8rpx]">
<div class="flex items-center flex-1 mr-[24rpx]">
<IconFont :name="file.iconName" size="24" :color="file.iconColor" customClass="mr-[24rpx]" />
<div class="flex flex-col">
<span class="text-[#1F2937] text-[28rpx] font-medium mb-[4rpx] line-clamp-1">{{ file.name }}</span>
<span class="text-[#9CA3AF] text-[24rpx]">{{ file.size }}</span>
</div>
</div>
<IconFont name="Download" size="20" color="#2563EB" @tap="onDownload(file)" />
</div>
<IconFont name="Download" size="20" color="#9CA3AF" />
</div>
</div>
</div>
......@@ -100,6 +117,7 @@ import { ref } from 'vue'
import NavHeader from '@/components/NavHeader.vue'
import TabBar from '@/components/TabBar.vue'
import IconFont from '@/components/IconFont.vue'
import Taro from '@tarojs/taro'
// Random banner image
const bannerImage = `https://picsum.photos/seed/${Math.floor(Math.random() * 1000)}/750/420`
......@@ -119,9 +137,197 @@ const features = ref([
])
const files = ref([
{ name: '产品条款.pdf', size: '2.3MB' },
{ name: '投保须知.pdf', size: '1.8MB' },
{ name: '健康告知.pdf', size: '980KB' },
{ name: '保险责任说明.pdf', size: '1.5MB' }
{
name: '产品条款.pdf',
size: '2.3MB',
fileName: '产品条款.pdf',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/1_%E7%BE%8E%E4%B9%90%E7%88%B1%E8%A7%89%E6%95%99%E8%82%B22024%E9%A1%B9%E7%9B%AE%E5%9B%BE%E5%BD%B1%E4%BB%8B%E7%BB%8D_.pdf',
iconName: 'Order',
iconColor: '#EF4444',
fileType: 'pdf',
showTip: false,
tipText: ''
},
{
name: '投保须知.docx',
size: '1.8MB',
fileName: '投保须知.docx',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/%E8%80%81%E6%9D%A5%E8%B5%9B%E9%9A%90%E7%A7%81%E6%94%BF%E7%AD%96.docx',
iconName: 'Order',
iconColor: '#2563EB',
fileType: 'docx',
showTip: true,
tipText: 'Word 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
},
{
name: '健康告知.pptx',
size: '3.2MB',
fileName: '健康告知.pptx',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/%E8%82%A1%E5%88%A4%E5%90%88%E5%8F%8B%E7%94%A8%E7%9F%A5%E8%AF%86%E8%AF%B4%E6%98%8E20240112110417414.pptx',
iconName: 'Order',
iconColor: '#F59E0B',
fileType: 'pptx',
showTip: true,
tipText: 'PPT 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
},
{
name: '保险责任说明.xlsx',
size: '1.5MB',
fileName: '保险责任说明.xlsx',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/%E8%80%81%E6%9D%A5%E8%B5%9B%E9%9A%90%E7%A7%81%E6%94%BF%E7%AD%96.docx',
iconName: 'Order',
iconColor: '#10B981',
fileType: 'xlsx',
showTip: true,
tipText: 'Excel 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
}
])
// 收藏状态
const isCollected = ref(false)
// 记录是否已经提示过 Office 文档预览限制
const hasShownOfficeTip = ref(false)
// 切换收藏状态
const toggleCollect = () => {
isCollected.value = !isCollected.value
if (isCollected.value) {
Taro.showToast({
title: '已收藏',
icon: 'success'
})
} else {
Taro.showToast({
title: '已取消收藏',
icon: 'none'
})
}
}
// 打开文件的通用函数
const openFile = async (filePath, file) => {
try {
await Taro.openDocument({
filePath: filePath,
showMenu: true,
success: () => {
console.log('文件打开成功')
},
fail: (err) => {
console.error('打开文件失败:', err)
const fileExt = file.fileName.split('.').pop()?.toLowerCase() || ''
let message = '文件打开失败'
let suggestion = ''
if (['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(fileExt)) {
message = '暂不支持预览 Office 文档'
suggestion = '\n\n建议:\n1. 点击右上角"..."菜单\n2. 选择"发送给朋友"\n3. 在电脑或支持的应用中打开'
} else if (['pdf'].includes(fileExt)) {
message = 'PDF 文件打开失败'
suggestion = '\n\n文件可能已损坏,请联系管理员'
} else {
message = `暂不支持预览 ${fileExt.toUpperCase()} 格式文件`
suggestion = '\n\n请在电脑或其他应用中打开'
}
Taro.showModal({
title: '提示',
content: message + suggestion,
showCancel: false,
confirmText: '我知道了'
})
}
})
} catch (error) {
console.error('打开文件异常:', error)
Taro.showToast({
title: '打开文件失败',
icon: 'none',
duration: 2000
})
}
}
// 下载并打开文件的函数
const downloadAndOpenFile = async (file) => {
try {
const downloadResult = await Taro.downloadFile({
url: file.downloadUrl
})
if (downloadResult.statusCode !== 200) {
throw new Error(`打开失败: HTTP ${downloadResult.statusCode}`)
}
if (!downloadResult.tempFilePath) {
throw new Error('打开失败: 未获取到文件')
}
Taro.hideLoading()
const fileExt = file.fileName.split('.').pop()?.toLowerCase() || ''
const unsupportedFormats = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls']
if (unsupportedFormats.includes(fileExt)) {
// 对于 Office 文档,如果还没提示过,就提示一次
if (!hasShownOfficeTip.value) {
Taro.showModal({
title: '预览提示',
content: `小程序对 ${fileExt.toUpperCase()} 文档的预览支持有限,如果显示为空白,请点击右上角"..."菜单,选择"发送给朋友"后在电脑或其他应用中打开。\n\n是否继续尝试预览?`,
confirmText: '继续',
cancelText: '取消',
success: (modalRes) => {
if (modalRes.confirm) {
hasShownOfficeTip.value = true // 标记已提示过
openFile(downloadResult.tempFilePath, file)
}
}
})
} else {
// 已经提示过,直接打开
await openFile(downloadResult.tempFilePath, file)
}
} else {
// PDF 等其他格式直接打开
await openFile(downloadResult.tempFilePath, file)
}
} catch (error) {
Taro.hideLoading()
console.error('打开文件出错:', error)
let errorMessage = '打开失败,请重试'
if (error.errMsg && error.errMsg.includes('network')) {
errorMessage = '网络连接失败,请检查网络'
} else if (error.errMsg && error.errMsg.includes('TLS')) {
errorMessage = '安全连接失败,请检查网络'
}
Taro.showToast({
title: errorMessage,
icon: 'none',
duration: 2000
})
}
}
// 下载按钮点击处理
const onDownload = (file) => {
if (!file.downloadUrl) {
Taro.showToast({
title: '该文件暂无下载地址',
icon: 'none',
duration: 2000
})
return
}
Taro.showLoading({
title: '打开中...',
mask: true
})
downloadAndOpenFile(file)
}
</script>
......