hookehuyr

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

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

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

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