hookehuyr

docs: 为多个组件和工具函数添加详细的JSDoc注释

为AppLayout、CourseCard等组件和useShare、useTracking等工具函数添加了详细的JSDoc注释,包括功能描述、参数说明和返回值类型。这些注释将提升代码可读性和维护性,帮助开发者快速理解组件和函数的用途及使用方法。

新增的注释涵盖了组件props、methods、events等关键部分,并遵循了统一的文档格式标准。对于复杂逻辑的函数,添加了详细的实现说明和使用示例。

这些文档更新不会影响现有功能,但会显著改善开发体验和代码可维护性。
Showing 42 changed files with 867 additions and 162 deletions
1 <template> 1 <template>
2 <div class="post-card shadow-md"> 2 <div class="post-card shadow-md">
3 - <!-- Header --> 3 + <!-- 头部信息 -->
4 <div class="post-header"> 4 <div class="post-header">
5 <van-row> 5 <van-row>
6 <van-col span="4"> 6 <van-col span="4">
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
12 <div class="user-info"> 12 <div class="user-info">
13 <div class="username"> 13 <div class="username">
14 {{ post.user.name }} 14 {{ post.user.name }}
15 - <!-- Makeup Tag --> 15 + <!-- 补卡标记 -->
16 <span v-if="post.user.is_makeup" 16 <span v-if="post.user.is_makeup"
17 class="MakeupTag inline-flex items-center ml-2 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-300">补打卡</span> 17 class="MakeupTag inline-flex items-center ml-2 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-300">补打卡</span>
18 <slot name="header-tags"></slot> 18 <slot name="header-tags"></slot>
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
23 <van-col span="3"> 23 <van-col span="3">
24 <div v-if="post.is_my" class="post-menu"> 24 <div v-if="post.is_my" class="post-menu">
25 <slot name="menu"> 25 <slot name="menu">
26 - <!-- Default menu items if needed, or left empty for parent to fill --> 26 + <!-- 默认菜单项,或留空供父组件填充 -->
27 <van-icon name="edit" @click="emit('edit', post)" class="mr-2" color="#4caf50" /> 27 <van-icon name="edit" @click="emit('edit', post)" class="mr-2" color="#4caf50" />
28 <van-icon name="delete-o" @click="emit('delete', post)" color="#f44336" /> 28 <van-icon name="delete-o" @click="emit('delete', post)" color="#f44336" />
29 </slot> 29 </slot>
...@@ -33,15 +33,15 @@ ...@@ -33,15 +33,15 @@
33 </van-row> 33 </van-row>
34 </div> 34 </div>
35 35
36 - <!-- Content --> 36 + <!-- 内容区域 -->
37 <div class="post-content"> 37 <div class="post-content">
38 <slot name="content-top"></slot> 38 <slot name="content-top"></slot>
39 <PostCountModel :post-data="post" /> 39 <PostCountModel :post-data="post" />
40 <div class="post-text">{{ post.content }}</div> 40 <div class="post-text">{{ post.content }}</div>
41 41
42 - <!-- Media --> 42 + <!-- 媒体内容 -->
43 <div class="post-media"> 43 <div class="post-media">
44 - <!-- Images --> 44 + <!-- 图片列表 -->
45 <div v-if="post.images.length" class="post-images"> 45 <div v-if="post.images.length" class="post-images">
46 <div class="post-image-item" v-for="(image, index) in post.images" :key="index"> 46 <div class="post-image-item" v-for="(image, index) in post.images" :key="index">
47 <van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5" 47 <van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5"
...@@ -51,9 +51,9 @@ ...@@ -51,9 +51,9 @@
51 <van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images" 51 <van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images"
52 :start-position="localStartPosition" :show-index="true" /> 52 :start-position="localStartPosition" :show-index="true" />
53 53
54 - <!-- Videos --> 54 + <!-- 视频列表 -->
55 <div v-for="(v, idx) in post.videoList" :key="idx"> 55 <div v-for="(v, idx) in post.videoList" :key="idx">
56 - <!-- Cover --> 56 + <!-- 封面图 -->
57 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden" 57 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
58 style="aspect-ratio: 16/9; margin-bottom: 1rem;"> 58 style="aspect-ratio: 16/9; margin-bottom: 1rem;">
59 <img :src="getOptimizedUrl(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')" 59 <img :src="getOptimizedUrl(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')"
...@@ -66,28 +66,28 @@ ...@@ -66,28 +66,28 @@
66 </div> 66 </div>
67 </div> 67 </div>
68 </div> 68 </div>
69 - <!-- Video Player --> 69 + <!-- 视频播放器 -->
70 <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video" 70 <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
71 :video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false" class="post-video rounded-lg overflow-hidden" 71 :video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false" class="post-video rounded-lg overflow-hidden"
72 :ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)" 72 :ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)"
73 @onPause="handleVideoPause" /> 73 @onPause="handleVideoPause" />
74 </div> 74 </div>
75 75
76 - <!-- Audio --> 76 + <!-- 音频播放器 -->
77 <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio" :id="post.id" 77 <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio" :id="post.id"
78 :ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" /> 78 :ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" />
79 </div> 79 </div>
80 </div> 80 </div>
81 81
82 - <!-- Footer --> 82 + <!-- 底部操作栏 -->
83 <div class="post-footer flex items-center justify-between"> 83 <div class="post-footer flex items-center justify-between">
84 - <!-- Left: Like --> 84 + <!-- 左侧:点赞 -->
85 <div class="flex items-center"> 85 <div class="flex items-center">
86 <van-icon @click="emit('like', post)" name="good-job" class="like-icon" 86 <van-icon @click="emit('like', post)" name="good-job" class="like-icon"
87 :color="post.is_liked ? 'red' : ''" /> 87 :color="post.is_liked ? 'red' : ''" />
88 <span class="like-count ml-1">{{ post.likes }}</span> 88 <span class="like-count ml-1">{{ post.likes }}</span>
89 </div> 89 </div>
90 - <!-- Right: Custom Actions --> 90 + <!-- 右侧:自定义操作 -->
91 <div class="flex items-center"> 91 <div class="flex items-center">
92 <slot name="footer-right"></slot> 92 <slot name="footer-right"></slot>
93 </div> 93 </div>
...@@ -102,25 +102,47 @@ import VideoPlayer from "@/components/ui/VideoPlayer.vue"; ...@@ -102,25 +102,47 @@ import VideoPlayer from "@/components/ui/VideoPlayer.vue";
102 import AudioPlayer from "@/components/ui/AudioPlayer.vue"; 102 import AudioPlayer from "@/components/ui/AudioPlayer.vue";
103 103
104 const props = defineProps({ 104 const props = defineProps({
105 + /**
106 + * 帖子数据对象
107 + * @property {number|string} id - 帖子ID
108 + * @property {Object} user - 用户信息
109 + * @property {string} content - 帖子内容
110 + * @property {Array} images - 图片列表
111 + * @property {Array} videoList - 视频列表
112 + * @property {Array} audio - 音频列表
113 + * @property {number} likes - 点赞数
114 + * @property {boolean} is_liked - 是否已点赞
115 + * @property {boolean} is_my - 是否是自己的帖子
116 + */
105 post: { type: Object, required: true }, 117 post: { type: Object, required: true },
118 + /** 是否使用 CDN 优化链接 */
106 useCdnOptimization: { type: Boolean, default: false } 119 useCdnOptimization: { type: Boolean, default: false }
107 }) 120 })
108 121
109 const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play']) 122 const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play'])
110 123
111 -// Image Preview State 124 +// 图片预览状态
112 const showLocalImagePreview = ref(false) 125 const showLocalImagePreview = ref(false)
113 const localStartPosition = ref(0) 126 const localStartPosition = ref(0)
114 127
128 +/**
129 + * @description 打开图片预览
130 + * @param {number} index 图片索引
131 + */
115 const openImagePreview = (index) => { 132 const openImagePreview = (index) => {
116 localStartPosition.value = index 133 localStartPosition.value = index
117 showLocalImagePreview.value = true 134 showLocalImagePreview.value = true
118 } 135 }
119 136
120 -// Media Refs 137 +// 媒体引用
121 const videoRefs = ref(new Map()) 138 const videoRefs = ref(new Map())
122 const audioRefs = ref(new Map()) 139 const audioRefs = ref(new Map())
123 140
141 +/**
142 + * @description 设置视频引用
143 + * @param {HTMLElement} el 元素
144 + * @param {string|number} id 视频ID
145 + */
124 const setVideoRef = (el, id) => { 146 const setVideoRef = (el, id) => {
125 if (el) { 147 if (el) {
126 videoRefs.value.set(id, el) 148 videoRefs.value.set(id, el)
...@@ -128,6 +150,12 @@ const setVideoRef = (el, id) => { ...@@ -128,6 +150,12 @@ const setVideoRef = (el, id) => {
128 videoRefs.value.delete(id) 150 videoRefs.value.delete(id)
129 } 151 }
130 } 152 }
153 +
154 +/**
155 + * @description 设置音频引用
156 + * @param {HTMLElement} el 元素
157 + * @param {string|number} id 音频ID
158 + */
131 const setAudioRef = (el, id) => { 159 const setAudioRef = (el, id) => {
132 if (el) { 160 if (el) {
133 audioRefs.value.set(id, el) 161 audioRefs.value.set(id, el)
...@@ -136,41 +164,65 @@ const setAudioRef = (el, id) => { ...@@ -136,41 +164,65 @@ const setAudioRef = (el, id) => {
136 } 164 }
137 } 165 }
138 166
139 -// Optimization 167 +// CDN 链接优化
168 +/**
169 + * @description 获取优化后的 CDN 链接
170 + * @param {string} url 原始链接
171 + * @returns {string} 优化后的链接
172 + */
140 const getOptimizedUrl = (url) => { 173 const getOptimizedUrl = (url) => {
141 if (!props.useCdnOptimization) return url 174 if (!props.useCdnOptimization) return url
142 if (url.includes('?')) return url 175 if (url.includes('?')) return url
143 return `${url}?imageMogr2/thumbnail/200x/strip/quality/70` 176 return `${url}?imageMogr2/thumbnail/200x/strip/quality/70`
144 } 177 }
145 178
146 -// Video Logic 179 +// 视频播放逻辑
180 +/**
181 + * @description 开始播放视频
182 + * @param {Object} v 视频对象
183 + */
147 const startPlay = (v) => { 184 const startPlay = (v) => {
148 - // Pause other videos in this card 185 + // 暂停当前卡片中的其他视频
149 props.post.videoList.forEach(item => { 186 props.post.videoList.forEach(item => {
150 if (item.id !== v.id) item.isPlaying = false 187 if (item.id !== v.id) item.isPlaying = false
151 }) 188 })
152 v.isPlaying = true 189 v.isPlaying = true
153 } 190 }
154 191
192 +/**
193 + * @description 处理视频播放事件
194 + * @param {Object} player 播放器实例
195 + * @param {Object} v 视频对象
196 + */
155 const handleVideoPlay = (player, v) => { 197 const handleVideoPlay = (player, v) => {
156 - // Stop local audio 198 + // 停止本地音频
157 stopLocalAudio() 199 stopLocalAudio()
158 - // Emit to parent to stop other cards 200 + // 通知父组件停止其他卡片的播放
159 emit('video-play', { post: props.post, player, videoId: v.id }) 201 emit('video-play', { post: props.post, player, videoId: v.id })
160 } 202 }
161 203
204 +/**
205 + * @description 处理视频暂停事件
206 + */
162 const handleVideoPause = () => { 207 const handleVideoPause = () => {
163 - // do nothing 208 + // 暂无操作
164 } 209 }
165 210
166 -// Audio Logic 211 +// 音频播放逻辑
212 +/**
213 + * @description 处理音频播放事件
214 + * @param {Object} player 播放器实例
215 + */
167 const handleAudioPlay = (player) => { 216 const handleAudioPlay = (player) => {
168 - // Stop local videos 217 + // 停止本地视频
169 stopLocalVideos() 218 stopLocalVideos()
170 - // Emit to parent 219 + // 通知父组件
171 emit('audio-play', { post: props.post, player }) 220 emit('audio-play', { post: props.post, player })
172 } 221 }
173 222
223 +/**
224 + * @description 停止当前卡片内的所有视频
225 + */
174 const stopLocalVideos = () => { 226 const stopLocalVideos = () => {
175 videoRefs.value.forEach(player => { 227 videoRefs.value.forEach(player => {
176 if (player && typeof player.pause === 'function') { 228 if (player && typeof player.pause === 'function') {
...@@ -180,25 +232,25 @@ const stopLocalVideos = () => { ...@@ -180,25 +232,25 @@ const stopLocalVideos = () => {
180 props.post.videoList.forEach(v => v.isPlaying = false) 232 props.post.videoList.forEach(v => v.isPlaying = false)
181 } 233 }
182 234
235 +/**
236 + * @description 停止当前卡片内的所有音频
237 + */
183 const stopLocalAudio = () => { 238 const stopLocalAudio = () => {
184 audioRefs.value.forEach(player => { 239 audioRefs.value.forEach(player => {
185 if (player && typeof player.pause === 'function') { 240 if (player && typeof player.pause === 'function') {
186 player.pause() 241 player.pause()
187 } 242 }
188 }) 243 })
189 - // Also update isPlaying state if AudioPlayer doesn't handle it fully self-contained 244 + // 同时也需要暂停 AudioPlayer 组件实例
190 - // But AudioPlayer usually manages its own state or we don't track audio isPlaying on post object for audio lists?
191 - // Looking at IndexCheckInPage: post.audio is array. AudioPlayer takes songs.
192 - // So we just pause the player component.
193 } 245 }
194 246
195 -// Expose methods for parent 247 +// 暴露方法给父组件
196 defineExpose({ 248 defineExpose({
197 stopAllMedia: () => { 249 stopAllMedia: () => {
198 stopLocalVideos() 250 stopLocalVideos()
199 stopLocalAudio() 251 stopLocalAudio()
200 }, 252 },
201 - // Also expose post id for convenience 253 + // 暴露 post id 以便父组件查找
202 id: props.post.id 254 id: props.post.id
203 }) 255 })
204 </script> 256 </script>
......
1 <!-- 1 <!--
2 * @Date: 2025-12-16 11:44:27 2 * @Date: 2025-12-16 11:44:27
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-21 10:04:55 4 + * @LastEditTime: 2026-01-22 16:45:04
5 * @FilePath: /mlaj/src/components/count/CheckinTargetList.vue 5 * @FilePath: /mlaj/src/components/count/CheckinTargetList.vue
6 * @Description: 打卡动态对象列表组件 6 * @Description: 打卡动态对象列表组件
7 --> 7 -->
...@@ -101,6 +101,11 @@ const props = defineProps({ ...@@ -101,6 +101,11 @@ const props = defineProps({
101 }, 101 },
102 /** 102 /**
103 * 所有可用的对象列表 103 * 所有可用的对象列表
104 + * @property {number|string} id - 目标ID
105 + * @property {string} name - 目标名称
106 + * @property {number} count - 当前计数
107 + * @property {number} target_count - 目标计数
108 + * @property {string} unit - 单位
104 */ 109 */
105 targetList: { 110 targetList: {
106 type: Array, 111 type: Array,
...@@ -158,7 +163,16 @@ onMounted(() => { ...@@ -158,7 +163,16 @@ onMounted(() => {
158 checkHeight() 163 checkHeight()
159 }) 164 })
160 165
161 -const emit = defineEmits(['add', 'toggle', 'edit', 'delete']) 166 +const emit = defineEmits([
167 + 'add',
168 + 'toggle',
169 + /**
170 + * 编辑目标
171 + * @property {Object} item - 目标对象
172 + */
173 + 'edit',
174 + 'delete'
175 +])
162 176
163 /** 177 /**
164 * 点击添加按钮 178 * 点击添加按钮
...@@ -175,6 +189,7 @@ const currentItem = ref(null) ...@@ -175,6 +189,7 @@ const currentItem = ref(null)
175 189
176 const actions = [ 190 const actions = [
177 { name: '编辑', color: '#1989fa', action: 'edit' }, 191 { name: '编辑', color: '#1989fa', action: 'edit' },
192 + // 没有加删除功能, 那个接口也是不存在的
178 ] 193 ]
179 194
180 const startLongPress = (item) => { 195 const startLongPress = (item) => {
......
...@@ -15,42 +15,42 @@ const isWxMobile = isMobile && isWeiXin; ...@@ -15,42 +15,42 @@ const isWxMobile = isMobile && isWeiXin;
15 import { ref, onMounted, onUnmounted, computed } from 'vue'; 15 import { ref, onMounted, onUnmounted, computed } from 'vue';
16 16
17 const props = defineProps({ 17 const props = defineProps({
18 - // 背景颜色 18 + /** 背景颜色 */
19 bgColor: { 19 bgColor: {
20 type: String, 20 type: String,
21 default: 'white' 21 default: 'white'
22 }, 22 },
23 - // 背景图片 URL 23 + /** 背景图片 URL */
24 bgImage: { 24 bgImage: {
25 type: String, 25 type: String,
26 default: 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png' 26 default: 'https://cdn.ipadbiz.cn/mlaj/recall/img/bg01@2x.png'
27 }, 27 },
28 - // 星星数量 28 + /** 星星数量 */
29 starCount: { 29 starCount: {
30 type: Number, 30 type: Number,
31 default: isWxMobile ? 500 : 500 31 default: isWxMobile ? 500 : 500
32 }, 32 },
33 - // 星星颜色 (RGBA 前缀,例如 '255,255,255') 33 + /** 星星颜色 (RGBA 前缀,例如 '255,255,255') */
34 starColor: { 34 starColor: {
35 type: String, 35 type: String,
36 default: '255,255,255' 36 default: '255,255,255'
37 }, 37 },
38 - // 星星移动速度系数 (值越大移动越快) 38 + /** 星星移动速度系数 (值越大移动越快) */
39 starSpeed: { 39 starSpeed: {
40 type: Number, 40 type: Number,
41 default: isWxMobile ? 0.3 : 0.1 41 default: isWxMobile ? 0.3 : 0.1
42 }, 42 },
43 - // 流星速度 - 水平分量 (负值向左,正值向右) 43 + /** 流星速度 - 水平分量 (负值向左,正值向右) */
44 meteorVx: { 44 meteorVx: {
45 type: Number, 45 type: Number,
46 default: isWxMobile ? -10 : -2 46 default: isWxMobile ? -10 : -2
47 }, 47 },
48 - // 流星速度 - 垂直分量 (正值向下) 48 + /** 流星速度 - 垂直分量 (正值向下) */
49 meteorVy: { 49 meteorVy: {
50 type: Number, 50 type: Number,
51 default: isWxMobile ? 10 : 2 51 default: isWxMobile ? 10 : 2
52 }, 52 },
53 - // 流星拖尾长度系数 (值越大尾巴越长) 53 + /** 流星拖尾长度系数 (值越大尾巴越长) */
54 meteorTrail: { 54 meteorTrail: {
55 type: Number, 55 type: Number,
56 default: isWxMobile ? 4 : 10 56 default: isWxMobile ? 4 : 10
...@@ -80,7 +80,10 @@ let animationFrameId = null; ...@@ -80,7 +80,10 @@ let animationFrameId = null;
80 let meteorTimeoutId = null; 80 let meteorTimeoutId = null;
81 let meteorIndex = -1; // 当前流星的索引 81 let meteorIndex = -1; // 当前流星的索引
82 82
83 -// 创建星星纹理(放射效果) 83 +/**
84 + * @description 创建星星纹理(放射效果)
85 + * @returns {HTMLCanvasElement} 包含星星纹理的 canvas 元素
86 + */
84 const createStarTexture = () => { 87 const createStarTexture = () => {
85 const canvas = document.createElement('canvas'); 88 const canvas = document.createElement('canvas');
86 canvas.width = 32; 89 canvas.width = 32;
...@@ -115,7 +118,9 @@ const createStarTexture = () => { ...@@ -115,7 +118,9 @@ const createStarTexture = () => {
115 return canvas; 118 return canvas;
116 }; 119 };
117 120
118 -// 初始化星星 121 +/**
122 + * @description 初始化星星数组
123 + */
119 const initStars = () => { 124 const initStars = () => {
120 stars = []; 125 stars = [];
121 starTexture = createStarTexture(); // 生成纹理 126 starTexture = createStarTexture(); // 生成纹理
...@@ -135,7 +140,9 @@ const initStars = () => { ...@@ -135,7 +140,9 @@ const initStars = () => {
135 } 140 }
136 }; 141 };
137 142
138 -// 渲染函数 143 +/**
144 + * @description 渲染动画帧
145 + */
139 const render = () => { 146 const render = () => {
140 if (!context) return; 147 if (!context) return;
141 148
...@@ -234,7 +241,9 @@ const render = () => { ...@@ -234,7 +241,9 @@ const render = () => {
234 animationFrameId = requestAnimationFrame(render); 241 animationFrameId = requestAnimationFrame(render);
235 }; 242 };
236 243
237 -// 流星触发逻辑 244 +/**
245 + * @description 触发流星动画
246 + */
238 const triggerMeteor = () => { 247 const triggerMeteor = () => {
239 const time = Math.round(Math.random() * 3000 + 33); 248 const time = Math.round(Math.random() * 3000 + 33);
240 meteorTimeoutId = setTimeout(() => { 249 meteorTimeoutId = setTimeout(() => {
......
...@@ -36,20 +36,22 @@ import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue' ...@@ -36,20 +36,22 @@ import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
36 * 组件属性定义 36 * 组件属性定义
37 */ 37 */
38 const props = defineProps({ 38 const props = defineProps({
39 - // 控制弹窗显示状态 39 + /** 控制弹窗显示状态 */
40 show: { 40 show: {
41 type: Boolean, 41 type: Boolean,
42 default: false 42 default: false
43 }, 43 },
44 - // iframe的src地址 44 + /** iframe的src地址 */
45 iframeSrc: { 45 iframeSrc: {
46 type: String, 46 type: String,
47 required: true 47 required: true
48 }, 48 },
49 + /** 弹窗标题 */
49 title: { 50 title: {
50 type: String, 51 type: String,
51 default: '个人信息录入' 52 default: '个人信息录入'
52 }, 53 },
54 + /** 操作类型: add | edit */
53 type: { 55 type: {
54 type: String, 56 type: String,
55 default: 'add' 57 default: 'add'
......
...@@ -55,6 +55,9 @@ const props = defineProps({ ...@@ -55,6 +55,9 @@ const props = defineProps({
55 } 55 }
56 }) 56 })
57 57
58 +/**
59 + * @description 处理返回按钮点击事件
60 + */
58 const handleBack = () => { 61 const handleBack = () => {
59 if (props.onBack) { 62 if (props.onBack) {
60 props.onBack() 63 props.onBack()
......
...@@ -49,12 +49,19 @@ const router = useRouter() ...@@ -49,12 +49,19 @@ const router = useRouter()
49 const { currentUser } = useAuth() 49 const { currentUser } = useAuth()
50 const { getUserInfoFromLocal } = useUserInfo() 50 const { getUserInfoFromLocal } = useUserInfo()
51 51
52 +/**
53 + * @description 未读消息数量
54 + * @type {import('vue').ComputedRef<number>}
55 + */
52 const unread_msg_count = computed(() => { 56 const unread_msg_count = computed(() => {
53 const userInfo = currentUser.value || getUserInfoFromLocal() 57 const userInfo = currentUser.value || getUserInfoFromLocal()
54 const count = Number(userInfo?.unread_msg_count || 0) 58 const count = Number(userInfo?.unread_msg_count || 0)
55 return Number.isFinite(count) ? count : 0 59 return Number.isFinite(count) ? count : 0
56 }) 60 })
57 61
62 +/**
63 + * @description 底部导航配置项
64 + */
58 const navItems = [ 65 const navItems = [
59 { 66 {
60 name: '首页', 67 name: '首页',
...@@ -79,10 +86,19 @@ const navItems = [ ...@@ -79,10 +86,19 @@ const navItems = [
79 } 86 }
80 ] 87 ]
81 88
89 +/**
90 + * @description 判断导航项是否激活
91 + * @param {string} path 路由路径
92 + * @returns {boolean}
93 + */
82 const isActive = (path) => { 94 const isActive = (path) => {
83 return route.path === path || (path !== '/' && route.path.startsWith(path)) 95 return route.path === path || (path !== '/' && route.path.startsWith(path))
84 } 96 }
85 97
98 +/**
99 + * @description 处理导航点击事件
100 + * @param {Object} item 导航项配置
101 + */
86 const handleNavClick = (item) => { 102 const handleNavClick = (item) => {
87 if (item.path === '/activity') { 103 if (item.path === '/activity') {
88 // showToast('功能暂未开放') 104 // showToast('功能暂未开放')
......
...@@ -102,7 +102,11 @@ const initWxPay = () => { ...@@ -102,7 +102,11 @@ const initWxPay = () => {
102 }) 102 })
103 } 103 }
104 104
105 -// 检查支付状态 105 +/**
106 + * @description 检查支付状态
107 + * @param {string} orderId 订单ID
108 + * @returns {Promise<boolean>} 支付是否成功
109 + */
106 const checkPaymentStatus = async (orderId) => { 110 const checkPaymentStatus = async (orderId) => {
107 try { 111 try {
108 const payStatus = await wxPayCheckAPI({ order_id: orderId }) 112 const payStatus = await wxPayCheckAPI({ order_id: orderId })
...@@ -117,7 +121,13 @@ const checkPaymentStatus = async (orderId) => { ...@@ -117,7 +121,13 @@ const checkPaymentStatus = async (orderId) => {
117 } 121 }
118 } 122 }
119 123
120 -// 处理支付结果 124 +/**
125 + * @description 处理支付结果
126 + * 1. 如果微信返回成功,进入轮询验证流程
127 + * 2. 轮询 checkPaymentStatus 确认后端状态
128 + * 3. 如果微信返回取消或失败,直接更新状态
129 + * @param {Object} res 微信支付回调结果
130 + */
121 const handlePaymentResult = async (res) => { 131 const handlePaymentResult = async (res) => {
122 if (res.err_msg === "get_brand_wcpay_request:ok") { 132 if (res.err_msg === "get_brand_wcpay_request:ok") {
123 // 支付成功,验证支付状态 133 // 支付成功,验证支付状态
...@@ -149,7 +159,13 @@ const handlePaymentResult = async (res) => { ...@@ -149,7 +159,13 @@ const handlePaymentResult = async (res) => {
149 } 159 }
150 } 160 }
151 161
152 -// 处理支付流程 162 +/**
163 + * @description 处理支付流程
164 + * 1. 初始化支付状态
165 + * 2. 调用后端接口获取支付参数
166 + * 3. 初始化微信JSBridge
167 + * 4. 调用微信支付
168 + */
153 const handlePayment = async () => { 169 const handlePayment = async () => {
154 try { 170 try {
155 // 重置支付状态 171 // 重置支付状态
......
1 +<!--
2 + * @Date: 2025-12-27 23:56:28
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-22 15:59:35
5 + * @FilePath: /mlaj/src/components/studyDetail/StudyCatalogPopup.vue
6 + * @Description: 课程目录弹窗
7 +-->
1 <template> 8 <template>
2 <van-popup v-model:show="show_catalog_model" position="bottom" round closeable safe-area-inset-bottom 9 <van-popup v-model:show="show_catalog_model" position="bottom" round closeable safe-area-inset-bottom
3 style="height: 80%"> 10 style="height: 80%">
...@@ -75,9 +82,11 @@ watch(show_catalog_model, (newVal) => { ...@@ -75,9 +82,11 @@ watch(show_catalog_model, (newVal) => {
75 const container = scroll_container_ref.value; 82 const container = scroll_container_ref.value;
76 if (!container) return; 83 if (!container) return;
77 84
85 + // 查找当前激活的课程项
78 const active_item = container.querySelector('.lesson-item.is-active'); 86 const active_item = container.querySelector('.lesson-item.is-active');
79 if (!active_item) return; 87 if (!active_item) return;
80 88
89 + // 计算滚动位置,使选中项居中
81 const container_height = container.clientHeight; 90 const container_height = container.clientHeight;
82 const item_top = active_item.offsetTop; 91 const item_top = active_item.offsetTop;
83 const item_height = active_item.clientHeight; 92 const item_height = active_item.clientHeight;
......
...@@ -85,34 +85,42 @@ import { formatDate } from '@/utils/tools' ...@@ -85,34 +85,42 @@ import { formatDate } from '@/utils/tools'
85 import { delGroupCommentAPI } from '@/api/course' 85 import { delGroupCommentAPI } from '@/api/course'
86 86
87 const props = defineProps({ 87 const props = defineProps({
88 + /** 评论总数 */
88 commentCount: { 89 commentCount: {
89 type: Number, 90 type: Number,
90 default: 0 91 default: 0
91 }, 92 },
93 + /** 评论列表数据 */
92 commentList: { 94 commentList: {
93 type: Array, 95 type: Array,
94 default: () => [] 96 default: () => []
95 }, 97 },
98 + /** 是否显示评论弹窗 (v-model) */
96 showCommentPopup: { 99 showCommentPopup: {
97 type: Boolean, 100 type: Boolean,
98 default: false 101 default: false
99 }, 102 },
103 + /** 弹窗内的评论列表数据 */
100 popupCommentList: { 104 popupCommentList: {
101 type: Array, 105 type: Array,
102 default: () => [] 106 default: () => []
103 }, 107 },
108 + /** 弹窗加载状态 (v-model) */
104 popupLoading: { 109 popupLoading: {
105 type: Boolean, 110 type: Boolean,
106 default: false 111 default: false
107 }, 112 },
113 + /** 弹窗加载是否完成 */
108 popupFinished: { 114 popupFinished: {
109 type: Boolean, 115 type: Boolean,
110 default: false 116 default: false
111 }, 117 },
118 + /** 弹窗评论输入框内容 (v-model) */
112 popupComment: { 119 popupComment: {
113 type: String, 120 type: String,
114 default: '' 121 default: ''
115 }, 122 },
123 + /** 底部留白高度,防止被固定元素遮挡 */
116 bottomWrapperHeight: { 124 bottomWrapperHeight: {
117 type: String, 125 type: String,
118 default: '0px' 126 default: '0px'
...@@ -120,32 +128,45 @@ const props = defineProps({ ...@@ -120,32 +128,45 @@ const props = defineProps({
120 }); 128 });
121 129
122 const emit = defineEmits([ 130 const emit = defineEmits([
131 + /** 更新评论弹窗显示状态 */
123 'update:showCommentPopup', 132 'update:showCommentPopup',
133 + /** 更新弹窗加载状态 */
124 'update:popupLoading', 134 'update:popupLoading',
135 + /** 更新弹窗评论内容 */
125 'update:popupComment', 136 'update:popupComment',
137 + /** 切换点赞状态 */
126 'toggleLike', 138 'toggleLike',
139 + /** 弹窗加载更多 */
127 'popupLoad', 140 'popupLoad',
141 + /** 提交弹窗评论 */
128 'submitPopupComment', 142 'submitPopupComment',
143 + /** 评论删除成功 */
129 'commentDeleted' 144 'commentDeleted'
130 ]); 145 ]);
131 146
147 +/** @type {import('vue').WritableComputedRef<boolean>} 弹窗显示状态双向绑定 */
132 const show_comment_popup_model = computed({ 148 const show_comment_popup_model = computed({
133 get: () => props.showCommentPopup, 149 get: () => props.showCommentPopup,
134 set: (val) => emit('update:showCommentPopup', val) 150 set: (val) => emit('update:showCommentPopup', val)
135 }); 151 });
136 152
153 +/** @type {import('vue').WritableComputedRef<boolean>} 弹窗加载状态双向绑定 */
137 const popup_loading_model = computed({ 154 const popup_loading_model = computed({
138 get: () => props.popupLoading, 155 get: () => props.popupLoading,
139 set: (val) => emit('update:popupLoading', val) 156 set: (val) => emit('update:popupLoading', val)
140 }); 157 });
141 158
159 +/** @type {import('vue').WritableComputedRef<string>} 弹窗评论内容双向绑定 */
142 const popup_comment_model = computed({ 160 const popup_comment_model = computed({
143 get: () => props.popupComment, 161 get: () => props.popupComment,
144 set: (val) => emit('update:popupComment', val) 162 set: (val) => emit('update:popupComment', val)
145 }); 163 });
146 164
165 +/** @type {import('vue').Ref<boolean>} 是否显示操作面板 */
147 const show_actions = ref(false) 166 const show_actions = ref(false)
167 +/** @type {import('vue').Ref<Object|null>} 当前操作的评论对象 */
148 const current_comment = ref(null) 168 const current_comment = ref(null)
169 +/** 操作面板选项 */
149 const actions = [ 170 const actions = [
150 { name: '删除', color: '#ef4444' }, 171 { name: '删除', color: '#ef4444' },
151 ] 172 ]
...@@ -176,6 +197,10 @@ const on_select_action = (action) => { ...@@ -176,6 +197,10 @@ const on_select_action = (action) => {
176 /** 197 /**
177 * @function confirm_delete_comment 198 * @function confirm_delete_comment
178 * @description 二次确认删除评论并调用接口 199 * @description 二次确认删除评论并调用接口
200 + * 1. 检查是否有当前选中的评论ID
201 + * 2. 弹出确认对话框
202 + * 3. 调用 delGroupCommentAPI 接口
203 + * 4. 成功后提示并触发 commentDeleted 事件
179 * @returns {Promise<void>} 204 * @returns {Promise<void>}
180 */ 205 */
181 const confirm_delete_comment = async () => { 206 const confirm_delete_comment = async () => {
...@@ -188,6 +213,7 @@ const confirm_delete_comment = async () => { ...@@ -188,6 +213,7 @@ const confirm_delete_comment = async () => {
188 message: '确定要删除这条评论吗?', 213 message: '确定要删除这条评论吗?',
189 }) 214 })
190 } catch (e) { 215 } catch (e) {
216 + // 用户取消删除
191 return 217 return
192 } 218 }
193 219
......
...@@ -84,10 +84,12 @@ import { computed } from 'vue'; ...@@ -84,10 +84,12 @@ import { computed } from 'vue';
84 import FrostedGlass from '@/components/ui/FrostedGlass.vue'; 84 import FrostedGlass from '@/components/ui/FrostedGlass.vue';
85 85
86 const props = defineProps({ 86 const props = defineProps({
87 + /** 是否显示资料弹窗 (v-model) */
87 showMaterialsPopup: { 88 showMaterialsPopup: {
88 type: Boolean, 89 type: Boolean,
89 default: false 90 default: false
90 }, 91 },
92 + /** 资料文件列表 */
91 files: { 93 files: {
92 type: Array, 94 type: Array,
93 default: () => [] 95 default: () => []
...@@ -95,23 +97,39 @@ const props = defineProps({ ...@@ -95,23 +97,39 @@ const props = defineProps({
95 }); 97 });
96 98
97 const emit = defineEmits([ 99 const emit = defineEmits([
100 + /** 更新弹窗显示状态 */
98 'update:showMaterialsPopup', 101 'update:showMaterialsPopup',
102 + /** 打开PDF文件 */
99 'openPdf', 103 'openPdf',
104 + /** 打开音频文件 */
100 'openAudio', 105 'openAudio',
106 + /** 打开视频文件 */
101 'openVideo', 107 'openVideo',
108 + /** 打开图片文件 */
102 'openImage' 109 'openImage'
103 ]); 110 ]);
104 111
112 +/** @type {import('vue').WritableComputedRef<boolean>} 弹窗显示状态双向绑定 */
105 const show_materials_popup_model = computed({ 113 const show_materials_popup_model = computed({
106 get: () => props.showMaterialsPopup, 114 get: () => props.showMaterialsPopup,
107 set: (val) => emit('update:showMaterialsPopup', val) 115 set: (val) => emit('update:showMaterialsPopup', val)
108 }); 116 });
109 117
118 +/**
119 + * @description 判断是否为PDF文件
120 + * @param {string} url 文件URL
121 + * @returns {boolean}
122 + */
110 const isPdfFile = (url) => { 123 const isPdfFile = (url) => {
111 if (!url || typeof url !== 'string') return false; 124 if (!url || typeof url !== 'string') return false;
112 return url.toLowerCase().includes('.pdf'); 125 return url.toLowerCase().includes('.pdf');
113 } 126 }
114 127
128 +/**
129 + * @description 判断是否为音频文件
130 + * @param {string} fileName 文件名
131 + * @returns {boolean}
132 + */
115 const isAudioFile = (fileName) => { 133 const isAudioFile = (fileName) => {
116 if (!fileName || typeof fileName !== 'string') return false; 134 if (!fileName || typeof fileName !== 'string') return false;
117 const extension = fileName.split('.').pop().toLowerCase(); 135 const extension = fileName.split('.').pop().toLowerCase();
...@@ -119,6 +137,11 @@ const isAudioFile = (fileName) => { ...@@ -119,6 +137,11 @@ const isAudioFile = (fileName) => {
119 return audioTypes.includes(extension); 137 return audioTypes.includes(extension);
120 } 138 }
121 139
140 +/**
141 + * @description 判断是否为视频文件
142 + * @param {string} fileName 文件名
143 + * @returns {boolean}
144 + */
122 const isVideoFile = (fileName) => { 145 const isVideoFile = (fileName) => {
123 if (!fileName || typeof fileName !== 'string') return false; 146 if (!fileName || typeof fileName !== 'string') return false;
124 const extension = fileName.split('.').pop().toLowerCase(); 147 const extension = fileName.split('.').pop().toLowerCase();
...@@ -126,6 +149,11 @@ const isVideoFile = (fileName) => { ...@@ -126,6 +149,11 @@ const isVideoFile = (fileName) => {
126 return videoTypes.includes(extension); 149 return videoTypes.includes(extension);
127 } 150 }
128 151
152 +/**
153 + * @description 判断是否为图片文件
154 + * @param {string} fileName 文件名
155 + * @returns {boolean}
156 + */
129 const isImageFile = (fileName) => { 157 const isImageFile = (fileName) => {
130 if (!fileName || typeof fileName !== 'string') return false; 158 if (!fileName || typeof fileName !== 'string') return false;
131 const extension = fileName.split('.').pop().toLowerCase(); 159 const extension = fileName.split('.').pop().toLowerCase();
...@@ -133,6 +161,11 @@ const isImageFile = (fileName) => { ...@@ -133,6 +161,11 @@ const isImageFile = (fileName) => {
133 return imageTypes.includes(extension); 161 return imageTypes.includes(extension);
134 } 162 }
135 163
164 +/**
165 + * @description 根据文件扩展名获取对应图标
166 + * @param {string} fileName 文件名
167 + * @returns {string} Vant图标名称
168 + */
136 const getFileIcon = (fileName) => { 169 const getFileIcon = (fileName) => {
137 if (!fileName || typeof fileName !== 'string') { 170 if (!fileName || typeof fileName !== 'string') {
138 return 'description'; 171 return 'description';
...@@ -166,6 +199,11 @@ const getFileIcon = (fileName) => { ...@@ -166,6 +199,11 @@ const getFileIcon = (fileName) => {
166 return iconMap[extension] || 'description'; 199 return iconMap[extension] || 'description';
167 } 200 }
168 201
202 +/**
203 + * @description 获取文件类型描述
204 + * @param {string} fileName 文件名
205 + * @returns {string} 文件类型中文描述
206 + */
169 const getFileType = (fileName) => { 207 const getFileType = (fileName) => {
170 if (!fileName || typeof fileName !== 'string') { 208 if (!fileName || typeof fileName !== 'string') {
171 return '未知文件'; 209 return '未知文件';
......
...@@ -25,13 +25,22 @@ import { ref, watch, computed } from 'vue' ...@@ -25,13 +25,22 @@ import { ref, watch, computed } from 'vue'
25 import { getTeacherTaskListAPI, getTeacherTaskDetailAPI } from '@/api/teacher' 25 import { getTeacherTaskListAPI, getTeacherTaskDetailAPI } from '@/api/teacher'
26 26
27 const props = defineProps({ 27 const props = defineProps({
28 + /** 班级群组ID */
28 groupId: { 29 groupId: {
29 type: [String, Number], 30 type: [String, Number],
30 default: '' 31 default: ''
31 } 32 }
32 }) 33 })
33 34
34 -const emit = defineEmits(['change']) 35 +const emit = defineEmits([
36 + /**
37 + * 筛选条件变更事件
38 + * @property {Object} payload
39 + * @property {string|number} payload.task_id - 作业ID
40 + * @property {string|number} payload.subtask_id - 子作业ID
41 + */
42 + 'change'
43 +])
35 44
36 const show = ref(false) 45 const show = ref(false)
37 const cascaderValue = ref('') 46 const cascaderValue = ref('')
...@@ -57,6 +66,10 @@ const selectedLabel = computed(() => { ...@@ -57,6 +66,10 @@ const selectedLabel = computed(() => {
57 }) 66 })
58 67
59 // 获取作业列表(第一级) 68 // 获取作业列表(第一级)
69 +/**
70 + * @description 获取作业列表(第一级)
71 + * @returns {Promise<void>}
72 + */
60 const fetchTaskList = async () => { 73 const fetchTaskList = async () => {
61 if (!props.groupId) { 74 if (!props.groupId) {
62 options.value = [] 75 options.value = []
...@@ -73,9 +86,7 @@ const fetchTaskList = async () => { ...@@ -73,9 +86,7 @@ const fetchTaskList = async () => {
73 if (res.code === 1) { 86 if (res.code === 1) {
74 // 构造第一级数据 87 // 构造第一级数据
75 // 注意:为了让级联选择器知道还有下一级,需要给children一个空数组 88 // 注意:为了让级联选择器知道还有下一级,需要给children一个空数组
76 - // 或者我们可以一次性加载?如果不确定数据量,建议动态加载 89 + // 这里采用动态加载策略:给一个占位符,必须给children。
77 - // 这里采用动态加载策略:给一个占位符,或者如果Vant Cascader支持,可以不给children但通过change事件动态添加
78 - // Vant Cascader如果不给children,就认为是叶子节点。所以必须给children。
79 options.value = (res.data || []).map(item => ({ 90 options.value = (res.data || []).map(item => ({
80 text: item.title, 91 text: item.title,
81 value: item.id, 92 value: item.id,
...@@ -97,6 +108,11 @@ const fetchTaskList = async () => { ...@@ -97,6 +108,11 @@ const fetchTaskList = async () => {
97 } 108 }
98 109
99 // 获取子作业(第二级) 110 // 获取子作业(第二级)
111 +/**
112 + * @description 获取子作业列表(第二级)
113 + * @param {string|number} taskId 作业ID
114 + * @returns {Promise<Array>} 子作业选项列表
115 + */
100 const fetchSubtaskList = async (taskId) => { 116 const fetchSubtaskList = async (taskId) => {
101 try { 117 try {
102 const res = await getTeacherTaskDetailAPI({ id: taskId }) 118 const res = await getTeacherTaskDetailAPI({ id: taskId })
...@@ -126,6 +142,10 @@ watch(() => props.groupId, () => { ...@@ -126,6 +142,10 @@ watch(() => props.groupId, () => {
126 fetchTaskList() 142 fetchTaskList()
127 }, { immediate: true }) 143 }, { immediate: true })
128 144
145 +/**
146 + * @description 处理级联选择器变化
147 + * @param {Object} params 包含 value, selectedOptions, tabIndex
148 + */
129 const onChange = async ({ value, selectedOptions, tabIndex }) => { 149 const onChange = async ({ value, selectedOptions, tabIndex }) => {
130 // 当选中第一级(大作业)时 150 // 当选中第一级(大作业)时
131 if (tabIndex === 0) { 151 if (tabIndex === 0) {
......
...@@ -49,13 +49,27 @@ import { getTeacherTaskListAPI, getTeacherTaskDetailAPI } from '@/api/teacher' ...@@ -49,13 +49,27 @@ import { getTeacherTaskListAPI, getTeacherTaskDetailAPI } from '@/api/teacher'
49 import { showToast } from 'vant' 49 import { showToast } from 'vant'
50 50
51 const props = defineProps({ 51 const props = defineProps({
52 + /** 班级群组ID */
52 groupId: { 53 groupId: {
53 type: [String, Number], 54 type: [String, Number],
54 default: '' 55 default: ''
55 } 56 }
56 }) 57 })
57 58
58 -const emit = defineEmits(['change', 'popup-visible-change']) 59 +const emit = defineEmits([
60 + /**
61 + * 筛选条件变更事件
62 + * @property {Object} payload
63 + * @property {string|number} payload.task_id - 作业ID
64 + * @property {string|number} payload.subtask_id - 子作业ID
65 + */
66 + 'change',
67 + /**
68 + * 弹窗显示状态变更事件
69 + * @property {boolean} visible - 是否显示
70 + */
71 + 'popup-visible-change'
72 +])
59 73
60 // 状态 74 // 状态
61 const showTaskPicker = ref(false) 75 const showTaskPicker = ref(false)
...@@ -94,7 +108,13 @@ const selectedSubtaskName = computed(() => { ...@@ -94,7 +108,13 @@ const selectedSubtaskName = computed(() => {
94 return found ? found.title : '' 108 return found ? found.title : ''
95 }) 109 })
96 110
97 -// 获取大作业列表 111 +/**
112 + * @description 获取大作业列表
113 + * 1. 检查 groupId 是否存在
114 + * 2. 调用 getTeacherTaskListAPI 获取列表
115 + * 3. 更新 taskList 数据
116 + * @returns {Promise<void>}
117 + */
98 const fetchTaskList = async () => { 118 const fetchTaskList = async () => {
99 if (!props.groupId) { 119 if (!props.groupId) {
100 taskList.value = [] 120 taskList.value = []
...@@ -121,7 +141,11 @@ watch(() => props.groupId, (newVal) => { ...@@ -121,7 +141,11 @@ watch(() => props.groupId, (newVal) => {
121 fetchTaskList() 141 fetchTaskList()
122 }, { immediate: true }) 142 }, { immediate: true })
123 143
124 -// 获取大作业详情(含小作业) 144 +/**
145 + * @description 获取作业详情
146 + * @param {number|string} taskId 作业ID
147 + * @returns {Promise<void>}
148 + */
125 const fetchTaskDetail = async (taskId) => { 149 const fetchTaskDetail = async (taskId) => {
126 if (!taskId) { 150 if (!taskId) {
127 subtaskList.value = [] 151 subtaskList.value = []
...@@ -138,7 +162,11 @@ const fetchTaskDetail = async (taskId) => { ...@@ -138,7 +162,11 @@ const fetchTaskDetail = async (taskId) => {
138 } 162 }
139 } 163 }
140 164
141 -// 事件处理 165 +/**
166 + * @description 确认选择作业
167 + * @param {Object} params
168 + * @param {Array} params.selectedOptions - 选中的选项数组
169 + */
142 const onConfirmTask = async ({ selectedOptions }) => { 170 const onConfirmTask = async ({ selectedOptions }) => {
143 const option = selectedOptions[0] 171 const option = selectedOptions[0]
144 if (selectedTaskId.value !== option.value) { 172 if (selectedTaskId.value !== option.value) {
...@@ -156,6 +184,11 @@ const onConfirmTask = async ({ selectedOptions }) => { ...@@ -156,6 +184,11 @@ const onConfirmTask = async ({ selectedOptions }) => {
156 showTaskPicker.value = false 184 showTaskPicker.value = false
157 } 185 }
158 186
187 +/**
188 + * @description 确认选择子作业
189 + * @param {Object} params
190 + * @param {Array} params.selectedOptions - 选中的选项数组
191 + */
159 const onConfirmSubtask = ({ selectedOptions }) => { 192 const onConfirmSubtask = ({ selectedOptions }) => {
160 const option = selectedOptions[0] 193 const option = selectedOptions[0]
161 selectedSubtaskId.value = option.value 194 selectedSubtaskId.value = option.value
...@@ -163,6 +196,10 @@ const onConfirmSubtask = ({ selectedOptions }) => { ...@@ -163,6 +196,10 @@ const onConfirmSubtask = ({ selectedOptions }) => {
163 showSubtaskPicker.value = false 196 showSubtaskPicker.value = false
164 } 197 }
165 198
199 +/**
200 + * @description 处理子作业点击
201 + * 如果未选择作业,提示用户
202 + */
166 const handleSubtaskClick = () => { 203 const handleSubtaskClick = () => {
167 if (!selectedTaskId.value) { 204 if (!selectedTaskId.value) {
168 showToast('请先选择作业') 205 showToast('请先选择作业')
...@@ -171,6 +208,9 @@ const handleSubtaskClick = () => { ...@@ -171,6 +208,9 @@ const handleSubtaskClick = () => {
171 showSubtaskPicker.value = true 208 showSubtaskPicker.value = true
172 } 209 }
173 210
211 +/**
212 + * @description 触发 change 事件
213 + */
174 const emitChange = () => { 214 const emitChange = () => {
175 emit('change', { 215 emit('change', {
176 task_id: selectedTaskId.value, 216 task_id: selectedTaskId.value,
......
...@@ -151,12 +151,17 @@ ...@@ -151,12 +151,17 @@
151 </van-popup> 151 </van-popup>
152 </template> 152 </template>
153 153
154 +<!--
155 + * @component ActivityApplyHistoryPopup
156 + * @description 活动申请历史记录弹窗,支持增删改查
157 +-->
154 <script setup> 158 <script setup>
155 import { ref, onMounted } from 'vue' 159 import { ref, onMounted } from 'vue'
156 import { showToast, showConfirmDialog } from 'vant' 160 import { showToast, showConfirmDialog } from 'vant'
157 import { getSupplementListAPI, supplementAddAPI, supplementEditAPI, supplementDelAPI } from '@/api/recall_users' 161 import { getSupplementListAPI, supplementAddAPI, supplementEditAPI, supplementDelAPI } from '@/api/recall_users'
158 162
159 const props = defineProps({ 163 const props = defineProps({
164 + /** 是否显示弹窗 */
160 show: { 165 show: {
161 type: Boolean, 166 type: Boolean,
162 default: false 167 default: false
...@@ -165,6 +170,7 @@ const props = defineProps({ ...@@ -165,6 +170,7 @@ const props = defineProps({
165 170
166 const emit = defineEmits(['update:show']) 171 const emit = defineEmits(['update:show'])
167 172
173 +/** @type {import('vue').Ref<Array>} 申请记录列表 */
168 const apply_records = ref([]) 174 const apply_records = ref([])
169 175
170 const show_form_popup = ref(false) 176 const show_form_popup = ref(false)
...@@ -177,6 +183,9 @@ const form = ref({ ...@@ -177,6 +183,9 @@ const form = ref({
177 paid_amount: '' 183 paid_amount: ''
178 }) 184 })
179 185
186 +/**
187 + * @description 重置表单数据
188 + */
180 const reset_form = () => { 189 const reset_form = () => {
181 form.value = { 190 form.value = {
182 name: '', 191 name: '',
...@@ -187,6 +196,11 @@ const reset_form = () => { ...@@ -187,6 +196,11 @@ const reset_form = () => {
187 editing_id.value = null 196 editing_id.value = null
188 } 197 }
189 198
199 +/**
200 + * @description 获取申请记录列表
201 + * 调用 getSupplementListAPI 接口并映射数据字段
202 + * @returns {Promise<void>}
203 + */
190 const fetch_records = async () => { 204 const fetch_records = async () => {
191 const res = await getSupplementListAPI() 205 const res = await getSupplementListAPI()
192 if (res && res.code) { 206 if (res && res.code) {
...@@ -203,12 +217,19 @@ const fetch_records = async () => { ...@@ -203,12 +217,19 @@ const fetch_records = async () => {
203 } 217 }
204 } 218 }
205 219
220 +/**
221 + * @description 打开新增表单
222 + */
206 const open_add = () => { 223 const open_add = () => {
207 form_mode.value = 'add' 224 form_mode.value = 'add'
208 reset_form() 225 reset_form()
209 show_form_popup.value = true 226 show_form_popup.value = true
210 } 227 }
211 228
229 +/**
230 + * @description 打开编辑表单
231 + * @param {Object} item 记录对象
232 + */
212 const open_edit = (item) => { 233 const open_edit = (item) => {
213 form_mode.value = 'edit' 234 form_mode.value = 'edit'
214 editing_id.value = item.id 235 editing_id.value = item.id
...@@ -221,10 +242,21 @@ const open_edit = (item) => { ...@@ -221,10 +242,21 @@ const open_edit = (item) => {
221 show_form_popup.value = true 242 show_form_popup.value = true
222 } 243 }
223 244
245 +/**
246 + * @description 关闭表单弹窗
247 + */
224 const close_form_popup = () => { 248 const close_form_popup = () => {
225 show_form_popup.value = false 249 show_form_popup.value = false
226 } 250 }
227 251
252 +/**
253 + * @description 提交表单(新增或编辑)
254 + * 1. 验证必填项
255 + * 2. 构造参数
256 + * 3. 根据模式调用 add 或 edit 接口
257 + * 4. 成功后刷新列表
258 + * @returns {Promise<void>}
259 + */
228 const submit_form = async () => { 260 const submit_form = async () => {
229 if (!String(form.value.name || '').trim()) { 261 if (!String(form.value.name || '').trim()) {
230 showToast('请输入活动名称') 262 showToast('请输入活动名称')
...@@ -278,6 +310,11 @@ const submit_form = async () => { ...@@ -278,6 +310,11 @@ const submit_form = async () => {
278 } 310 }
279 } 311 }
280 312
313 +/**
314 + * @description 确认删除记录
315 + * @param {Object} item 记录对象
316 + * @returns {Promise<void>}
317 + */
281 const confirm_delete = async (item) => { 318 const confirm_delete = async (item) => {
282 try { 319 try {
283 await showConfirmDialog({ 320 await showConfirmDialog({
...@@ -300,6 +337,11 @@ const confirm_delete = async (item) => { ...@@ -300,6 +337,11 @@ const confirm_delete = async (item) => {
300 } 337 }
301 } 338 }
302 339
340 +/**
341 + * @description 格式化实付金额
342 + * @param {string|number} val 金额
343 + * @returns {string} 格式化后的金额字符串
344 + */
303 const format_paid_amount = (val) => { 345 const format_paid_amount = (val) => {
304 const str = String(val ?? '').trim() 346 const str = String(val ?? '').trim()
305 if (!str) return '¥0.00' 347 if (!str) return '¥0.00'
......
1 <!-- 1 <!--
2 * @Date: 2025-03-20 20:36:36 2 * @Date: 2025-03-20 20:36:36
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-12-04 10:40:05 4 + * @LastEditTime: 2026-01-22 16:35:54
5 * @FilePath: /mlaj/src/components/ui/ActivityCard.vue 5 * @FilePath: /mlaj/src/components/ui/ActivityCard.vue
6 - * @Description: 文件描述 6 + * @Description: 活动卡片组件
7 --> 7 -->
8 <template> 8 <template>
9 <div @click="navigateToActivity" class="block mb-4"> 9 <div @click="navigateToActivity" class="block mb-4">
...@@ -84,11 +84,18 @@ const props = defineProps({ ...@@ -84,11 +84,18 @@ const props = defineProps({
84 } 84 }
85 }) 85 })
86 86
87 +/**
88 + * @description 跳转到活动详情页
89 + */
87 const navigateToActivity = () => { 90 const navigateToActivity = () => {
88 - // window.open(`https://example.com/activities/${props.activity.id}`, '_blank')
89 window.open(`${props.activity.mock_link}`, '_blank') 91 window.open(`${props.activity.mock_link}`, '_blank')
90 } 92 }
91 93
94 +/**
95 + * @description 获取活动状态对应的样式类
96 + * @param {string} status 活动状态
97 + * @returns {string} 样式类名
98 + */
92 const getStatusClass = (status) => { 99 const getStatusClass = (status) => {
93 switch (status) { 100 switch (status) {
94 case '活动中': 101 case '活动中':
......
1 <!-- 1 <!--
2 * @Date: 2025-04-07 12:35:35 2 * @Date: 2025-04-07 12:35:35
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-12-27 23:56:18 4 + * @LastEditTime: 2026-01-22 16:04:13
5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue 5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue
6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能 6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
7 --> 7 -->
...@@ -171,7 +171,10 @@ onMounted(() => { ...@@ -171,7 +171,10 @@ onMounted(() => {
171 audio.value?.addEventListener('ended', handleEnded) 171 audio.value?.addEventListener('ended', handleEnded)
172 }) 172 })
173 173
174 -// 核心方法:音频加载 174 +/**
175 + * @description 加载音频资源
176 + * @returns {Promise<boolean>} 加载是否成功
177 + */
175 const loadAudio = async () => { 178 const loadAudio = async () => {
176 // 如果没有当前歌曲,直接返回 179 // 如果没有当前歌曲,直接返回
177 if (!currentSong.value) { 180 if (!currentSong.value) {
...@@ -247,7 +250,9 @@ const checkIfEnded = () => { ...@@ -247,7 +250,9 @@ const checkIfEnded = () => {
247 // 定义组件事件 250 // 定义组件事件
248 const emit = defineEmits(['play', 'pause']) 251 const emit = defineEmits(['play', 'pause'])
249 252
250 -// 播放控制:切换播放/暂停状态 253 +/**
254 + * @description 切换播放/暂停状态
255 + */
251 const togglePlay = async () => { 256 const togglePlay = async () => {
252 // 如果正在加载中,不执行任何操作 257 // 如果正在加载中,不执行任何操作
253 if (isLoading.value) return 258 if (isLoading.value) return
...@@ -304,7 +309,9 @@ const handleEnded = () => { ...@@ -304,7 +309,9 @@ const handleEnded = () => {
304 nextSong() 309 nextSong()
305 } 310 }
306 311
307 -// 控制方法:切换到上一首 312 +/**
313 + * @description 播放上一首
314 + */
308 const prevSong = async () => { 315 const prevSong = async () => {
309 try { 316 try {
310 // 如果音频实例存在,先暂停并清除 317 // 如果音频实例存在,先暂停并清除
...@@ -344,7 +351,9 @@ const prevSong = async () => { ...@@ -344,7 +351,9 @@ const prevSong = async () => {
344 } 351 }
345 } 352 }
346 353
347 -// 控制方法:切换到下一首 354 +/**
355 + * @description 播放下一首
356 + */
348 const nextSong = async () => { 357 const nextSong = async () => {
349 try { 358 try {
350 // 如果音频实例存在,先暂停并清除 359 // 如果音频实例存在,先暂停并清除
...@@ -396,7 +405,9 @@ const handleProgressChange = (e) => { ...@@ -396,7 +405,9 @@ const handleProgressChange = (e) => {
396 progress.value = parseFloat(target.value) 405 progress.value = parseFloat(target.value)
397 } 406 }
398 407
399 -// 跳转到指定进度 408 +/**
409 + * @description 调整播放进度
410 + */
400 const seekTo = () => { 411 const seekTo = () => {
401 if (!audio.value || !duration.value) return 412 if (!audio.value || !duration.value) return
402 audio.value.currentTime = (progress.value / 100) * duration.value 413 audio.value.currentTime = (progress.value / 100) * duration.value
......
...@@ -35,9 +35,21 @@ const router = useRouter() ...@@ -35,9 +35,21 @@ const router = useRouter()
35 const props = defineProps({ 35 const props = defineProps({
36 /** 弹窗显隐 */ 36 /** 弹窗显隐 */
37 show: { type: Boolean, required: true, default: false }, 37 show: { type: Boolean, required: true, default: false },
38 - /** 今日打卡任务(外部传入,可选) */ 38 + /**
39 + * 今日打卡任务(外部传入,可选)
40 + * @property {number|string} id - 任务ID
41 + * @property {string} name - 任务名称
42 + * @property {string} task_type - 任务类型 (checkin/upload)
43 + * @property {boolean} is_gray - 是否置灰(已完成)
44 + */
39 items_today: { type: Array, default: () => [] }, 45 items_today: { type: Array, default: () => [] },
40 - /** 历史打卡任务(外部传入,可选) */ 46 + /**
47 + * 历史打卡任务(外部传入,可选)
48 + * @property {number|string} id - 任务ID
49 + * @property {string} name - 任务名称
50 + * @property {string} task_type - 任务类型 (checkin/upload)
51 + * @property {boolean} is_gray - 是否置灰(已完成)
52 + */
41 items_history: { type: Array, default: () => [] } 53 items_history: { type: Array, default: () => [] }
42 }) 54 })
43 55
...@@ -99,6 +111,9 @@ const handleListSuccess = async () => { ...@@ -99,6 +111,9 @@ const handleListSuccess = async () => {
99 setTimeout(() => emit('update:show', false), 1500) 111 setTimeout(() => emit('update:show', false), 1500)
100 } 112 }
101 113
114 +/**
115 + * @description 关闭弹窗
116 + */
102 const handleClose = () => { 117 const handleClose = () => {
103 emit('update:show', false) 118 emit('update:show', false)
104 } 119 }
......
...@@ -125,18 +125,22 @@ import { normalizeAttachmentTypeConfig } from '@/utils/tools' ...@@ -125,18 +125,22 @@ import { normalizeAttachmentTypeConfig } from '@/utils/tools'
125 125
126 // Props定义 126 // Props定义
127 const props = defineProps({ 127 const props = defineProps({
128 + /** 标题 */
128 title: { 129 title: {
129 type: String, 130 type: String,
130 default: '选择日期' 131 default: '选择日期'
131 }, 132 },
133 + /** 日历格式化函数 */
132 formatter: { 134 formatter: {
133 type: Function, 135 type: Function,
134 required: true 136 required: true
135 }, 137 },
138 + /** 当前选中日期 (v-model) */
136 modelValue: { 139 modelValue: {
137 type: [String, Date], 140 type: [String, Date],
138 default: null 141 default: null
139 }, 142 },
143 + /** 子作业列表,用于筛选 */
140 subtaskList: { 144 subtaskList: {
141 type: Array, 145 type: Array,
142 default: () => [] 146 default: () => []
......
...@@ -30,18 +30,25 @@ ...@@ -30,18 +30,25 @@
30 </Teleport> 30 </Teleport>
31 </template> 31 </template>
32 32
33 +<!--
34 + * @component ConfirmDialog
35 + * @description 确认对话框组件,基于 FrostedGlass
36 +-->
33 <script setup> 37 <script setup>
34 import FrostedGlass from './FrostedGlass.vue' 38 import FrostedGlass from './FrostedGlass.vue'
35 39
36 const props = defineProps({ 40 const props = defineProps({
41 + /** 是否显示弹窗 (v-model) */
37 show: { 42 show: {
38 type: Boolean, 43 type: Boolean,
39 default: false 44 default: false
40 }, 45 },
46 + /** 标题 */
41 title: { 47 title: {
42 type: String, 48 type: String,
43 default: '确认' 49 default: '确认'
44 }, 50 },
51 + /** 消息内容 */
45 message: { 52 message: {
46 type: String, 53 type: String,
47 required: true 54 required: true
...@@ -50,11 +57,17 @@ const props = defineProps({ ...@@ -50,11 +57,17 @@ const props = defineProps({
50 57
51 const emit = defineEmits(['confirm', 'cancel', 'update:show']) 58 const emit = defineEmits(['confirm', 'cancel', 'update:show'])
52 59
60 +/**
61 + * @description 确认操作
62 + */
53 const handleConfirm = () => { 63 const handleConfirm = () => {
54 emit('confirm') 64 emit('confirm')
55 emit('update:show', false) 65 emit('update:show', false)
56 } 66 }
57 67
68 +/**
69 + * @description 取消操作
70 + */
58 const handleCancel = () => { 71 const handleCancel = () => {
59 emit('cancel') 72 emit('cancel')
60 emit('update:show', false) 73 emit('update:show', false)
......
...@@ -44,6 +44,11 @@ ...@@ -44,6 +44,11 @@
44 </template> 44 </template>
45 45
46 <script setup> 46 <script setup>
47 +/**
48 + * @description 课程卡片组件
49 + * @property {Object} course 课程对象
50 + * @property {string} linkTo 跳转链接(可选)
51 + */
47 defineProps({ 52 defineProps({
48 course: { 53 course: {
49 type: Object, 54 type: Object,
......
1 <!-- 1 <!--
2 * @Date: 2025-01-21 14:31:21 2 * @Date: 2025-01-21 14:31:21
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-10-20 17:56:14 4 + * @LastEditTime: 2026-01-22 16:17:22
5 * @FilePath: /mlaj/src/components/ui/CourseImageCard.vue 5 * @FilePath: /mlaj/src/components/ui/CourseImageCard.vue
6 * @Description: 课程图片卡片组件 - 一行显示一张图片,高度自适应 6 * @Description: 课程图片卡片组件 - 一行显示一张图片,高度自适应
7 --> 7 -->
...@@ -52,12 +52,22 @@ ...@@ -52,12 +52,22 @@
52 * 课程图片卡片组件的属性定义 52 * 课程图片卡片组件的属性定义
53 */ 53 */
54 defineProps({ 54 defineProps({
55 - // 课程数据对象 55 + /**
56 + * 课程数据对象
57 + * @property {number|string} id - 课程ID
58 + * @property {string} title - 课程标题
59 + * @property {string} subtitle - 副标题
60 + * @property {string} cover - 封面图片URL
61 + * @property {string} price - 价格
62 + * @property {boolean} is_buy - 是否已购买
63 + * @property {number} count - 更新期数
64 + * @property {number} buy_count - 订阅人数
65 + */
56 course: { 66 course: {
57 type: Object, 67 type: Object,
58 required: true 68 required: true
59 }, 69 },
60 - // 自定义链接地址 70 + /** 自定义链接地址 (可选,覆盖默认课程详情链接) */
61 linkTo: { 71 linkTo: {
62 type: String, 72 type: String,
63 default: '' 73 default: ''
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 * @LastEditTime: 2025-03-21 15:37:45 4 * @LastEditTime: 2025-03-21 15:37:45
5 * @FilePath: /mlaj/src/components/ui/FrostedGlass.vue 5 * @FilePath: /mlaj/src/components/ui/FrostedGlass.vue
6 - * @Description: 文件描述 6 + * @Description: 毛玻璃效果背景组件
7 --> 7 -->
8 <template> 8 <template>
9 <div 9 <div
...@@ -18,15 +18,25 @@ ...@@ -18,15 +18,25 @@
18 18
19 <script setup> 19 <script setup>
20 defineProps({ 20 defineProps({
21 + /**
22 + * 自定义类名
23 + */
21 className: { 24 className: {
22 type: String, 25 type: String,
23 default: '' 26 default: ''
24 }, 27 },
28 + /**
29 + * 背景透明度 (0-100)
30 + */
25 bgOpacity: { 31 bgOpacity: {
26 type: Number, 32 type: Number,
27 default: 80, 33 default: 80,
28 validator: (value) => value >= 0 && value <= 100 34 validator: (value) => value >= 0 && value <= 100
29 }, 35 },
36 + /**
37 + * 模糊等级
38 + * @values 'none', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'
39 + */
30 blurLevel: { 40 blurLevel: {
31 type: String, 41 type: String,
32 default: 'sm', 42 default: 'sm',
......
...@@ -24,21 +24,25 @@ ...@@ -24,21 +24,25 @@
24 24
25 <script setup> 25 <script setup>
26 defineProps({ 26 defineProps({
27 + /** 标题文本 */
27 title: { 28 title: {
28 type: String, 29 type: String,
29 required: true 30 required: true
30 }, 31 },
32 + /** 是否显示返回按钮 */
31 showBackButton: { 33 showBackButton: {
32 type: Boolean, 34 type: Boolean,
33 default: false 35 default: false
34 }, 36 },
37 + /** 返回按钮点击回调函数 */
35 onBack: { 38 onBack: {
36 type: Function, 39 type: Function,
37 default: () => {} 40 default: () => {}
38 }, 41 },
42 + /** 右侧内容配置对象 */
39 rightContent: { 43 rightContent: {
40 type: Object, 44 type: Object,
41 - default: null 45 + default: null,
42 - } 46 + },
43 }) 47 })
44 </script> 48 </script>
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 * @LastEditTime: 2025-03-21 15:38:03 4 * @LastEditTime: 2025-03-21 15:38:03
5 * @FilePath: /mlaj/src/components/ui/LiveStreamCard.vue 5 * @FilePath: /mlaj/src/components/ui/LiveStreamCard.vue
6 - * @Description: 文件描述 6 + * @Description: 直播流展示卡片组件
7 --> 7 -->
8 <template> 8 <template>
9 <div class="relative"> 9 <div class="relative">
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
44 44
45 <script setup> 45 <script setup>
46 defineProps({ 46 defineProps({
47 + /** 直播流数据对象 */
47 stream: { 48 stream: {
48 type: Object, 49 type: Object,
49 required: true 50 required: true
......
...@@ -98,18 +98,22 @@ ...@@ -98,18 +98,22 @@
98 98
99 <script setup> 99 <script setup>
100 defineProps({ 100 defineProps({
101 + /** 图标名称 (Iconify 或本地图标) */
101 icon: { 102 icon: {
102 type: String, 103 type: String,
103 required: true 104 required: true
104 }, 105 },
106 + /** 菜单标题 */
105 title: { 107 title: {
106 type: String, 108 type: String,
107 required: true 109 required: true
108 }, 110 },
111 + /** 跳转路径 */
109 path: { 112 path: {
110 type: String, 113 type: String,
111 required: true 114 required: true
112 }, 115 },
116 + /** 徽标内容 (可选) */
113 badge: { 117 badge: {
114 type: String, 118 type: String,
115 default: '' 119 default: ''
......
...@@ -94,7 +94,7 @@ const error = ref('') ...@@ -94,7 +94,7 @@ const error = ref('')
94 const containerHeight = computed(() => props.height) 94 const containerHeight = computed(() => props.height)
95 95
96 /** 96 /**
97 - * 文档渲染完成回调 97 + * @description 文档渲染完成回调
98 */ 98 */
99 const onRendered = () => { 99 const onRendered = () => {
100 console.log('Office document rendered successfully') 100 console.log('Office document rendered successfully')
...@@ -104,7 +104,7 @@ const onRendered = () => { ...@@ -104,7 +104,7 @@ const onRendered = () => {
104 } 104 }
105 105
106 /** 106 /**
107 - * 文档渲染错误回调 107 + * @description 文档渲染错误回调
108 * @param {Error} err - 错误对象 108 * @param {Error} err - 错误对象
109 */ 109 */
110 const onError = (err) => { 110 const onError = (err) => {
...@@ -115,7 +115,7 @@ const onError = (err) => { ...@@ -115,7 +115,7 @@ const onError = (err) => {
115 } 115 }
116 116
117 /** 117 /**
118 - * 重试加载文档 118 + * @description 重试加载文档
119 */ 119 */
120 const retry = () => { 120 const retry = () => {
121 console.log('Retrying to load office document') 121 console.log('Retrying to load office document')
...@@ -125,7 +125,7 @@ const retry = () => { ...@@ -125,7 +125,7 @@ const retry = () => {
125 } 125 }
126 126
127 /** 127 /**
128 - * 监听 src 变化,重新加载文档 128 + * @description 监听 src 变化,重新加载文档
129 */ 129 */
130 watch(() => props.src, (newSrc) => { 130 watch(() => props.src, (newSrc) => {
131 console.log('Office document src changed:', newSrc) 131 console.log('Office document src changed:', newSrc)
...@@ -151,13 +151,14 @@ watch(() => props.src, (newSrc) => { ...@@ -151,13 +151,14 @@ watch(() => props.src, (newSrc) => {
151 }, { immediate: true }) 151 }, { immediate: true })
152 152
153 /** 153 /**
154 - * 监听 fileType 变化 154 + * @description 监听 fileType 变化
155 */ 155 */
156 watch(() => props.fileType, (newType) => { 156 watch(() => props.fileType, (newType) => {
157 console.log('Office document fileType changed:', newType) 157 console.log('Office document fileType changed:', newType)
158 }) 158 })
159 159
160 // 响应式数据 160 // 响应式数据
161 +/** @type {import('vue').Ref<boolean>} 加载状态 */
161 const loading = ref(false) 162 const loading = ref(false)
162 163
163 // 组件挂载时的初始化 164 // 组件挂载时的初始化
......
...@@ -540,12 +540,13 @@ const handleClose = () => { ...@@ -540,12 +540,13 @@ const handleClose = () => {
540 }; 540 };
541 541
542 /** 542 /**
543 - * 放大PDF 543 + * @description 放大PDF
544 */ 544 */
545 const zoomIn = () => { 545 const zoomIn = () => {
546 - if (zoomLevel.value < 3) { // 最大放大到300% 546 + if (zoomLevel.value < 3) {
547 const pdfContainer = document.querySelector('.pdf-viewer-container .pdf-content'); 547 const pdfContainer = document.querySelector('.pdf-viewer-container .pdf-content');
548 if (pdfContainer) { 548 if (pdfContainer) {
549 + // 记录缩放前的滚动位置和视口中心
549 const oldW = pdfContainer.scrollWidth; 550 const oldW = pdfContainer.scrollWidth;
550 const oldH = pdfContainer.scrollHeight; 551 const oldH = pdfContainer.scrollHeight;
551 const viewportW = pdfContainer.clientWidth; 552 const viewportW = pdfContainer.clientWidth;
...@@ -554,6 +555,7 @@ const zoomIn = () => { ...@@ -554,6 +555,7 @@ const zoomIn = () => {
554 const centerX = pdfContainer.scrollLeft + viewportW / 2; 555 const centerX = pdfContainer.scrollLeft + viewportW / 2;
555 const centerY = pdfContainer.scrollTop + viewportH / 2; 556 const centerY = pdfContainer.scrollTop + viewportH / 2;
556 557
558 + // 计算锚点比例
557 const anchorRatioX = oldW > 0 ? centerX / oldW : 0; 559 const anchorRatioX = oldW > 0 ? centerX / oldW : 0;
558 const anchorRatioY = oldH > 0 ? centerY / oldH : 0; 560 const anchorRatioY = oldH > 0 ? centerY / oldH : 0;
559 561
...@@ -563,16 +565,17 @@ const zoomIn = () => { ...@@ -563,16 +565,17 @@ const zoomIn = () => {
563 const newW = pdfContainer.scrollWidth; 565 const newW = pdfContainer.scrollWidth;
564 const newH = pdfContainer.scrollHeight; 566 const newH = pdfContainer.scrollHeight;
565 567
568 + // 计算新的中心点位置
566 let targetCenterX = newW * anchorRatioX; 569 let targetCenterX = newW * anchorRatioX;
567 let targetCenterY = newH * anchorRatioY; 570 let targetCenterY = newH * anchorRatioY;
568 571
569 let newScrollLeft = Math.max(0, targetCenterX - viewportW / 2); 572 let newScrollLeft = Math.max(0, targetCenterX - viewportW / 2);
570 let newScrollTop = Math.max(0, targetCenterY - viewportH / 2); 573 let newScrollTop = Math.max(0, targetCenterY - viewportH / 2);
571 574
575 + // 边界检查
572 newScrollLeft = Math.min(newScrollLeft, Math.max(0, newW - viewportW)); 576 newScrollLeft = Math.min(newScrollLeft, Math.max(0, newW - viewportW));
573 newScrollTop = Math.min(newScrollTop, Math.max(0, newH - viewportH)); 577 newScrollTop = Math.min(newScrollTop, Math.max(0, newH - viewportH));
574 578
575 - // 直接赋值避免平滑滚动导致的偏移抖动
576 pdfContainer.scrollLeft = newScrollLeft; 579 pdfContainer.scrollLeft = newScrollLeft;
577 pdfContainer.scrollTop = newScrollTop; 580 pdfContainer.scrollTop = newScrollTop;
578 }); 581 });
...@@ -583,12 +586,13 @@ const zoomIn = () => { ...@@ -583,12 +586,13 @@ const zoomIn = () => {
583 }; 586 };
584 587
585 /** 588 /**
586 - * 缩小PDF 589 + * @description 缩小PDF
587 */ 590 */
588 const zoomOut = () => { 591 const zoomOut = () => {
589 - if (zoomLevel.value > 0.5) { // 最小缩小到50% 592 + if (zoomLevel.value > 0.5) {
590 const pdfContainer = document.querySelector('.pdf-viewer-container .pdf-content'); 593 const pdfContainer = document.querySelector('.pdf-viewer-container .pdf-content');
591 if (pdfContainer) { 594 if (pdfContainer) {
595 + // 记录缩放前的滚动位置
592 const oldW = pdfContainer.scrollWidth; 596 const oldW = pdfContainer.scrollWidth;
593 const oldH = pdfContainer.scrollHeight; 597 const oldH = pdfContainer.scrollHeight;
594 const viewportW = pdfContainer.clientWidth; 598 const viewportW = pdfContainer.clientWidth;
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
13 <div class="mt-4 text-sm font-medium">海报生成中...</div> 13 <div class="mt-4 text-sm font-medium">海报生成中...</div>
14 </div> 14 </div>
15 15
16 - <!-- Canvas (Hidden) --> 16 + <!-- Canvas (隐藏) -->
17 <canvas ref="canvasRef" class="hidden"></canvas> 17 <canvas ref="canvasRef" class="hidden"></canvas>
18 </div> 18 </div>
19 </template> 19 </template>
...@@ -24,18 +24,22 @@ import { showToast } from 'vant' ...@@ -24,18 +24,22 @@ import { showToast } from 'vant'
24 // import request from '@/utils/axios' // 移除 axios 依赖,改用 fetch 24 // import request from '@/utils/axios' // 移除 axios 依赖,改用 fetch
25 25
26 const props = defineProps({ 26 const props = defineProps({
27 + /** 海报背景图片 URL */
27 bgUrl: { 28 bgUrl: {
28 type: String, 29 type: String,
29 required: true 30 required: true
30 }, 31 },
32 + /** 海报标题 */
31 title: { 33 title: {
32 type: String, 34 type: String,
33 default: '' 35 default: ''
34 }, 36 },
37 + /** Logo 图片 URL */
35 logoUrl: { 38 logoUrl: {
36 type: String, 39 type: String,
37 default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/kai@2x.png' 40 default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/kai@2x.png'
38 }, 41 },
42 + /** 二维码图片 URL */
39 qrUrl: { 43 qrUrl: {
40 type: String, 44 type: String,
41 default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/%E4%BA%8C%E7%BB%B4%E7%A0%81@2x.png' 45 default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/%E4%BA%8C%E7%BB%B4%E7%A0%81@2x.png'
...@@ -84,6 +88,16 @@ const drawRoundedRect = (ctx, x, y, width, height, radius) => { ...@@ -84,6 +88,16 @@ const drawRoundedRect = (ctx, x, y, width, height, radius) => {
84 } 88 }
85 89
86 // 工具函数:绘制多行文本 90 // 工具函数:绘制多行文本
91 +/**
92 + * 绘制多行文本
93 + * @param {CanvasRenderingContext2D} ctx - Canvas上下文
94 + * @param {string} text - 文本内容
95 + * @param {number} x - x坐标
96 + * @param {number} y - y坐标
97 + * @param {number} maxWidth - 最大宽度
98 + * @param {number} lineHeight - 行高
99 + * @param {number} maxLines - 最大行数
100 + */
87 const wrapText = (ctx, text, x, y, maxWidth, lineHeight, maxLines) => { 101 const wrapText = (ctx, text, x, y, maxWidth, lineHeight, maxLines) => {
88 const words = text.split('') // 中文按字分割 102 const words = text.split('') // 中文按字分割
89 let line = '' 103 let line = ''
......
...@@ -61,6 +61,7 @@ import resolveConfig from 'tailwindcss/resolveConfig'; ...@@ -61,6 +61,7 @@ import resolveConfig from 'tailwindcss/resolveConfig';
61 import tailwindConfig from '@root/tailwind.config.js'; 61 import tailwindConfig from '@root/tailwind.config.js';
62 62
63 const props = defineProps({ 63 const props = defineProps({
64 + /** 夏令营项目列表 */
64 items: { 65 items: {
65 type: Array, 66 type: Array,
66 default: () => [ 67 default: () => [
......
...@@ -57,9 +57,21 @@ import { ref, computed, watch } from 'vue' ...@@ -57,9 +57,21 @@ import { ref, computed, watch } from 'vue'
57 * 组件对外暴露的 v-model 值:选中日期(YYYY-MM-DD) 57 * 组件对外暴露的 v-model 值:选中日期(YYYY-MM-DD)
58 */ 58 */
59 const props = defineProps({ 59 const props = defineProps({
60 - modelValue: { type: String, default: '' }, 60 + /** 当前选中的日期 (v-model) */
61 - // 是否不在初始化时默认选中“今日”,用于弹窗场景 61 + modelValue: {
62 - noDefaultSelect: { type: Boolean, default: false } 62 + type: Date,
63 + default: () => new Date()
64 + },
65 + /**
66 + * 任务列表数据
67 + * @property {string} date - 日期字符串 (YYYY-MM-DD)
68 + * @property {number} count - 任务数量
69 + * @property {boolean} hasUnread - 是否有未读
70 + */
71 + tasks: {
72 + type: Array,
73 + default: () => []
74 + }
63 }) 75 })
64 const emit = defineEmits(['update:modelValue', 'select']) 76 const emit = defineEmits(['update:modelValue', 'select'])
65 77
......
...@@ -68,16 +68,19 @@ ...@@ -68,16 +68,19 @@
68 68
69 <script setup> 69 <script setup>
70 defineProps({ 70 defineProps({
71 + /** 是否显示弹窗 */
71 show: { 72 show: {
72 type: Boolean, 73 type: Boolean,
73 required: true, 74 required: true,
74 default: false 75 default: false
75 }, 76 },
77 + /** 协议类型: 'terms' | 'privacy' */
76 type: { 78 type: {
77 type: String, 79 type: String,
78 required: true, 80 required: true,
79 validator: (value) => ['terms', 'privacy'].includes(value) 81 validator: (value) => ['terms', 'privacy'].includes(value)
80 }, 82 },
83 + /** 弹窗标题 */
81 title: { 84 title: {
82 type: String, 85 type: String,
83 required: true 86 required: true
......
...@@ -44,13 +44,27 @@ import VideoPlayer from '@/components/ui/VideoPlayer.vue'; ...@@ -44,13 +44,27 @@ import VideoPlayer from '@/components/ui/VideoPlayer.vue';
44 import { uploadFile, validateFile } from '@/utils/upload'; 44 import { uploadFile, validateFile } from '@/utils/upload';
45 45
46 const props = defineProps({ 46 const props = defineProps({
47 + /** 是否显示弹窗 (v-model) */
47 modelValue: { 48 modelValue: {
48 type: Boolean, 49 type: Boolean,
49 required: true 50 required: true
50 } 51 }
51 }); 52 });
52 53
53 -const emit = defineEmits(['update:modelValue', 'submit', 'cancel']); 54 +const emit = defineEmits([
55 + /** 更新显示状态 */
56 + 'update:modelValue',
57 + /**
58 + * 提交视频事件
59 + * @property {Object} payload
60 + * @property {string} payload.url - 视频URL
61 + * @property {string} payload.id - 视频ID/Hash
62 + * @property {string} payload.name - 视频文件名
63 + */
64 + 'submit',
65 + /** 取消事件 */
66 + 'cancel'
67 +]);
54 68
55 const show = ref(false); 69 const show = ref(false);
56 70
...@@ -75,6 +89,11 @@ const videoName = ref(''); ...@@ -75,6 +89,11 @@ const videoName = ref('');
75 const uploadProgress = ref(0); 89 const uploadProgress = ref(0);
76 const maxSize = 100 * 1024 * 1024; // 100MB 90 const maxSize = 100 * 1024 * 1024; // 100MB
77 91
92 +/**
93 + * @description 文件读取前校验
94 + * @param {File} file 文件对象
95 + * @returns {boolean} 是否通过校验
96 + */
78 const beforeRead = (file) => { 97 const beforeRead = (file) => {
79 const validation = validateFile(file); 98 const validation = validateFile(file);
80 if (!validation.valid) { 99 if (!validation.valid) {
...@@ -84,6 +103,10 @@ const beforeRead = (file) => { ...@@ -84,6 +103,10 @@ const beforeRead = (file) => {
84 return true; 103 return true;
85 }; 104 };
86 105
106 +/**
107 + * @description 文件读取后上传
108 + * @param {Object} file 读取的文件对象
109 + */
87 const afterRead = async (file) => { 110 const afterRead = async (file) => {
88 // 清除之前的上传结果 111 // 清除之前的上传结果
89 videoUrl.value = ''; 112 videoUrl.value = '';
...@@ -112,12 +135,18 @@ const afterRead = async (file) => { ...@@ -112,12 +135,18 @@ const afterRead = async (file) => {
112 } 135 }
113 }; 136 };
114 137
138 +/**
139 + * @description 文件超过大小限制回调
140 + */
115 const onOversize = () => { 141 const onOversize = () => {
116 showToast('文件大小不能超过100MB'); 142 showToast('文件大小不能超过100MB');
117 }; 143 };
118 144
119 const videoPlayerRef = ref(null); 145 const videoPlayerRef = ref(null);
120 146
147 +/**
148 + * @description 提交视频
149 + */
121 const onSubmit = () => { 150 const onSubmit = () => {
122 if (!videoUrl.value || !videoId.value) { 151 if (!videoUrl.value || !videoId.value) {
123 showToast('请先上传视频'); 152 showToast('请先上传视频');
...@@ -132,6 +161,9 @@ const onSubmit = () => { ...@@ -132,6 +161,9 @@ const onSubmit = () => {
132 emit('update:modelValue', false); 161 emit('update:modelValue', false);
133 }; 162 };
134 163
164 +/**
165 + * @description 取消上传
166 + */
135 const onCancel = () => { 167 const onCancel = () => {
136 videoPlayerRef.value?.pause(); 168 videoPlayerRef.value?.pause();
137 emit('cancel'); 169 emit('cancel');
......
...@@ -55,10 +55,16 @@ import { ref, defineExpose } from 'vue'; ...@@ -55,10 +55,16 @@ import { ref, defineExpose } from 'vue';
55 55
56 const show = ref(false); 56 const show = ref(false);
57 57
58 +/**
59 + * @description 关闭协议弹窗
60 + */
58 const handleClose = () => { 61 const handleClose = () => {
59 show.value = false; 62 show.value = false;
60 }; 63 };
61 64
65 +/**
66 + * @description 打开协议弹窗
67 + */
62 const openAgreement = () => { 68 const openAgreement = () => {
63 show.value = true; 69 show.value = true;
64 }; 70 };
......
...@@ -123,9 +123,28 @@ const { ...@@ -123,9 +123,28 @@ const {
123 } = useVideoPlayer(props, emit, videoRef, nativeVideoRef); 123 } = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
124 124
125 // 事件处理 125 // 事件处理
126 +/**
127 + * @description 处理播放事件
128 + * @param {Event|Object} payload - 事件对象或数据
129 + */
126 const handlePlay = (payload) => emit("onPlay", payload); 130 const handlePlay = (payload) => emit("onPlay", payload);
131 +
132 +/**
133 + * @description 处理暂停事件
134 + * @param {Event|Object} payload - 事件对象或数据
135 + */
127 const handlePause = (payload) => emit("onPause", payload); 136 const handlePause = (payload) => emit("onPause", payload);
137 +
138 +/**
139 + * @description 处理原生播放事件
140 + * @param {Event} event - 事件对象
141 + */
128 const handleNativePlay = (event) => emit("onPlay", event); 142 const handleNativePlay = (event) => emit("onPlay", event);
143 +
144 +/**
145 + * @description 处理原生暂停事件
146 + * @param {Event} event - 事件对象
147 + */
129 const handleNativePause = (event) => emit("onPause", event); 148 const handleNativePause = (event) => emit("onPause", event);
130 149
131 // 暴露方法给父组件 150 // 暴露方法给父组件
......
...@@ -7,31 +7,61 @@ import { qiniuFileHash } from '@/utils/qiniuFileHash'; ...@@ -7,31 +7,61 @@ import { qiniuFileHash } from '@/utils/qiniuFileHash';
7 import { useAuth } from '@/contexts/auth' 7 import { useAuth } from '@/contexts/auth'
8 8
9 /** 9 /**
10 - * 打卡功能的composable 10 + * 打卡核心逻辑封装
11 - * @returns {Object} 打卡相关的状态和方法 11 + * @module useCheckin
12 + * @description
13 + * 该 Hook 封装了打卡页面的核心业务逻辑,包括:
14 + * 1. 状态管理:上传状态、文件列表、打卡类型、计数器等。
15 + * 2. 文件处理:文件选择、校验(类型/大小)、MD5计算、七牛云上传。
16 + * 3. 提交逻辑:表单校验、数据组装、新增/编辑API调用。
17 + * 4. 数据回显:编辑模式下的数据初始化与恢复。
18 + *
19 + * @returns {Object} 包含响应式状态和操作方法的对象
12 */ 20 */
13 export function useCheckin() { 21 export function useCheckin() {
14 const route = useRoute() 22 const route = useRoute()
15 const router = useRouter() 23 const router = useRouter()
16 const { currentUser } = useAuth() 24 const { currentUser } = useAuth()
17 25
18 - // 基础状态 26 + // ==================== 状态定义 ====================
27 +
28 + /** @type {import('vue').Ref<boolean>} 上传中状态 */
19 const uploading = ref(false) 29 const uploading = ref(false)
30 + /** @type {import('vue').Ref<boolean>} 加载中状态 */
20 const loading = ref(false) 31 const loading = ref(false)
32 + /** @type {import('vue').Ref<string>} 打卡文本内容 */
21 const message = ref('') 33 const message = ref('')
34 + /** @type {import('vue').Ref<Array>} 文件列表 */
22 const fileList = ref([]) 35 const fileList = ref([])
23 - const activeType = ref('') // 当前选中的打卡类型 36 + /** @type {import('vue').Ref<string>} 当前选中的打卡类型 (text/image/video/audio) */
24 - const subTaskId = ref('') // 当前选中的任务ID 37 + const activeType = ref('')
25 - const selectedTaskText = ref('') // 选中的任务文本 38 + /** @type {import('vue').Ref<string>} 当前选中的子任务ID */
26 - const selectedTaskValue = ref([]) // 选中的任务值(Picker使用) 39 + const subTaskId = ref('')
27 - const isMakeup = ref(false) // 是否为补录作业 40 + /** @type {import('vue').Ref<string>} 选中的任务文本显示 */
41 + const selectedTaskText = ref('')
42 + /** @type {import('vue').Ref<Array>} 选中的任务值(Picker使用) */
43 + const selectedTaskValue = ref([])
44 + /** @type {import('vue').Ref<boolean>} 是否为补录作业 */
45 + const isMakeup = ref(false)
46 + /** @type {import('vue').Ref<number>} 最大上传数量 */
28 const maxCount = ref(5) 47 const maxCount = ref(5)
48 +
49 + /**
50 + * 各类型文件的最大体积限制 (MB)
51 + * @type {import('vue').Ref<Object>}
52 + */
29 const maxFileSizeMbMap = ref({ 53 const maxFileSizeMbMap = ref({
30 image: 20, 54 image: 20,
31 video: 20, 55 video: 20,
32 audio: 20 56 audio: 20
33 }) 57 })
34 58
59 + // ==================== 计算属性 ====================
60 +
61 + /**
62 + * 当前类型文件的最大体积限制
63 + * @returns {number} 体积限制(MB)
64 + */
35 const maxFileSizeMb = computed(() => { 65 const maxFileSizeMb = computed(() => {
36 const type = String(activeType.value || '') 66 const type = String(activeType.value || '')
37 const raw = maxFileSizeMbMap.value?.[type] 67 const raw = maxFileSizeMbMap.value?.[type]
...@@ -44,7 +74,6 @@ export function useCheckin() { ...@@ -44,7 +74,6 @@ export function useCheckin() {
44 * 设置最大文件大小映射 74 * 设置最大文件大小映射
45 * @param {Object} map - 包含 image, video, audio 键的对象 75 * @param {Object} map - 包含 image, video, audio 键的对象
46 */ 76 */
47 -
48 const setMaxFileSizeMbMap = (map = {}) => { 77 const setMaxFileSizeMbMap = (map = {}) => {
49 if (!map || typeof map !== 'object') return 78 if (!map || typeof map !== 'object') return
50 79
...@@ -59,10 +88,16 @@ export function useCheckin() { ...@@ -59,10 +88,16 @@ export function useCheckin() {
59 maxFileSizeMbMap.value = next 88 maxFileSizeMbMap.value = next
60 } 89 }
61 90
62 - // 打卡类型 91 + /**
92 + * 从路由获取打卡类型
93 + * @returns {string} 打卡类型
94 + */
63 const checkinType = computed(() => route.query.task_type) 95 const checkinType = computed(() => route.query.task_type)
64 96
65 - // 用于记忆不同类型的文件列表 97 + /**
98 + * 用于记忆不同类型的文件列表,切换类型时恢复
99 + * @type {import('vue').Ref<Object>}
100 + */
66 const fileListMemory = ref({ 101 const fileListMemory = ref({
67 text: [], 102 text: [],
68 image: [], 103 image: [],
...@@ -72,6 +107,12 @@ export function useCheckin() { ...@@ -72,6 +107,12 @@ export function useCheckin() {
72 107
73 /** 108 /**
74 * 是否可以提交 109 * 是否可以提交
110 + * @description
111 + * 校验逻辑:
112 + * 1. 计数打卡:直接通过,由组件内部校验
113 + * 2. 文字打卡:必须有内容且长度 >= 10
114 + * 3. 多媒体打卡:必须有文件上传
115 + * @returns {boolean}
75 */ 116 */
76 const canSubmit = computed(() => { 117 const canSubmit = computed(() => {
77 // 如果是计数打卡,交由组件内部校验 118 // 如果是计数打卡,交由组件内部校验
...@@ -88,11 +129,13 @@ export function useCheckin() { ...@@ -88,11 +129,13 @@ export function useCheckin() {
88 } 129 }
89 }) 130 })
90 131
132 + // ==================== 文件处理方法 ====================
133 +
91 /** 134 /**
92 * 获取文件哈希(与七牛云ETag一致) 135 * 获取文件哈希(与七牛云ETag一致)
93 * @param {File} file 文件对象 136 * @param {File} file 文件对象
94 * @returns {Promise<string>} 哈希字符串 137 * @returns {Promise<string>} 哈希字符串
95 - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案 138 + * @description 使用 qiniuFileHash 进行计算,替代浏览器MD5方案,确保与七牛云存储一致性
96 */ 139 */
97 const getFileMD5 = async (file) => { 140 const getFileMD5 = async (file) => {
98 return await qiniuFileHash(file) 141 return await qiniuFileHash(file)
...@@ -104,6 +147,11 @@ export function useCheckin() { ...@@ -104,6 +147,11 @@ export function useCheckin() {
104 * @param {string} token - 七牛云token 147 * @param {string} token - 七牛云token
105 * @param {string} fileName - 文件名 148 * @param {string} fileName - 文件名
106 * @returns {Promise<Object>} 上传结果 149 * @returns {Promise<Object>} 上传结果
150 + * @description
151 + * 数据流:
152 + * 1. 构建 FormData,包含 file, token, key
153 + * 2. 根据协议(http/https)选择上传域名
154 + * 3. 调用 qiniuUploadAPI 执行上传
107 */ 155 */
108 const uploadToQiniu = async (file, token, fileName) => { 156 const uploadToQiniu = async (file, token, fileName) => {
109 const formData = new FormData() 157 const formData = new FormData()
...@@ -124,9 +172,16 @@ export function useCheckin() { ...@@ -124,9 +172,16 @@ export function useCheckin() {
124 } 172 }
125 173
126 /** 174 /**
127 - * 处理单个文件上传 175 + * 处理单个文件上传流程
128 * @param {Object} file - 文件对象 176 * @param {Object} file - 文件对象
129 * @returns {Promise<Object|null>} 上传结果 177 * @returns {Promise<Object|null>} 上传结果
178 + * @description
179 + * 完整上传流程:
180 + * 1. 计算文件 MD5
181 + * 2. 调用 qiniuTokenAPI 获取上传凭证或秒传检测
182 + * 3. 如果已存在(秒传),直接返回文件信息
183 + * 4. 如果不存在,构造文件名并上传到七牛云
184 + * 5. 上传成功后调用 saveFileAPI 保存文件记录到后端
130 */ 185 */
131 const handleUpload = async (file) => { 186 const handleUpload = async (file) => {
132 loading.value = true 187 loading.value = true
...@@ -193,6 +248,11 @@ export function useCheckin() { ...@@ -193,6 +248,11 @@ export function useCheckin() {
193 * 文件上传前的校验 248 * 文件上传前的校验
194 * @param {File|File[]} file - 文件或文件数组 249 * @param {File|File[]} file - 文件或文件数组
195 * @returns {boolean} 是否通过校验 250 * @returns {boolean} 是否通过校验
251 + * @description
252 + * 校验规则:
253 + * 1. 数量限制:当前已选 + 新选 <= maxCount
254 + * 2. 大小限制:每个文件 <= maxFileSizeMb
255 + * 3. 类型限制:根据 activeType 校验 MIME type
196 */ 256 */
197 const beforeRead = (file) => { 257 const beforeRead = (file) => {
198 let flag = true 258 let flag = true
...@@ -247,6 +307,7 @@ export function useCheckin() { ...@@ -247,6 +307,7 @@ export function useCheckin() {
247 /** 307 /**
248 * 文件读取后的处理 308 * 文件读取后的处理
249 * @param {File|File[]} file - 文件或文件数组 309 * @param {File|File[]} file - 文件或文件数组
310 + * @description 遍历文件列表,逐个执行上传,并更新状态
250 */ 311 */
251 const afterRead = async (file) => { 312 const afterRead = async (file) => {
252 const files = Array.isArray(file) ? file : [file] 313 const files = Array.isArray(file) ? file : [file]
...@@ -282,7 +343,7 @@ export function useCheckin() { ...@@ -282,7 +343,7 @@ export function useCheckin() {
282 } 343 }
283 344
284 /** 345 /**
285 - * 删除文件项 346 + * 删除文件项(别名)
286 * @param {Object} item - 要删除的文件项 347 * @param {Object} item - 要删除的文件项
287 */ 348 */
288 const delItem = (item) => { 349 const delItem = (item) => {
...@@ -292,9 +353,12 @@ export function useCheckin() { ...@@ -292,9 +353,12 @@ export function useCheckin() {
292 } 353 }
293 } 354 }
294 355
356 + // ==================== 提交逻辑 ====================
357 +
295 /** 358 /**
296 - * 提交打卡 359 + * 递归查找新生成的打卡ID
297 - * @param {Object} extraData - 额外提交数据 360 + * @param {Object} data API返回的数据对象
361 + * @returns {string|null} 找到的ID或null
298 */ 362 */
299 const get_new_checkin_id = (data) => { 363 const get_new_checkin_id = (data) => {
300 if (!data) return null 364 if (!data) return null
...@@ -337,6 +401,17 @@ export function useCheckin() { ...@@ -337,6 +401,17 @@ export function useCheckin() {
337 return null 401 return null
338 } 402 }
339 403
404 + /**
405 + * 提交打卡
406 + * @param {Object} extraData - 额外提交数据
407 + * @description
408 + * 提交流程:
409 + * 1. 表单校验(内容长度、是否上传文件)
410 + * 2. 组装数据(note, file_type, meta_id等)
411 + * 3. 根据 route.query.status 判断是新增还是编辑
412 + * 4. 调用对应 API (addUploadTaskAPI / editUploadTaskInfoAPI)
413 + * 5. 成功后设置 sessionStorage 刷新标记并返回上一页
414 + */
340 const onSubmit = async (extraData = {}) => { 415 const onSubmit = async (extraData = {}) => {
341 if (uploading.value) return 416 if (uploading.value) return
342 417
...@@ -428,6 +503,7 @@ export function useCheckin() { ...@@ -428,6 +503,7 @@ export function useCheckin() {
428 /** 503 /**
429 * 切换打卡类型 504 * 切换打卡类型
430 * @param {string} type - 打卡类型 505 * @param {string} type - 打卡类型
506 + * @description 切换时会保存当前类型的文件列表,并恢复新类型的文件列表
431 */ 507 */
432 const switchType = (type) => { 508 const switchType = (type) => {
433 if (activeType.value !== type) { 509 if (activeType.value !== type) {
...@@ -468,6 +544,11 @@ export function useCheckin() { ...@@ -468,6 +544,11 @@ export function useCheckin() {
468 * 初始化编辑数据 544 * 初始化编辑数据
469 * @param {Array} taskOptions - 任务选项列表 545 * @param {Array} taskOptions - 任务选项列表
470 * @param {Object} handlers - 回调处理函数 546 * @param {Object} handlers - 回调处理函数
547 + * @description
548 + * 编辑模式下:
549 + * 1. 调用 getUploadTaskInfoAPI 获取详情
550 + * 2. 回显文本、类型、文件列表
551 + * 3. 处理计数打卡的数据恢复
471 */ 552 */
472 const initEditData = async (taskOptions = [], handlers = {}) => { 553 const initEditData = async (taskOptions = [], handlers = {}) => {
473 if (route.query.status === 'edit') { 554 if (route.query.status === 'edit') {
......
1 import { ref } from 'vue' 1 import { ref } from 'vue'
2 2
3 +/**
4 + * 首页视频播放控制
5 + * @module useHomeVideoPlayer
6 + * @description 管理首页视频列表的播放状态,确保同一时间只有一个视频在播放。
7 + * @returns {Object} 包含视频播放索引和控制方法
8 + */
3 export const useHomeVideoPlayer = () => { 9 export const useHomeVideoPlayer = () => {
10 + /** @type {import('vue').Ref<number|null>} 当前播放的视频索引 */
4 const activeVideoIndex = ref(null) 11 const activeVideoIndex = ref(null)
5 12
13 + /**
14 + * 播放指定索引的视频
15 + * @param {number} index - 视频索引
16 + */
6 const playVideo = (index) => { 17 const playVideo = (index) => {
7 activeVideoIndex.value = index 18 activeVideoIndex.value = index
8 } 19 }
9 20
21 + /**
22 + * 关闭当前视频播放
23 + */
10 const closeVideo = () => { 24 const closeVideo = () => {
11 activeVideoIndex.value = null 25 activeVideoIndex.value = null
12 } 26 }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 * @LastEditTime: 2025-12-04 13:36:41 4 * @LastEditTime: 2025-12-04 13:36:41
5 * @FilePath: /mlaj/src/composables/useShare.js 5 * @FilePath: /mlaj/src/composables/useShare.js
6 - * @Description: 文件描述 6 + * @Description: 微信分享相关逻辑
7 */ 7 */
8 import wx from 'weixin-js-sdk'; 8 import wx from 'weixin-js-sdk';
9 9
......
...@@ -2,25 +2,50 @@ import { ref, watch } from 'vue'; ...@@ -2,25 +2,50 @@ import { ref, watch } from 'vue';
2 import { getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI } from '@/api/course'; 2 import { getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI } from '@/api/course';
3 3
4 /** 4 /**
5 - * 学习评论跟踪器 5 + * 学习评论功能封装
6 - * @param {*} course 课程对象 6 + * @module useStudyComments
7 - * @returns 学习评论跟踪器对象,包含 commentCount、commentList、newComment、showCommentPopup、popupComment、popupCommentList、popupLoading、popupFinished、popupLimit、popupPage、refreshComments、toggleLike、submitComment 等方法 7 + * @description
8 + * 管理课程/学习资料的评论列表、发表评论、点赞以及弹窗内的评论加载。
9 + *
10 + * @param {import('vue').Ref<Object>} course - 课程对象响应式引用
11 + * @returns {Object} 评论相关状态和方法
8 */ 12 */
9 export const useStudyComments = (course) => { 13 export const useStudyComments = (course) => {
14 + // ==================== 状态定义 ====================
15 +
16 + /** @type {import('vue').Ref<number>} 评论总数 */
10 const commentCount = ref(0); 17 const commentCount = ref(0);
18 + /** @type {import('vue').Ref<Array>} 评论列表(主页面显示) */
11 const commentList = ref([]); 19 const commentList = ref([]);
12 - 20 + /** @type {import('vue').Ref<string>} 新评论内容(主页面输入框) */
13 const newComment = ref(''); 21 const newComment = ref('');
14 22
23 + // 弹窗相关状态
24 + /** @type {import('vue').Ref<boolean>} 是否显示评论弹窗 */
15 const showCommentPopup = ref(false); 25 const showCommentPopup = ref(false);
26 + /** @type {import('vue').Ref<string>} 弹窗内的新评论内容 */
16 const popupComment = ref(''); 27 const popupComment = ref('');
17 - 28 + /** @type {import('vue').Ref<Array>} 弹窗内的评论列表 */
18 const popupCommentList = ref([]); 29 const popupCommentList = ref([]);
30 + /** @type {import('vue').Ref<boolean>} 弹窗加载中状态 */
19 const popupLoading = ref(false); 31 const popupLoading = ref(false);
32 + /** @type {import('vue').Ref<boolean>} 弹窗是否已加载全部 */
20 const popupFinished = ref(false); 33 const popupFinished = ref(false);
34 + /** @type {import('vue').Ref<number>} 弹窗每页加载数量 */
21 const popupLimit = ref(5); 35 const popupLimit = ref(5);
36 + /** @type {import('vue').Ref<number>} 弹窗当前页码 */
22 const popupPage = ref(0); 37 const popupPage = ref(0);
23 38
39 + // ==================== 方法定义 ====================
40 +
41 + /**
42 + * 刷新主页面评论列表
43 + * @description
44 + * 数据流:
45 + * 1. 检查 course.value 是否包含 group_id 和 schedule_id
46 + * 2. 调用 getGroupCommentListAPI 获取最新评论
47 + * 3. 更新 commentList 和 commentCount
48 + */
24 const refreshComments = async () => { 49 const refreshComments = async () => {
25 if (!course.value?.group_id || !course.value?.id) return; 50 if (!course.value?.group_id || !course.value?.id) return;
26 51
...@@ -34,6 +59,14 @@ export const useStudyComments = (course) => { ...@@ -34,6 +59,14 @@ export const useStudyComments = (course) => {
34 } 59 }
35 }; 60 };
36 61
62 + /**
63 + * 切换点赞状态
64 + * @param {Object} comment - 评论对象
65 + * @description
66 + * 乐观更新:先调用 API,成功后更新本地状态
67 + * 1. 如果当前未点赞 -> 调用 addGroupCommentLikeAPI -> 成功则 is_like=true, count+1
68 + * 2. 如果当前已点赞 -> 调用 delGroupCommentLikeAPI -> 成功则 is_like=false, count-1
69 + */
37 const toggleLike = async (comment) => { 70 const toggleLike = async (comment) => {
38 try { 71 try {
39 if (!comment.is_like) { 72 if (!comment.is_like) {
...@@ -54,6 +87,13 @@ export const useStudyComments = (course) => { ...@@ -54,6 +87,13 @@ export const useStudyComments = (course) => {
54 } 87 }
55 }; 88 };
56 89
90 + /**
91 + * 提交主页面评论
92 + * @description
93 + * 1. 校验内容非空
94 + * 2. 调用 addGroupCommentAPI
95 + * 3. 成功后刷新评论列表并清空输入框
96 + */
57 const submitComment = async () => { 97 const submitComment = async () => {
58 if (!newComment.value.trim()) return; 98 if (!newComment.value.trim()) return;
59 if (!course.value?.group_id || !course.value?.id) return; 99 if (!course.value?.group_id || !course.value?.id) return;
...@@ -74,6 +114,15 @@ export const useStudyComments = (course) => { ...@@ -74,6 +114,15 @@ export const useStudyComments = (course) => {
74 } 114 }
75 }; 115 };
76 116
117 + /**
118 + * 弹窗加载更多评论(分页)
119 + * @description
120 + * 用于 Vant List 组件的 @load 事件
121 + * 1. 计算下一页页码
122 + * 2. 调用 API 获取分页数据
123 + * 3. 过滤重复数据并追加到 popupCommentList
124 + * 4. 判断是否已加载全部数据 (finished)
125 + */
77 const onPopupLoad = async () => { 126 const onPopupLoad = async () => {
78 if (!course.value?.group_id || !course.value?.id) { 127 if (!course.value?.group_id || !course.value?.id) {
79 popupLoading.value = false; 128 popupLoading.value = false;
...@@ -90,9 +139,12 @@ export const useStudyComments = (course) => { ...@@ -90,9 +139,12 @@ export const useStudyComments = (course) => {
90 }); 139 });
91 if (res.code === 1) { 140 if (res.code === 1) {
92 const newComments = res.data.comment_list; 141 const newComments = res.data.comment_list;
142 + // 去重处理,防止页码错乱导致的数据重复
93 const existingIds = new Set(popupCommentList.value.map(item => item.id)); 143 const existingIds = new Set(popupCommentList.value.map(item => item.id));
94 const uniqueNewComments = newComments.filter(item => !existingIds.has(item.id)); 144 const uniqueNewComments = newComments.filter(item => !existingIds.has(item.id));
145 +
95 popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments]; 146 popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments];
147 + // 如果返回数量小于限制,说明已无更多数据
96 popupFinished.value = res.data.comment_list.length < popupLimit.value; 148 popupFinished.value = res.data.comment_list.length < popupLimit.value;
97 popupPage.value = nextPage + 1; 149 popupPage.value = nextPage + 1;
98 } 150 }
...@@ -102,6 +154,14 @@ export const useStudyComments = (course) => { ...@@ -102,6 +154,14 @@ export const useStudyComments = (course) => {
102 popupLoading.value = false; 154 popupLoading.value = false;
103 }; 155 };
104 156
157 + /**
158 + * 提交弹窗内的评论
159 + * @description
160 + * 1. 调用 API 提交
161 + * 2. 成功后重置弹窗列表状态(清空列表、页码归零)
162 + * 3. 重新加载第一页数据
163 + * 4. 同步更新外部评论总数
164 + */
105 const submitPopupComment = async () => { 165 const submitPopupComment = async () => {
106 if (!popupComment.value.trim()) return; 166 if (!popupComment.value.trim()) return;
107 if (!course.value?.group_id || !course.value?.id) return; 167 if (!course.value?.group_id || !course.value?.id) return;
...@@ -114,13 +174,14 @@ export const useStudyComments = (course) => { ...@@ -114,13 +174,14 @@ export const useStudyComments = (course) => {
114 }); 174 });
115 175
116 if (code === 1) { 176 if (code === 1) {
177 + // 重置列表状态以重新加载
117 popupCommentList.value = []; 178 popupCommentList.value = [];
118 popupPage.value = 0; 179 popupPage.value = 0;
119 popupFinished.value = false; 180 popupFinished.value = false;
120 await onPopupLoad(); 181 await onPopupLoad();
121 182
122 commentCount.value = data.comment_count; 183 commentCount.value = data.comment_count;
123 - await refreshComments(); 184 + await refreshComments(); // 同步刷新主列表
124 popupComment.value = ''; 185 popupComment.value = '';
125 } 186 }
126 } catch (error) { 187 } catch (error) {
...@@ -128,6 +189,7 @@ export const useStudyComments = (course) => { ...@@ -128,6 +189,7 @@ export const useStudyComments = (course) => {
128 } 189 }
129 }; 190 };
130 191
192 + // 监听弹窗打开,初始化数据
131 watch(showCommentPopup, async (newVal) => { 193 watch(showCommentPopup, async (newVal) => {
132 if (!newVal) return; 194 if (!newVal) return;
133 if (!course.value?.group_id || !course.value?.id) return; 195 if (!course.value?.group_id || !course.value?.id) return;
......
...@@ -3,17 +3,27 @@ import { v4 as uuidv4 } from 'uuid'; ...@@ -3,17 +3,27 @@ import { v4 as uuidv4 } from 'uuid';
3 import { addStudyRecordAPI } from '@/api/record'; 3 import { addStudyRecordAPI } from '@/api/record';
4 4
5 /** 5 /**
6 - * 学习记录跟踪器 6 + * 学习记录埋点跟踪器
7 - * @param {*} course 课程对象 7 + * @module useStudyRecordTracker
8 - * @param {*} courseId 课程ID 8 + * @description
9 - * @param {*} videoPlayerRef 视频播放器引用 9 + * 自动追踪用户的学习行为(视频播放、音频播放),定期上报学习进度。
10 - * @param {*} audioPlayerRef 音频播放器引用 10 + *
11 - * @returns 学习记录跟踪器对象,包含 startAction、addRecord 等方法 11 + * @param {Object} params - 参数对象
12 + * @param {import('vue').Ref<Object>} params.course - 课程对象
13 + * @param {import('vue').Ref<string|number>} params.courseId - 课程ID
14 + * @param {import('vue').Ref<Object>} params.videoPlayerRef - 视频播放器组件引用
15 + * @param {import('vue').Ref<Object>} params.audioPlayerRef - 音频播放器组件引用
16 + * @returns {Object} 包含开始/结束追踪和手动添加记录的方法
12 */ 17 */
13 export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioPlayerRef }) => { 18 export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioPlayerRef }) => {
19 + /** @type {import('vue').Ref<number|null>} 定时器ID */
14 const action_timer = ref(null); 20 const action_timer = ref(null);
21 + /** @type {import('vue').Ref<string>} 当前播放会话ID (UUID) */
15 const playback_id = ref(''); 22 const playback_id = ref('');
16 23
24 + /**
25 + * 清除定时器
26 + */
17 const clear_timer = () => { 27 const clear_timer = () => {
18 if (action_timer.value) { 28 if (action_timer.value) {
19 clearInterval(action_timer.value); 29 clearInterval(action_timer.value);
...@@ -21,16 +31,30 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP ...@@ -21,16 +31,30 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP
21 } 31 }
22 }; 32 };
23 33
34 + /**
35 + * 手动上报学习记录
36 + * @param {Object} paramsObj - 上报参数
37 + * @returns {Promise<Object>} API响应
38 + */
24 const addRecord = async (paramsObj) => { 39 const addRecord = async (paramsObj) => {
25 if (!paramsObj || !paramsObj.schedule_id) return; 40 if (!paramsObj || !paramsObj.schedule_id) return;
26 return await addStudyRecordAPI(paramsObj); 41 return await addStudyRecordAPI(paramsObj);
27 }; 42 };
28 43
44 + /**
45 + * 获取规范化的课程ID
46 + * @returns {string|number}
47 + */
29 const get_schedule_id = () => { 48 const get_schedule_id = () => {
30 if (typeof courseId === 'object' && courseId && 'value' in courseId) return courseId.value; 49 if (typeof courseId === 'object' && courseId && 'value' in courseId) return courseId.value;
31 return courseId || ''; 50 return courseId || '';
32 }; 51 };
33 52
53 + /**
54 + * 获取视频播放状态载荷
55 + * @description 从 videoPlayerRef 获取当前播放时间、总时长和 meta_id
56 + * @returns {Object|null}
57 + */
34 const get_video_payload = () => { 58 const get_video_payload = () => {
35 const player = videoPlayerRef?.value?.getPlayer?.(); 59 const player = videoPlayerRef?.value?.getPlayer?.();
36 if (!player) return null; 60 if (!player) return null;
...@@ -48,6 +72,11 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP ...@@ -48,6 +72,11 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP
48 }; 72 };
49 }; 73 };
50 74
75 + /**
76 + * 获取音频播放状态载荷
77 + * @param {Object} item - 音频项数据
78 + * @returns {Object|null}
79 + */
51 const get_audio_payload = (item) => { 80 const get_audio_payload = (item) => {
52 const player = audioPlayerRef?.value?.getPlayer?.(); 81 const player = audioPlayerRef?.value?.getPlayer?.();
53 if (!player) return null; 82 if (!player) return null;
...@@ -63,6 +92,15 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP ...@@ -63,6 +92,15 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP
63 }; 92 };
64 }; 93 };
65 94
95 + /**
96 + * 开始追踪
97 + * @param {Object} [item] - 当前播放项(用于音频)
98 + * @description
99 + * 1. 生成新的 playback_id
100 + * 2. 启动定时器 (3秒间隔)
101 + * 3. 根据课程类型 (video/audio) 获取对应的播放状态
102 + * 4. 调用 addRecord 上报
103 + */
66 const startAction = (item) => { 104 const startAction = (item) => {
67 clear_timer(); 105 clear_timer();
68 106
...@@ -78,7 +116,9 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP ...@@ -78,7 +116,9 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP
78 let payload = null; 116 let payload = null;
79 if (is_video) payload = get_video_payload(); 117 if (is_video) payload = get_video_payload();
80 if (is_audio) payload = get_audio_payload(item); 118 if (is_audio) payload = get_audio_payload(item);
119 + // 兜底尝试
81 if (!payload) payload = get_video_payload() || get_audio_payload(item); 120 if (!payload) payload = get_video_payload() || get_audio_payload(item);
121 +
82 if (!payload) return; 122 if (!payload) return;
83 123
84 addRecord({ 124 addRecord({
...@@ -88,10 +128,14 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP ...@@ -88,10 +128,14 @@ export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioP
88 }, 3000); 128 }, 3000);
89 }; 129 };
90 130
131 + /**
132 + * 结束追踪
133 + */
91 const endAction = () => { 134 const endAction = () => {
92 clear_timer(); 135 clear_timer();
93 }; 136 };
94 137
138 + // 组件卸载时自动清理
95 onUnmounted(() => { 139 onUnmounted(() => {
96 clear_timer(); 140 clear_timer();
97 }); 141 });
......
1 -/* 1 +/**
2 - * @Date: 2025-12-24 13:50:03 2 + * 埋点系统封装
3 - * @LastEditors: hookehuyr hookehuyr@gmail.com 3 + * @module useTracking
4 - * @LastEditTime: 2025-12-24 14:19:52 4 + * @description 提供统一的事件埋点上报功能,支持页面浏览、点击事件和自定义事件。
5 - * @FilePath: /mlaj/src/composables/useTracking.js
6 - * @Description: 埋点 Composable, 模拟埋点接口请求
7 */ 5 */
6 +
8 /** 7 /**
9 * 模拟埋点接口请求 8 * 模拟埋点接口请求
10 * @param {Object} data 埋点数据 9 * @param {Object} data 埋点数据
...@@ -31,7 +30,7 @@ export function useTracking() { ...@@ -31,7 +30,7 @@ export function useTracking() {
31 * @param {Object} [extraData={}] - 额外参数 (业务相关的数据) 30 * @param {Object} [extraData={}] - 额外参数 (业务相关的数据)
32 */ 31 */
33 const trackEvent = async (eventType, actionName, extraData = {}) => { 32 const trackEvent = async (eventType, actionName, extraData = {}) => {
34 - // 获取当前页面路径 (简单获取,如果在组件setup中使用window.location) 33 + // 获取当前页面路径
35 const pagePath = window.location.pathname + window.location.hash 34 const pagePath = window.location.pathname + window.location.hash
36 35
37 const payload = { 36 const payload = {
......
1 import { ref } from "vue"; 1 import { ref } from "vue";
2 2
3 /** 3 /**
4 - * @description 播放器叠层逻辑:弱网提示、HLS 下载速率(基于 VHS 带宽)与调试信息。 4 + * 视频播放器叠层逻辑管理
5 - * @param {{ 5 + * @module useVideoPlaybackOverlays
6 - * props: any, 6 + * @description
7 - * player: import("vue").Ref<any>, 7 + * 管理视频播放器上的各种叠层信息,包括:
8 - * is_m3u8: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>, 8 + * 1. 弱网提示:当网速过慢时显示提示。
9 - * use_native_player: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>, 9 + * 2. HLS 下载速率:显示当前 HLS 流的下载速度(调试模式)。
10 - * show_error_overlay: import("vue").Ref<boolean>, 10 + * 3. 调试信息:显示播放器内核、带宽等技术参数。
11 - * has_started_playback: import("vue").Ref<boolean> 11 + *
12 - * }} params 12 + * @param {Object} params - 初始化参数
13 - * @returns {{ 13 + * @param {any} params.props - 组件 props
14 - * showNetworkSpeedOverlay: import("vue").Ref<boolean>, 14 + * @param {import("vue").Ref<any>} params.player - Video.js 实例引用
15 - * networkSpeedText: import("vue").Ref<string>, 15 + * @param {import("vue").Ref<boolean>} params.is_m3u8 - 是否为 m3u8 格式
16 - * hlsDownloadSpeedText: import("vue").Ref<string>, 16 + * @param {import("vue").Ref<boolean>} params.use_native_player - 是否使用原生播放器
17 - * hlsSpeedDebugText: import("vue").Ref<string>, 17 + * @param {import("vue").Ref<boolean>} params.show_error_overlay - 是否显示错误叠层
18 - * setHlsDebug: (event_name: string, extra?: string) => void, 18 + * @param {import("vue").Ref<boolean>} params.has_started_playback - 是否已开始播放
19 - * showNetworkSpeed: () => void, 19 + * @returns {Object} 叠层状态和控制方法
20 - * hideNetworkSpeed: () => void,
21 - * startHlsDownloadSpeed: () => void,
22 - * stopHlsDownloadSpeed: (reason?: string) => void,
23 - * disposeOverlays: () => void
24 - * }}
25 */ 20 */
26 export const useVideoPlaybackOverlays = ({ 21 export const useVideoPlaybackOverlays = ({
27 props, 22 props,
...@@ -31,15 +26,28 @@ export const useVideoPlaybackOverlays = ({ ...@@ -31,15 +26,28 @@ export const useVideoPlaybackOverlays = ({
31 show_error_overlay, 26 show_error_overlay,
32 has_started_playback, 27 has_started_playback,
33 }) => { 28 }) => {
29 + // ==================== 状态定义 ====================
30 +
31 + /** @type {import('vue').Ref<boolean>} 是否显示网速叠层 */
34 const show_network_speed_overlay = ref(false); 32 const show_network_speed_overlay = ref(false);
33 + /** @type {import('vue').Ref<string>} 网速文本 */
35 const network_speed_text = ref(""); 34 const network_speed_text = ref("");
36 let network_speed_timer = null; 35 let network_speed_timer = null;
37 let last_weixin_network_type_at = 0; 36 let last_weixin_network_type_at = 0;
38 37
38 + /** @type {import('vue').Ref<string>} HLS下载速度文本 */
39 const hls_download_speed_text = ref(""); 39 const hls_download_speed_text = ref("");
40 let hls_speed_timer = null; 40 let hls_speed_timer = null;
41 + /** @type {import('vue').Ref<string>} HLS调试信息文本 */
41 const hls_speed_debug_text = ref(""); 42 const hls_speed_debug_text = ref("");
42 43
44 + // ==================== 方法定义 ====================
45 +
46 + /**
47 + * 设置 HLS 调试信息
48 + * @param {string} event_name - 事件名称
49 + * @param {string} [extra] - 额外信息
50 + */
43 const set_hls_debug = (event_name, extra) => { 51 const set_hls_debug = (event_name, extra) => {
44 if (!props || props.debug !== true) return; 52 if (!props || props.debug !== true) return;
45 53
...@@ -66,6 +74,10 @@ export const useVideoPlaybackOverlays = ({ ...@@ -66,6 +74,10 @@ export const useVideoPlaybackOverlays = ({
66 hls_speed_debug_text.value = `${event_name}${extra_text}\nmode:${mode} m3u8:${is_m3u8.value ? "1" : "0"} native:${use_native_player.value ? "1" : "0"}\ntech:${tech_name} vhs:${vhs ? "1" : "0"} bw:${bw_kbps}kbps hls_bw:${hls_bw_kbps}kbps`; 74 hls_speed_debug_text.value = `${event_name}${extra_text}\nmode:${mode} m3u8:${is_m3u8.value ? "1" : "0"} native:${use_native_player.value ? "1" : "0"}\ntech:${tech_name} vhs:${vhs ? "1" : "0"} bw:${bw_kbps}kbps hls_bw:${hls_bw_kbps}kbps`;
67 }; 75 };
68 76
77 + /**
78 + * 更新网速信息
79 + * @returns {boolean} 是否获取成功
80 + */
69 const update_network_speed = () => { 81 const update_network_speed = () => {
70 if (typeof navigator === "undefined") { 82 if (typeof navigator === "undefined") {
71 network_speed_text.value = "未知"; 83 network_speed_text.value = "未知";
...@@ -85,6 +97,9 @@ export const useVideoPlaybackOverlays = ({ ...@@ -85,6 +97,9 @@ export const useVideoPlaybackOverlays = ({
85 return effective_type ? true : false; 97 return effective_type ? true : false;
86 }; 98 };
87 99
100 + /**
101 + * 更新微信环境下的网络类型
102 + */
88 const update_weixin_network_type = () => { 103 const update_weixin_network_type = () => {
89 if (typeof window === "undefined") return; 104 if (typeof window === "undefined") return;
90 if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== "function") return; 105 if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== "function") return;
...@@ -100,6 +115,10 @@ export const useVideoPlaybackOverlays = ({ ...@@ -100,6 +115,10 @@ export const useVideoPlaybackOverlays = ({
100 }); 115 });
101 }; 116 };
102 117
118 + /**
119 + * 显示网速叠层
120 + * @description 启动定时器定期获取网速
121 + */
103 const show_network_speed = () => { 122 const show_network_speed = () => {
104 // 没有进入过播放阶段时不显示,避免一加载就出现“弱网提示”造成误导 123 // 没有进入过播放阶段时不显示,避免一加载就出现“弱网提示”造成误导
105 if (!has_started_playback.value) return; 124 if (!has_started_playback.value) return;
...@@ -117,6 +136,9 @@ export const useVideoPlaybackOverlays = ({ ...@@ -117,6 +136,9 @@ export const useVideoPlaybackOverlays = ({
117 }, 800); 136 }, 800);
118 }; 137 };
119 138
139 + /**
140 + * 隐藏网速叠层
141 + */
120 const hide_network_speed = () => { 142 const hide_network_speed = () => {
121 show_network_speed_overlay.value = false; 143 show_network_speed_overlay.value = false;
122 if (network_speed_timer) { 144 if (network_speed_timer) {
...@@ -125,6 +147,11 @@ export const useVideoPlaybackOverlays = ({ ...@@ -125,6 +147,11 @@ export const useVideoPlaybackOverlays = ({
125 } 147 }
126 }; 148 };
127 149
150 + /**
151 + * 格式化速度显示
152 + * @param {number} bytes_per_second
153 + * @returns {string}
154 + */
128 const format_speed = (bytes_per_second) => { 155 const format_speed = (bytes_per_second) => {
129 const size = Number(bytes_per_second) || 0; 156 const size = Number(bytes_per_second) || 0;
130 if (!size) return ""; 157 if (!size) return "";
...@@ -136,6 +163,9 @@ export const useVideoPlaybackOverlays = ({ ...@@ -136,6 +163,9 @@ export const useVideoPlaybackOverlays = ({
136 return `${Math.round(size)}B/s`; 163 return `${Math.round(size)}B/s`;
137 }; 164 };
138 165
166 + /**
167 + * 更新 HLS 下载速度
168 + */
139 const update_hls_download_speed = () => { 169 const update_hls_download_speed = () => {
140 if (!player.value || player.value.isDisposed()) { 170 if (!player.value || player.value.isDisposed()) {
141 hls_download_speed_text.value = ""; 171 hls_download_speed_text.value = "";
...@@ -171,6 +201,9 @@ export const useVideoPlaybackOverlays = ({ ...@@ -171,6 +201,9 @@ export const useVideoPlaybackOverlays = ({
171 set_hls_debug("update", `speed:${hls_download_speed_text.value}`); 201 set_hls_debug("update", `speed:${hls_download_speed_text.value}`);
172 }; 202 };
173 203
204 + /**
205 + * 开始监控 HLS 下载速度
206 + */
174 const start_hls_download_speed = () => { 207 const start_hls_download_speed = () => {
175 if (hls_speed_timer) return; 208 if (hls_speed_timer) return;
176 if (!is_m3u8.value || use_native_player.value) return; 209 if (!is_m3u8.value || use_native_player.value) return;
...@@ -182,6 +215,10 @@ export const useVideoPlaybackOverlays = ({ ...@@ -182,6 +215,10 @@ export const useVideoPlaybackOverlays = ({
182 }, 1000); 215 }, 1000);
183 }; 216 };
184 217
218 + /**
219 + * 停止监控 HLS 下载速度
220 + * @param {string} [reason] - 停止原因
221 + */
185 const stop_hls_download_speed = (reason) => { 222 const stop_hls_download_speed = (reason) => {
186 if (hls_speed_timer) { 223 if (hls_speed_timer) {
187 clearInterval(hls_speed_timer); 224 clearInterval(hls_speed_timer);
...@@ -191,6 +228,9 @@ export const useVideoPlaybackOverlays = ({ ...@@ -191,6 +228,9 @@ export const useVideoPlaybackOverlays = ({
191 set_hls_debug("stop", reason || ""); 228 set_hls_debug("stop", reason || "");
192 }; 229 };
193 230
231 + /**
232 + * 销毁所有叠层
233 + */
194 const dispose_overlays = () => { 234 const dispose_overlays = () => {
195 hide_network_speed(); 235 hide_network_speed();
196 stop_hls_download_speed("dispose"); 236 stop_hls_download_speed("dispose");
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 * @LastEditTime: 2026-01-20 17:06:44 4 * @LastEditTime: 2026-01-20 17:06:44
5 * @FilePath: /mlaj/src/composables/videoPlayerSource.js 5 * @FilePath: /mlaj/src/composables/videoPlayerSource.js
6 - * @Description: 文件描述 6 + * @Description: 视频播放源处理逻辑
7 */ 7 */
8 /** 8 /**
9 * @description 从 url(或文件名)中提取扩展名(小写,不含点)。支持 base_url 作为 URL 解析基准。 9 * @description 从 url(或文件名)中提取扩展名(小写,不含点)。支持 base_url 作为 URL 解析基准。
......
...@@ -457,22 +457,23 @@ const handleTargetEdit = (item) => { ...@@ -457,22 +457,23 @@ const handleTargetEdit = (item) => {
457 * 处理对象删除 457 * 处理对象删除
458 */ 458 */
459 const handleTargetDelete = async (item) => { 459 const handleTargetDelete = async (item) => {
460 - const { code } = await gratitudeDeleteAPI({ id: item.id }) 460 + // 屏蔽删除功能, 那个接口也是不存在的
461 - if (code === 1) { 461 + // const { code } = await gratitudeDeleteAPI({ id: item.id })
462 - // 删除成功,更新本地列表 462 + // if (code === 1) {
463 - const targetIndex = targetList.value.findIndex(t => t.id === item.id) 463 + // // 删除成功,更新本地列表
464 - if (targetIndex > -1) { 464 + // const targetIndex = targetList.value.findIndex(t => t.id === item.id)
465 - targetList.value.splice(targetIndex, 1) 465 + // if (targetIndex > -1) {
466 - } 466 + // targetList.value.splice(targetIndex, 1)
467 + // }
467 468
468 - // 从选中列表中也删除 469 + // // 从选中列表中也删除
469 - const selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id) 470 + // const selectedIndex = selectedTargets.value.findIndex(t => t.id === item.id)
470 - if (selectedIndex > -1) { 471 + // if (selectedIndex > -1) {
471 - selectedTargets.value.splice(selectedIndex, 1) 472 + // selectedTargets.value.splice(selectedIndex, 1)
472 - } 473 + // }
473 474
474 - showToast('删除成功') 475 + // showToast('删除成功')
475 - } 476 + // }
476 } 477 }
477 478
478 /** 479 /**
......