hookehuyr

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -13,15 +13,30 @@ ...@@ -13,15 +13,30 @@
13 :src="bannerImage" 13 :src="bannerImage"
14 mode="aspectFill" 14 mode="aspectFill"
15 /> 15 />
16 - <div class="absolute top-[32rpx] right-[32rpx] bg-red-500 text-white text-[24rpx] px-[16rpx] py-[8rpx] rounded-full shadow-sm"> 16 + <div class="absolute top-[32rpx] right-[32rpx] flex gap-[16rpx]">
17 - 热卖 17 + <!-- Hot Tag -->
18 + <div class="bg-red-500 text-white text-[24rpx] px-[16rpx] py-[8rpx] rounded-full shadow-sm">
19 + 热卖
20 + </div>
21 + <!-- Favorite Button -->
22 + <div
23 + class="w-[64rpx] h-[48rpx] rounded-full shadow-md flex items-center justify-center"
24 + :class="isCollected ? 'bg-[#F59E0B]' : 'bg-white'"
25 + @tap="toggleCollect"
26 + >
27 + <IconFont
28 + :name="isCollected ? 'StarFill' : 'Star'"
29 + :size="20"
30 + :color="isCollected ? 'white' : '#9CA3AF'"
31 + />
32 + </div>
18 </div> 33 </div>
19 </div> 34 </div>
20 35
21 <!-- Product Header --> 36 <!-- Product Header -->
22 <div class="relative mt-[-40rpx] bg-white rounded-t-[40rpx] px-[40rpx] pt-[48rpx] pb-[40rpx] z-10"> 37 <div class="relative mt-[-40rpx] bg-white rounded-t-[40rpx] px-[40rpx] pt-[48rpx] pb-[40rpx] z-10">
23 <h1 class="text-[#1F2937] text-[44rpx] font-bold mb-[24rpx]">终身寿险尊享版</h1> 38 <h1 class="text-[#1F2937] text-[44rpx] font-bold mb-[24rpx]">终身寿险尊享版</h1>
24 - 39 +
25 <div class="flex flex-wrap gap-[16rpx]"> 40 <div class="flex flex-wrap gap-[16rpx]">
26 <div class="px-[16rpx] py-[6rpx] bg-red-50 rounded-[8rpx]"> 41 <div class="px-[16rpx] py-[6rpx] bg-red-50 rounded-[8rpx]">
27 <span class="text-red-600 text-[24rpx]">收益率3.5%</span> 42 <span class="text-red-600 text-[24rpx]">收益率3.5%</span>
...@@ -38,8 +53,8 @@ ...@@ -38,8 +53,8 @@
38 <!-- Stats Grid --> 53 <!-- Stats Grid -->
39 <div class="px-[32rpx] mt-[24rpx]"> 54 <div class="px-[32rpx] mt-[24rpx]">
40 <div class="grid grid-cols-2 gap-[24rpx]"> 55 <div class="grid grid-cols-2 gap-[24rpx]">
41 - <div 56 + <div
42 - v-for="(item, index) in stats" 57 + v-for="(item, index) in stats"
43 :key="index" 58 :key="index"
44 class="bg-white rounded-[24rpx] p-[32rpx] border border-gray-100" 59 class="bg-white rounded-[24rpx] p-[32rpx] border border-gray-100"
45 > 60 >
...@@ -72,19 +87,21 @@ ...@@ -72,19 +87,21 @@
72 <div class="bg-white rounded-[32rpx] p-[40rpx]"> 87 <div class="bg-white rounded-[32rpx] p-[40rpx]">
73 <h2 class="text-[#1F2937] text-[32rpx] font-bold mb-[32rpx]">相关附件</h2> 88 <h2 class="text-[#1F2937] text-[32rpx] font-bold mb-[32rpx]">相关附件</h2>
74 <div class="flex flex-col gap-[24rpx]"> 89 <div class="flex flex-col gap-[24rpx]">
75 - <div 90 + <div
76 - v-for="(file, index) in files" 91 + v-for="(file, index) in files"
77 :key="index" 92 :key="index"
78 - class="flex items-center justify-between p-[24rpx] bg-gray-50 rounded-[16rpx]" 93 + class="flex flex-col p-[24rpx] bg-gray-50 rounded-[16rpx]"
79 > 94 >
80 - <div class="flex items-center flex-1 mr-[24rpx]"> 95 + <div class="flex items-center justify-between mb-[8rpx]">
81 - <IconFont name="Order" size="24" color="#EF4444" customClass="mr-[24rpx]" /> 96 + <div class="flex items-center flex-1 mr-[24rpx]">
82 - <div class="flex flex-col"> 97 + <IconFont :name="file.iconName" size="24" :color="file.iconColor" customClass="mr-[24rpx]" />
83 - <span class="text-[#1F2937] text-[28rpx] font-medium mb-[4rpx] line-clamp-1">{{ file.name }}</span> 98 + <div class="flex flex-col">
84 - <span class="text-[#9CA3AF] text-[24rpx]">{{ file.size }}</span> 99 + <span class="text-[#1F2937] text-[28rpx] font-medium mb-[4rpx] line-clamp-1">{{ file.name }}</span>
100 + <span class="text-[#9CA3AF] text-[24rpx]">{{ file.size }}</span>
101 + </div>
85 </div> 102 </div>
103 + <IconFont name="Download" size="20" color="#2563EB" @tap="onDownload(file)" />
86 </div> 104 </div>
87 - <IconFont name="Download" size="20" color="#9CA3AF" />
88 </div> 105 </div>
89 </div> 106 </div>
90 </div> 107 </div>
...@@ -100,6 +117,7 @@ import { ref } from 'vue' ...@@ -100,6 +117,7 @@ import { ref } from 'vue'
100 import NavHeader from '@/components/NavHeader.vue' 117 import NavHeader from '@/components/NavHeader.vue'
101 import TabBar from '@/components/TabBar.vue' 118 import TabBar from '@/components/TabBar.vue'
102 import IconFont from '@/components/IconFont.vue' 119 import IconFont from '@/components/IconFont.vue'
120 +import Taro from '@tarojs/taro'
103 121
104 // Random banner image 122 // Random banner image
105 const bannerImage = `https://picsum.photos/seed/${Math.floor(Math.random() * 1000)}/750/420` 123 const bannerImage = `https://picsum.photos/seed/${Math.floor(Math.random() * 1000)}/750/420`
...@@ -119,9 +137,197 @@ const features = ref([ ...@@ -119,9 +137,197 @@ const features = ref([
119 ]) 137 ])
120 138
121 const files = ref([ 139 const files = ref([
122 - { name: '产品条款.pdf', size: '2.3MB' }, 140 + {
123 - { name: '投保须知.pdf', size: '1.8MB' }, 141 + name: '产品条款.pdf',
124 - { name: '健康告知.pdf', size: '980KB' }, 142 + size: '2.3MB',
125 - { name: '保险责任说明.pdf', size: '1.5MB' } 143 + fileName: '产品条款.pdf',
144 + 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',
145 + iconName: 'Order',
146 + iconColor: '#EF4444',
147 + fileType: 'pdf',
148 + showTip: false,
149 + tipText: ''
150 + },
151 + {
152 + name: '投保须知.docx',
153 + size: '1.8MB',
154 + fileName: '投保须知.docx',
155 + 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',
156 + iconName: 'Order',
157 + iconColor: '#2563EB',
158 + fileType: 'docx',
159 + showTip: true,
160 + tipText: 'Word 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
161 + },
162 + {
163 + name: '健康告知.pptx',
164 + size: '3.2MB',
165 + fileName: '健康告知.pptx',
166 + 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',
167 + iconName: 'Order',
168 + iconColor: '#F59E0B',
169 + fileType: 'pptx',
170 + showTip: true,
171 + tipText: 'PPT 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
172 + },
173 + {
174 + name: '保险责任说明.xlsx',
175 + size: '1.5MB',
176 + fileName: '保险责任说明.xlsx',
177 + 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',
178 + iconName: 'Order',
179 + iconColor: '#10B981',
180 + fileType: 'xlsx',
181 + showTip: true,
182 + tipText: 'Excel 文档可能无法正常显示,建议打开后点击右上角"..."菜单选择"发送给朋友"保存到电脑查看'
183 + }
126 ]) 184 ])
185 +
186 +// 收藏状态
187 +const isCollected = ref(false)
188 +
189 +// 记录是否已经提示过 Office 文档预览限制
190 +const hasShownOfficeTip = ref(false)
191 +
192 +// 切换收藏状态
193 +const toggleCollect = () => {
194 + isCollected.value = !isCollected.value
195 + if (isCollected.value) {
196 + Taro.showToast({
197 + title: '已收藏',
198 + icon: 'success'
199 + })
200 + } else {
201 + Taro.showToast({
202 + title: '已取消收藏',
203 + icon: 'none'
204 + })
205 + }
206 +}
207 +
208 +// 打开文件的通用函数
209 +const openFile = async (filePath, file) => {
210 + try {
211 + await Taro.openDocument({
212 + filePath: filePath,
213 + showMenu: true,
214 + success: () => {
215 + console.log('文件打开成功')
216 + },
217 + fail: (err) => {
218 + console.error('打开文件失败:', err)
219 +
220 + const fileExt = file.fileName.split('.').pop()?.toLowerCase() || ''
221 + let message = '文件打开失败'
222 + let suggestion = ''
223 +
224 + if (['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(fileExt)) {
225 + message = '暂不支持预览 Office 文档'
226 + suggestion = '\n\n建议:\n1. 点击右上角"..."菜单\n2. 选择"发送给朋友"\n3. 在电脑或支持的应用中打开'
227 + } else if (['pdf'].includes(fileExt)) {
228 + message = 'PDF 文件打开失败'
229 + suggestion = '\n\n文件可能已损坏,请联系管理员'
230 + } else {
231 + message = `暂不支持预览 ${fileExt.toUpperCase()} 格式文件`
232 + suggestion = '\n\n请在电脑或其他应用中打开'
233 + }
234 +
235 + Taro.showModal({
236 + title: '提示',
237 + content: message + suggestion,
238 + showCancel: false,
239 + confirmText: '我知道了'
240 + })
241 + }
242 + })
243 + } catch (error) {
244 + console.error('打开文件异常:', error)
245 + Taro.showToast({
246 + title: '打开文件失败',
247 + icon: 'none',
248 + duration: 2000
249 + })
250 + }
251 +}
252 +
253 +// 下载并打开文件的函数
254 +const downloadAndOpenFile = async (file) => {
255 + try {
256 + const downloadResult = await Taro.downloadFile({
257 + url: file.downloadUrl
258 + })
259 +
260 + if (downloadResult.statusCode !== 200) {
261 + throw new Error(`打开失败: HTTP ${downloadResult.statusCode}`)
262 + }
263 +
264 + if (!downloadResult.tempFilePath) {
265 + throw new Error('打开失败: 未获取到文件')
266 + }
267 +
268 + Taro.hideLoading()
269 +
270 + const fileExt = file.fileName.split('.').pop()?.toLowerCase() || ''
271 + const unsupportedFormats = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls']
272 +
273 + if (unsupportedFormats.includes(fileExt)) {
274 + // 对于 Office 文档,如果还没提示过,就提示一次
275 + if (!hasShownOfficeTip.value) {
276 + Taro.showModal({
277 + title: '预览提示',
278 + content: `小程序对 ${fileExt.toUpperCase()} 文档的预览支持有限,如果显示为空白,请点击右上角"..."菜单,选择"发送给朋友"后在电脑或其他应用中打开。\n\n是否继续尝试预览?`,
279 + confirmText: '继续',
280 + cancelText: '取消',
281 + success: (modalRes) => {
282 + if (modalRes.confirm) {
283 + hasShownOfficeTip.value = true // 标记已提示过
284 + openFile(downloadResult.tempFilePath, file)
285 + }
286 + }
287 + })
288 + } else {
289 + // 已经提示过,直接打开
290 + await openFile(downloadResult.tempFilePath, file)
291 + }
292 + } else {
293 + // PDF 等其他格式直接打开
294 + await openFile(downloadResult.tempFilePath, file)
295 + }
296 + } catch (error) {
297 + Taro.hideLoading()
298 + console.error('打开文件出错:', error)
299 +
300 + let errorMessage = '打开失败,请重试'
301 + if (error.errMsg && error.errMsg.includes('network')) {
302 + errorMessage = '网络连接失败,请检查网络'
303 + } else if (error.errMsg && error.errMsg.includes('TLS')) {
304 + errorMessage = '安全连接失败,请检查网络'
305 + }
306 +
307 + Taro.showToast({
308 + title: errorMessage,
309 + icon: 'none',
310 + duration: 2000
311 + })
312 + }
313 +}
314 +
315 +// 下载按钮点击处理
316 +const onDownload = (file) => {
317 + if (!file.downloadUrl) {
318 + Taro.showToast({
319 + title: '该文件暂无下载地址',
320 + icon: 'none',
321 + duration: 2000
322 + })
323 + return
324 + }
325 +
326 + Taro.showLoading({
327 + title: '打开中...',
328 + mask: true
329 + })
330 +
331 + downloadAndOpenFile(file)
332 +}
127 </script> 333 </script>
......