hookehuyr

feat: 添加埋点功能和图片预览支持

新增功能:
- 创建通用埋点 Composable (useEventTracking)
  - 支持 READ_FILE 等事件类型追踪
  - 静默处理错误,不影响用户体验
  - 提供便捷方法 trackFileRead()

- ListItemActions 组件集成埋点
  - 新增 itemId prop,自动发送埋点数据
  - 向后兼容,不破坏现有代码

- 图片预览支持
  - useFileOperation 添加 isImageFile() 判断
  - 图片文件使用 Taro.previewImage 直接预览
  - 支持 jpg, jpeg, png, gif, webp, bmp, svg 格式

- 产品详情页文件预览
  - 集成 useFileOperation
  - 自动判断文件类型并选择最优预览方式

文档:
- 更新 CHANGELOG 记录变更
- 新增埋点功能使用指南

技术栈:Vue 3, Taro 4, Composition API

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -18,6 +18,7 @@ declare module 'vue' { ...@@ -18,6 +18,7 @@ declare module 'vue' {
18 NutButton: typeof import('@nutui/nutui-taro')['Button'] 18 NutButton: typeof import('@nutui/nutui-taro')['Button']
19 NutEmpty: typeof import('@nutui/nutui-taro')['Empty'] 19 NutEmpty: typeof import('@nutui/nutui-taro')['Empty']
20 NutInput: typeof import('@nutui/nutui-taro')['Input'] 20 NutInput: typeof import('@nutui/nutui-taro')['Input']
21 + NutLoading: typeof import('@nutui/nutui-taro')['Loading']
21 NutPicker: typeof import('@nutui/nutui-taro')['Picker'] 22 NutPicker: typeof import('@nutui/nutui-taro')['Picker']
22 NutPopup: typeof import('@nutui/nutui-taro')['Popup'] 23 NutPopup: typeof import('@nutui/nutui-taro')['Popup']
23 NutRadio: typeof import('@nutui/nutui-taro')['Radio'] 24 NutRadio: typeof import('@nutui/nutui-taro')['Radio']
......
...@@ -5,6 +5,66 @@ ...@@ -5,6 +5,66 @@
5 5
6 --- 6 ---
7 7
8 +## [2026-02-05] - 产品详情页图片预览支持
9 +
10 +### 新增
11 +- **图片预览支持**: 更新 `useFileOperation` Composable
12 + - 添加 `isImageFile()` 辅助函数,判断文件是否为图片
13 + - 更新 `viewFile()` 方法,图片文件使用 `Taro.previewImage` 预览
14 + - 支持的图片格式:jpg, jpeg, png, gif, webp, bmp, svg
15 +- **产品详情页图片预览**: 产品详情页附件支持图片预览
16 + - 集成 `useFileOperation` Composable
17 + - 点击附件时自动判断文件类型并使用对应的预览方式
18 +
19 +### 优化
20 +- 统一文件预览流程:
21 + - 图片文件 → `Taro.previewImage`(直接预览)
22 + - 视频文件 → 跳转视频播放页面
23 + - 其他文件 → 下载后使用 `Taro.openDocument` 打开
24 +
25 +---
26 +
27 +**详细信息**
28 +- **影响文件**:
29 + - src/composables/useFileOperation.js
30 + - src/pages/product-detail/index.vue
31 +- **技术栈**: Vue 3, Taro 4, Composition API
32 +- **测试状态**: 待测试
33 +- **备注**: 图片预览功能参考 material-list 页面的实现
34 +
35 +---
36 +
37 +## [2026-02-05] - 埋点功能集成
38 +
39 +### 新增
40 +- 创建通用埋点 Composable:`src/composables/useEventTracking.js`
41 + - 支持多种埋点类型(通过 `EventType` 枚举扩展)
42 + - 当前实现 `READ_FILE`(阅读素材)事件追踪
43 + - 自动处理错误,不影响用户体验
44 + - 开发环境提供详细日志
45 + - 提供便捷方法 `trackFileRead()`
46 +- 更新 `ListItemActions` 组件,集成自动埋点
47 + - 新增 `itemId` prop,用于关联埋点对象
48 + - 点击"查看"按钮时自动发送埋点数据
49 + - 埋点异步执行,不阻塞用户操作
50 +
51 +### 优化
52 +- 埋点失败静默处理,不影响业务流程
53 +- 使用 `try-catch` 捕获埋点异常
54 +- 仅在开发环境输出日志,生产环境静默
55 +
56 +---
57 +
58 +**详细信息**
59 +- **影响文件**:
60 + - src/composables/useEventTracking.js(新建)
61 + - src/components/ListItemActions/index.vue
62 +- **技术栈**: Vue 3, Composition API
63 +- **测试状态**: 待测试
64 +- **备注**: API 接口已存在于 src/api/event.js
65 +
66 +---
67 +
8 ## [2026-02-05] - 收藏页面联调完成 68 ## [2026-02-05] - 收藏页面联调完成
9 69
10 ### 新增 70 ### 新增
......
1 +# 埋点功能使用指南
2 +
3 +## 概述
4 +
5 +项目提供了统一的事件埋点功能,通过 `useEventTracking` Composable 实现各种用户行为的追踪。
6 +
7 +## 核心文件
8 +
9 +- **API 接口**: `src/api/event.js` - 埋点 API 接口定义
10 +- **Composable**: `src/composables/useEventTracking.js` - 埋点逻辑封装
11 +- **集成组件**: `src/components/ListItemActions/index.vue` - 已集成埋点功能
12 +
13 +## 快速开始
14 +
15 +### 1. 在列表页面使用(已集成)
16 +
17 +`ListItemActions` 组件已自动集成埋点功能,只需传递 `itemId` prop:
18 +
19 +```vue
20 +<template>
21 + <ListItemActions
22 + :item-id="item.id"
23 + @view="handleView"
24 + />
25 +</template>
26 +
27 +<script setup>
28 +import ListItemActions from '@/components/ListItemActions/index.vue'
29 +
30 +const item = {
31 + id: 'file-123',
32 + name: '保险产品手册'
33 +}
34 +
35 +const handleView = () => {
36 + // 点击查看时,自动发送埋点数据
37 + console.log('查看文件')
38 +}
39 +</script>
40 +```
41 +
42 +### 2. 自定义埋点
43 +
44 +#### 基础用法
45 +
46 +```vue
47 +<script setup>
48 +import { useEventTracking, EventType } from '@/composables/useEventTracking'
49 +
50 +const { trackEvent } = useEventTracking()
51 +
52 +// 追踪阅读素材事件
53 +await trackEvent(EventType.READ_FILE, 'file-id-123')
54 +</script>
55 +```
56 +
57 +#### 带额外数据的埋点
58 +
59 +```vue
60 +<script setup>
61 +import { useEventTracking, EventType } from '@/composables/useEventTracking'
62 +
63 +const { trackEvent } = useEventTracking()
64 +
65 +// 追踪阅读事件,并携带额外信息
66 +await trackEvent(EventType.READ_FILE, 'file-id-123', {
67 + title: '保险产品手册',
68 + category: '产品资料',
69 + author: '张三'
70 +})
71 +</script>
72 +```
73 +
74 +#### 使用便捷方法
75 +
76 +```vue
77 +<script setup>
78 +import { useEventTracking } from '@/composables/useEventTracking'
79 +
80 +const { trackFileRead } = useEventTracking()
81 +
82 +// 便捷方法:追踪文件阅读
83 +await trackFileRead('file-id-123', {
84 + title: '保险产品手册'
85 +})
86 +</script>
87 +```
88 +
89 +## 事件类型
90 +
91 +当前支持的事件类型:
92 +
93 +| 枚举值 | 说明 | 使用场景 |
94 +|--------|------|----------|
95 +| `EventType.READ_FILE` | 阅读素材 | 用户点击查看文件/素材时 |
96 +
97 +未来可扩展的事件类型(已预留):
98 +
99 +```javascript
100 +EventType.COLLECT_FILE // 收藏素材
101 +EventType.SHARE_FILE // 分享素材
102 +EventType.DOWNLOAD_FILE // 下载素材
103 +EventType.VIEW_PRODUCT // 查看产品
104 +```
105 +
106 +## 添加新的事件类型
107 +
108 +### 1. 更新 EventType 枚举
109 +
110 +编辑 `src/composables/useEventTracking.js`
111 +
112 +```javascript
113 +export const EventType = {
114 + READ_FILE: 'READ_FILE',
115 + // 添加新类型
116 + COLLECT_FILE: 'COLLECT_FILE',
117 +}
118 +```
119 +
120 +### 2. 添加便捷方法(可选)
121 +
122 +```javascript
123 +/**
124 + * 追踪收藏事件
125 + */
126 +const trackFileCollect = async (fileId, extraData = {}) => {
127 + await trackEvent(EventType.COLLECT_FILE, fileId, extraData)
128 +}
129 +
130 +return {
131 + trackEvent,
132 + trackFileRead,
133 + trackFileCollect // 导出新方法
134 +}
135 +```
136 +
137 +## 最佳实践
138 +
139 +### ✅ 推荐做法
140 +
141 +1. **使用枚举而非字符串**
142 + ```javascript
143 + // ✅ GOOD
144 + await trackEvent(EventType.READ_FILE, fileId)
145 +
146 + // ❌ BAD
147 + await trackEvent('READ_FILE', fileId)
148 + ```
149 +
150 +2. **传递有意义的额外数据**
151 + ```javascript
152 + await trackEvent(EventType.READ_FILE, fileId, {
153 + title: '保险产品手册',
154 + category: '产品资料',
155 + format: 'PDF'
156 + })
157 + ```
158 +
159 +3. **埋点失败不影响用户体验**
160 + ```javascript
161 + // ✅ GOOD - 静默处理
162 + try {
163 + await trackEvent(...)
164 + } catch (error) {
165 + // 仅在开发环境记录
166 + if (process.env.NODE_ENV === 'development') {
167 + console.error('[埋点失败]', error)
168 + }
169 + }
170 +
171 + // ❌ BAD - 向用户显示错误
172 + try {
173 + await trackEvent(...)
174 + } catch (error) {
175 + Taro.showToast({ title: '埋点失败' }) // 不要这样做
176 + }
177 + ```
178 +
179 +### ❌ 避免做法
180 +
181 +1. **不要埋点阻塞用户操作**
182 + ```javascript
183 + // ❌ BAD - await 会阻塞
184 + const handleView = async () => {
185 + await trackEvent(...) // 等待埋点完成
186 + navigateToDetail() // 延迟跳转
187 + }
188 +
189 + // ✅ GOOD - 异步执行
190 + const handleView = () => {
191 + trackEvent(...) // 不等待
192 + navigateToDetail() // 立即执行
193 + }
194 + ```
195 +
196 +2. **不要在循环中批量发送埋点**
197 + ```javascript
198 + // ❌ BAD
199 + items.forEach(item => {
200 + await trackEvent(...) // 串行执行
201 + })
202 +
203 + // ✅ GOOD
204 + await Promise.all(items.map(item => // 并行执行
205 + trackEvent(...)
206 + ))
207 + ```
208 +
209 +## 调试
210 +
211 +### 开发环境日志
212 +
213 +在开发环境中,埋点成功和失败都会输出日志:
214 +
215 +```javascript
216 +// 成功
217 +[埋点成功] { type: 'READ_FILE', objectId: 'file-123', extraData: {...} }
218 +
219 +// 失败
220 +[埋点失败] Error: Request failed
221 +```
222 +
223 +### 验证埋点数据
224 +
225 +1. 打开微信开发者工具
226 +2. 切换到 "Network" 面板
227 +3. 触发埋点操作
228 +4. 查找 `/srv/?a=event&t=add` 请求
229 +5. 检查请求参数是否正确
230 +
231 +## 常见问题
232 +
233 +### Q: 埋点失败会影响用户操作吗?
234 +
235 +**A**: 不会。埋点失败会被静默处理,不会影响用户的正常操作流程。
236 +
237 +### Q: 如何查看埋点是否成功?
238 +
239 +**A**:
240 +1. 开发环境查看控制台日志
241 +2. 使用微信开发者工具的 Network 面板查看请求
242 +3. 联系后端查看埋点数据
243 +
244 +### Q: 可以在组件卸载后继续埋点吗?
245 +
246 +**A**: 不建议。埋点应该在组件生命周期内完成,避免潜在的内存泄漏。
247 +
248 +### Q: 埋点数据会持久化吗?
249 +
250 +**A**: 埋点失败的数据不会重试,也不会持久化。如果需要离线埋点,需要额外的实现。
251 +
252 +## API 参考
253 +
254 +### useEventTracking()
255 +
256 +返回对象:
257 +
258 +| 属性 | 类型 | 说明 |
259 +|------|------|------|
260 +| `trackEvent` | `(type, objectId, extraData?) => Promise<void>` | 通用追踪方法 |
261 +| `trackFileRead` | `(fileId, extraData?) => Promise<void>` | 文件阅读追踪 |
262 +
263 +### EventType
264 +
265 +枚举值:
266 +
267 +| 值 | 说明 |
268 +|----|------|
269 +| `READ_FILE` | 阅读素材 |
270 +| (未来扩展更多类型) | |
271 +
272 +## 相关文档
273 +
274 +- [API 接口文档](../../api-specs/event/README.md)
275 +- [Composables 最佳实践](../../lessons-learned.md#composables-使用指南)
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
8 :collectable="true" 8 :collectable="true"
9 :deletable="true" 9 :deletable="true"
10 :collected="item.collected" 10 :collected="item.collected"
11 + :item-id="item.id"
11 @view="onView(item)" 12 @view="onView(item)"
12 @collect="onCollect(item)" 13 @collect="onCollect(item)"
13 @delete="onDelete(item)" 14 @delete="onDelete(item)"
...@@ -38,6 +39,7 @@ ...@@ -38,6 +39,7 @@
38 <script setup> 39 <script setup>
39 import { computed } from 'vue' 40 import { computed } from 'vue'
40 import IconFont from '@/components/IconFont.vue' 41 import IconFont from '@/components/IconFont.vue'
42 +import { useEventTracking } from '@/composables/useEventTracking'
41 43
42 /** 44 /**
43 * 组件属性 45 * 组件属性
...@@ -78,6 +80,14 @@ const props = defineProps({ ...@@ -78,6 +80,14 @@ const props = defineProps({
78 collected: { 80 collected: {
79 type: Boolean, 81 type: Boolean,
80 default: false 82 default: false
83 + },
84 + /**
85 + * 关联对象 ID(用于埋点)
86 + * @type {string}
87 + */
88 + itemId: {
89 + type: String,
90 + default: ''
81 } 91 }
82 }) 92 })
83 93
...@@ -101,10 +111,20 @@ const emit = defineEmits({ ...@@ -101,10 +111,20 @@ const emit = defineEmits({
101 111
102 const isCollected = computed(() => props.collected) 112 const isCollected = computed(() => props.collected)
103 113
114 +// 初始化埋点功能
115 +const { trackFileRead } = useEventTracking()
116 +
104 /** 117 /**
105 * 处理查看点击 118 * 处理查看点击
119 + *
120 + * @description 触发查看事件,并自动发送埋点数据
106 */ 121 */
107 const handleView = () => { 122 const handleView = () => {
123 + // 如果提供了 itemId,自动发送埋点
124 + if (props.itemId) {
125 + trackFileRead(props.itemId)
126 + }
127 +
108 emit('view') 128 emit('view')
109 } 129 }
110 130
......
1 +/**
2 + * 事件埋点 Composable
3 + *
4 + * @description 提供统一的事件埋点功能,支持多种埋点类型
5 + * @module composables/useEventTracking
6 + */
7 +
8 +import { addAPI } from '@/api/event'
9 +
10 +/**
11 + * 埋点事件类型枚举
12 + *
13 + * @enum {string}
14 + */
15 +export const EventType = {
16 + /** 阅读素材 */
17 + READ_FILE: 'READ_FILE',
18 + // 未来可以添加更多事件类型
19 + // COLLECT_FILE: 'COLLECT_FILE', // 收藏素材
20 + // SHARE_FILE: 'SHARE_FILE', // 分享素材
21 + // DOWNLOAD_FILE: 'DOWNLOAD_FILE', // 下载素材
22 + // VIEW_PRODUCT: 'VIEW_PRODUCT', // 查看产品
23 +}
24 +
25 +/**
26 + * 使用事件埋点
27 + *
28 + * @description 提供统一的事件埋点方法,自动处理错误
29 + * @returns {Object} 埋点方法和状态
30 + *
31 + * @example
32 + * // 基础用法
33 + * const { trackEvent } = useEventTracking()
34 + *
35 + * // 追踪阅读素材事件
36 + * trackEvent(EventType.READ_FILE, 'file-id-123')
37 + *
38 + * // 带额外数据的追踪
39 + * trackEvent(EventType.READ_FILE, 'file-id-123', {
40 + * title: '文档标题',
41 + * category: '培训资料'
42 + * })
43 + */
44 +export function useEventTracking() {
45 + /**
46 + * 追踪事件
47 + *
48 + * @description 发送埋点数据到后端
49 + * @async
50 + * @param {string} type - 事件类型(使用 EventType 枚举)
51 + * @param {string} objectId - 关联的对象 ID(文件 ID、产品 ID 等)
52 + * @param {Object} extraData - 额外的埋点数据(可选)
53 + * @param {string} [extraData.title] - 对象标题
54 + * @param {string} [extraData.category] - 分类
55 + * @param {Record<string, any>} [extraData.*] - 其他自定义字段
56 + * @returns {Promise<void>}
57 + *
58 + * @example
59 + * // 追踪阅读事件
60 + * await trackEvent(EventType.READ_FILE, 'file-id-123')
61 + *
62 + * // 追踪带额外数据的阅读事件
63 + * await trackEvent(EventType.READ_FILE, 'file-id-123', {
64 + * title: '保险产品手册',
65 + * category: '产品资料'
66 + * })
67 + */
68 + const trackEvent = async (type, objectId, extraData = {}) => {
69 + try {
70 + const params = {
71 + type,
72 + object_id: objectId,
73 + ...extraData
74 + }
75 +
76 + await addAPI(params)
77 +
78 + // 静默处理埋点失败,不影响用户操作
79 + // 仅在开发环境打印日志
80 + if (process.env.NODE_ENV === 'development') {
81 + console.log('[埋点成功]', { type, objectId, extraData })
82 + }
83 + } catch (error) {
84 + // 埋点失败不应影响用户体验,静默处理
85 + if (process.env.NODE_ENV === 'development') {
86 + console.error('[埋点失败]', error)
87 + }
88 + }
89 + }
90 +
91 + /**
92 + * 追踪文件阅读事件
93 + *
94 + * @description 便捷方法:追踪文件/素材阅读
95 + * @async
96 + * @param {string} fileId - 文件 ID
97 + * @param {Object} extraData - 额外的埋点数据
98 + * @returns {Promise<void>}
99 + *
100 + * @example
101 + * await trackFileRead('file-id-123', {
102 + * title: '保险产品手册'
103 + * })
104 + */
105 + const trackFileRead = async (fileId, extraData = {}) => {
106 + await trackEvent(EventType.READ_FILE, fileId, extraData)
107 + }
108 +
109 + return {
110 + trackEvent,
111 + trackFileRead
112 + }
113 +}
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
8 * @date 2026-01-31 8 * @date 2026-01-31
9 */ 9 */
10 10
11 -import { showToast, showLoading, hideLoading, showModal, openDocument, downloadFile } from '@tarojs/taro' 11 +import { showToast, showLoading, hideLoading, showModal, openDocument, downloadFile, previewImage } from '@tarojs/taro'
12 import { isVideoFile } from '@/utils/tools' 12 import { isVideoFile } from '@/utils/tools'
13 13
14 /** 14 /**
...@@ -18,6 +18,18 @@ import { isVideoFile } from '@/utils/tools' ...@@ -18,6 +18,18 @@ import { isVideoFile } from '@/utils/tools'
18 */ 18 */
19 export function useFileOperation() { 19 export function useFileOperation() {
20 /** 20 /**
21 + * 判断是否为图片文件
22 + *
23 + * @param {string} fileName - 文件名
24 + * @returns {boolean} 是否为图片文件
25 + */
26 + const isImageFile = (fileName) => {
27 + if (!fileName) return false
28 + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
29 + const extension = fileName.split('.').pop()?.toLowerCase() || ''
30 + return imageExtensions.includes(extension)
31 + }
32 + /**
21 * 打开文件的通用函数 33 * 打开文件的通用函数
22 * 34 *
23 * @description 使用 Taro.openDocument 打开文件,支持菜单转发和保存 35 * @description 使用 Taro.openDocument 打开文件,支持菜单转发和保存
...@@ -175,7 +187,10 @@ export function useFileOperation() { ...@@ -175,7 +187,10 @@ export function useFileOperation() {
175 /** 187 /**
176 * 查看文件(入口函数) 188 * 查看文件(入口函数)
177 * 189 *
178 - * @description 检查文件是否有下载地址,判断文件类型,视频文件跳转播放页面,其他文件下载并打开 190 + * @description 检查文件是否有下载地址,判断文件类型:
191 + * - 图片文件:使用 Taro.previewImage 预览
192 + * - 视频文件:跳转播放页面
193 + * - 其他文件:下载并使用 Taro.openDocument 打开
179 * @async 194 * @async
180 * @param {Object} item - 文件信息对象 195 * @param {Object} item - 文件信息对象
181 * @param {string} [item.downloadUrl] - 文件下载地址 196 * @param {string} [item.downloadUrl] - 文件下载地址
...@@ -185,8 +200,8 @@ export function useFileOperation() { ...@@ -185,8 +200,8 @@ export function useFileOperation() {
185 * @example 200 * @example
186 * const { viewFile } = useFileOperation() 201 * const { viewFile } = useFileOperation()
187 * await viewFile({ 202 * await viewFile({
188 - * downloadUrl: 'https://example.com/video.mp4', 203 + * downloadUrl: 'https://example.com/file.pdf',
189 - * fileName: 'tutorial.mp4' 204 + * fileName: 'document.pdf'
190 * }) 205 * })
191 */ 206 */
192 const viewFile = async (item) => { 207 const viewFile = async (item) => {
...@@ -200,6 +215,26 @@ export function useFileOperation() { ...@@ -200,6 +215,26 @@ export function useFileOperation() {
200 return 215 return
201 } 216 }
202 217
218 + // 判断是否为图片文件
219 + if (isImageFile(item.fileName)) {
220 + // 图片文件:使用图片预览
221 + console.log('[文件操作] 检测到图片文件,使用图片预览')
222 + try {
223 + await previewImage({
224 + urls: [item.downloadUrl],
225 + current: item.downloadUrl
226 + })
227 + } catch (error) {
228 + console.error('[文件操作] 图片预览失败:', error)
229 + showToast({
230 + title: '图片预览失败',
231 + icon: 'none',
232 + duration: 2000
233 + })
234 + }
235 + return
236 + }
237 +
203 // 判断是否为视频文件 238 // 判断是否为视频文件
204 if (isVideoFile(item.fileName)) { 239 if (isVideoFile(item.fileName)) {
205 // 视频文件:跳转到视频播放页面 240 // 视频文件:跳转到视频播放页面
......
...@@ -168,10 +168,13 @@ const fetchProductDetail = async (id) => { ...@@ -168,10 +168,13 @@ const fetchProductDetail = async (id) => {
168 /** 168 /**
169 * 查看文档 169 * 查看文档
170 * 170 *
171 - * @description 打开文档预览 171 + * @description 打开文档预览,支持图片、视频、PDF 等多种格式
172 * @param {Object} doc - 文档对象 172 * @param {Object} doc - 文档对象
173 + * @param {string} doc.file_name - 文档名称
174 + * @param {string} doc.file_url - 文档 URL
173 */ 175 */
174 const viewDocument = (doc) => { 176 const viewDocument = (doc) => {
177 + // 打开文件预览(自动判断文件类型)
175 viewFile({ 178 viewFile({
176 fileName: doc.file_name, 179 fileName: doc.file_name,
177 downloadUrl: doc.file_url 180 downloadUrl: doc.file_url
......