hookehuyr

feat(打卡): 重构打卡页面并提取公共逻辑到composable

- 将打卡按钮改为底部悬浮样式
- 统一编辑跳转逻辑到CheckinDetailPage
- 提取打卡相关逻辑到useCheckin composable
- 优化打卡详情页的UI和交互
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"
import BMF from 'browser-md5-file'
import { useAuth } from '@/contexts/auth'
/**
* 打卡功能的composable
* @returns {Object} 打卡相关的状态和方法
*/
export function useCheckin() {
const route = useRoute()
const router = useRouter()
const { currentUser } = useAuth()
// 基础状态
const uploading = ref(false)
const loading = ref(false)
const message = ref('')
const fileList = ref([])
const activeType = ref('text') // 当前选中的打卡类型
const maxCount = ref(5)
/**
* 是否可以提交
*/
const canSubmit = computed(() => {
if (activeType.value === 'text') {
return message.value.trim() !== '' && message.value.trim().length >= 10
} else {
return fileList.value.length > 0 && message.value.trim() !== ''
}
})
/**
* 获取文件MD5值
* @param {File} file - 文件对象
* @returns {Promise<string>} MD5值
*/
const getFileMD5 = (file) => {
return new Promise((resolve, reject) => {
const bmf = new BMF()
bmf.md5(file, (err, md5) => {
if (err) {
reject(err)
return
}
resolve(md5)
})
})
}
/**
* 上传文件到七牛云
* @param {File} file - 文件对象
* @param {string} token - 七牛云token
* @param {string} fileName - 文件名
* @returns {Promise<Object>} 上传结果
*/
const uploadToQiniu = async (file, token, fileName) => {
const formData = new FormData()
formData.append('file', file)
formData.append('token', token)
formData.append('key', fileName)
const config = {
headers: { 'Content-Type': 'multipart/form-data' }
}
// 根据协议选择上传地址
const qiniuUploadUrl = window.location.protocol === 'https:'
? 'https://up.qbox.me'
: 'http://upload.qiniu.com'
return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
}
/**
* 处理单个文件上传
* @param {Object} file - 文件对象
* @returns {Promise<Object|null>} 上传结果
*/
const handleUpload = async (file) => {
loading.value = true
try {
// 获取MD5值
const md5 = await getFileMD5(file.file)
// 获取七牛token
const tokenResult = await qiniuTokenAPI({
name: file.file.name,
hash: md5
})
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
}
// 新文件上传
if (tokenResult.token) {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
let fileName = ''
if (activeType.value === 'image') {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
} else {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
}
const uploadResult = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (uploadResult.filekey) {
// 保存文件信息
const saveData = {
name: file.file.name,
filekey: uploadResult.filekey,
hash: md5
}
// 图片类型需要保存尺寸信息
if (activeType.value === 'image' && uploadResult.image_info) {
saveData.height = uploadResult.image_info.height
saveData.width = uploadResult.image_info.width
}
const { data } = await saveFileAPI(saveData)
return data
}
}
return null
} catch (error) {
console.error('Upload error:', error)
return null
} finally {
loading.value = false
}
}
/**
* 文件上传前的校验
* @param {File|File[]} file - 文件或文件数组
* @returns {boolean} 是否通过校验
*/
const beforeRead = (file) => {
let flag = true
const files = Array.isArray(file) ? file : [file]
// 检查文件数量
if (fileList.value.length + files.length > maxCount.value) {
flag = false
showToast(`最大上传数量为${maxCount.value}个`)
return flag
}
// 检查文件类型和大小
for (const item of files) {
const fileType = item.type.toLowerCase()
// 文件大小检查
if ((item.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
showToast('最大文件体积为20MB')
break
}
// 文件类型检查
if (activeType.value === 'image') {
const imageTypes = ['jpg', 'jpeg', 'png']
const validImageTypes = imageTypes.map(type => `image/${type}`)
if (!validImageTypes.some(type => fileType.includes(type.split('/')[1]))) {
flag = false
showToast('请上传指定格式图片')
break
}
} else if (activeType.value === 'video') {
if (!fileType.startsWith('video/')) {
flag = false
showToast('请上传视频文件')
break
}
} else if (activeType.value === 'audio') {
if (!fileType.startsWith('audio/')) {
flag = false
showToast('请上传音频文件')
break
}
}
}
return flag
}
/**
* 文件读取后的处理
* @param {File|File[]} file - 文件或文件数组
*/
const afterRead = async (file) => {
const files = Array.isArray(file) ? file : [file]
for (const item of files) {
item.status = 'uploading'
item.message = '上传中...'
const result = await handleUpload(item)
if (result) {
item.status = 'done'
item.message = '上传成功'
item.url = result.url
item.meta_id = result.meta_id
item.name = result.name || item.file.name
} else {
item.status = 'failed'
item.message = '上传失败'
showToast('上传失败,请重试')
}
}
}
/**
* 删除文件
* @param {Object} file - 要删除的文件对象
*/
const onDelete = (file) => {
const index = fileList.value.findIndex(item => item === file)
if (index > -1) {
fileList.value.splice(index, 1)
}
}
/**
* 删除文件项
* @param {Object} item - 要删除的文件项
*/
const delItem = (item) => {
const index = fileList.value.findIndex(file => file === item)
if (index > -1) {
fileList.value.splice(index, 1)
}
}
/**
* 提交打卡
*/
const onSubmit = async () => {
if (uploading.value) return
// 表单验证
if (activeType.value === 'text') {
if (message.value.trim().length < 10) {
showToast('打卡内容至少需要10个字符')
return
}
} else {
if (fileList.value.length === 0) {
showToast('请先上传文件')
return
}
if (message.value.trim() === '') {
showToast('请输入打卡留言')
return
}
}
uploading.value = true
showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
// 准备提交数据
const submitData = {
task_id: route.query.id,
note: message.value,
file_type: activeType.value,
meta_id: []
}
// 如果有文件,添加文件ID
if (fileList.value.length > 0) {
submitData.meta_id = fileList.value
.filter(item => item.status === 'done' && item.meta_id)
.map(item => item.meta_id)
}
let result
if (route.query.status === 'edit') {
// 编辑打卡
result = await editUploadTaskInfoAPI({
i: route.query.post_id,
note: submitData.note,
meta_id: submitData.meta_id,
file_type: submitData.file_type,
})
} else {
// 新增打卡
result = await addUploadTaskAPI(submitData)
}
if (result.code) {
showToast('提交成功')
router.back()
}
} catch (error) {
showToast('提交失败,请重试')
} finally {
uploading.value = false
}
}
/**
* 切换打卡类型
* @param {string} type - 打卡类型
*/
const switchType = (type) => {
if (activeType.value !== type) {
activeType.value = type
// 切换类型时清空文件列表
fileList.value = []
}
}
/**
* 重置表单
*/
const resetForm = () => {
message.value = ''
fileList.value = []
activeType.value = 'text'
uploading.value = false
loading.value = false
}
/**
* 初始化编辑数据
*/
const initEditData = async () => {
if (route.query.status === 'edit') {
try {
const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id })
if (code) {
message.value = data.note || ''
activeType.value = data.file_type || 'text'
// 如果有文件数据,初始化文件列表 - 使用data.files而不是data.meta
if (data.files && data.files.length > 0) {
fileList.value = data.files.map(item => {
const fileItem = {
url: item.value,
status: 'done',
message: '已上传',
meta_id: item.meta_id,
name: item.name || ''
}
// 对于图片类型,添加isImage标记确保正确显示
if (activeType.value === 'image') {
fileItem.isImage = true
}
// 为了支持文件名显示,创建一个File对象
if (item.name) {
fileItem.file = new File([], item.name, { type: item.type || '' })
}
return fileItem
})
}
}
} catch (error) {
console.error('初始化编辑数据失败:', error)
}
}
}
return {
// 状态
uploading,
loading,
message,
fileList,
activeType,
maxCount,
canSubmit,
// 方法
beforeRead,
afterRead,
onDelete,
delItem,
onSubmit,
switchType,
resetForm,
initEditData
}
}
\ No newline at end of file
......@@ -15,56 +15,144 @@
</div>
</div>
<!-- 打卡类型选择 -->
<div v-if="!taskDetail.is_finish" class="section-wrapper">
<div class="section-title">选择打卡类型</div>
<!-- 打卡内容区域 -->
<div class="section-wrapper">
<div class="section-title">打卡内容</div>
<div class="section-content">
<div class="checkin-types">
<!-- 文本输入区域 -->
<div class="text-input-area">
<van-field
v-model="message"
rows="6"
autosize
type="textarea"
:placeholder="activeType === 'text' ? '请输入打卡内容,至少需要10个字符' : '请输入打卡留言'"
:maxlength="activeType === 'text' ? 500 : 200"
show-word-limit
/>
</div>
<!-- 打卡类型选项卡 -->
<div class="checkin-tabs">
<div class="tabs-header">
<div class="tab-title">选择打卡类型</div>
<div class="tabs-nav">
<div
v-for="option in attachmentTypeOptions"
:key="option.key"
@click="handleCheckinTypeClick(option.key)"
class="checkin-type-item"
@click="switchType(option.key)"
:class="['tab-item', { active: activeType === option.key }]"
>
<van-icon
:name="getIconName(option.key)"
size="2rem"
color="#4caf50"
size="1.2rem"
/>
<span class="tab-text">{{ option.value }}</span>
</div>
</div>
</div>
<!-- 文件上传区域 -->
<div v-if="activeType !== 'text'" class="upload-area">
<van-uploader
v-model="fileList"
:max-count="maxCount"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
multiple
:accept="getAcceptType()"
result-type="file"
:deletable="false"
/>
<span class="type-text">{{ option.value }}</span>
<!-- 文件列表显示 -->
<div v-if="fileList.length > 0" class="file-list">
<div v-for="(item, index) in fileList" :key="index" class="file-item">
<div class="file-info">
<van-icon :name="getFileIcon()" size="1rem" />
<span class="file-name">{{ item.name || item.file?.name }}</span>
<span class="file-status" :class="item.status">{{ item.message }}</span>
</div>
<van-icon
name="clear"
size="1rem"
@click="delItem(item)"
class="delete-icon"
/>
</div>
</div>
<div class="upload-tips">
<div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过20M</div>
<div class="tip-text">{{ getUploadTips() }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 已完成提示 -->
<div v-else class="section-wrapper">
<div class="finished-notice">
<van-icon name="success" size="3rem" color="#4caf50" />
<div class="finished-text">作业已完成</div>
<!-- 提交按钮 -->
<div v-if="!taskDetail.is_finish || route.query.status === 'edit'" class="submit-area">
<van-button
type="primary"
block
size="large"
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
{{ route.query.status === 'edit' ? '保存修改' : '提交打卡' }}
</van-button>
</div>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading">
<div class="loading-wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getTaskDetailAPI } from "@/api/checkin";
import { getTaskDetailAPI } from "@/api/checkin"
import { getTeacherFindSettingsAPI } from '@/api/teacher'
import { useTitle } from '@vueuse/core';
import { useTitle } from '@vueuse/core'
import { useCheckin } from '@/composables/useCheckin'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
useTitle('打卡详情');
useTitle('打卡详情')
// 使用打卡composable
const {
uploading,
loading,
message,
fileList,
activeType,
maxCount,
canSubmit,
beforeRead,
afterRead,
onDelete,
delItem,
onSubmit,
switchType,
initEditData
} = useCheckin()
// 任务详情数据
const taskDetail = ref({});
const taskDetail = ref({})
// 作品类型选项
const attachmentTypeOptions = ref([]);
const attachmentTypeOptions = ref([])
/**
* 返回上一页
......@@ -84,92 +172,57 @@ const getIconName = (type) => {
'image': 'photo',
'video': 'video',
'audio': 'music'
};
return iconMap[type] || 'edit';
};
/**
* 处理打卡类型点击事件
* @param {string} type - 打卡类型
*/
const handleCheckinTypeClick = (type) => {
switch (type) {
case 'text':
goToCheckinTextPage();
break;
case 'image':
goToCheckinImagePage();
break;
case 'video':
goToCheckinVideoPage();
break;
case 'audio':
goToCheckinAudioPage();
break;
default:
console.warn('未知的打卡类型:', type);
}
};
/**
* 跳转到文本打卡页面
*/
const goToCheckinTextPage = () => {
router.push({
path: '/checkin/text',
query: {
id: route.query.id,
type: 'text'
}
})
return iconMap[type] || 'edit'
}
/**
* 跳转到图片打卡页面
* 获取文件图标
* @returns {string} 文件图标名称
*/
const goToCheckinImagePage = () => {
router.push({
path: '/checkin/image',
query: {
id: route.query.id,
type: 'image'
const getFileIcon = () => {
const iconMap = {
'image': 'photo',
'video': 'video',
'audio': 'music'
}
})
return iconMap[activeType.value] || 'description'
}
/**
* 跳转到视频打卡页面
* 获取上传文件类型
* @returns {string} accept属性值
*/
const goToCheckinVideoPage = () => {
router.push({
path: '/checkin/video',
query: {
id: route.query.id,
type: 'video',
const getAcceptType = () => {
const acceptMap = {
'image': 'image/*',
'video': 'video/*',
'audio': '.mp3,.wav,.aac'
}
})
return acceptMap[activeType.value] || '*'
}
/**
* 跳转到音频打卡页面
* 获取上传提示文本
* @returns {string} 提示文本
*/
const goToCheckinAudioPage = () => {
router.push({
path: '/checkin/audio',
query: {
id: route.query.id,
type: 'audio',
const getUploadTips = () => {
const tipsMap = {
'image': '支持格式:jpg/jpeg/png',
'video': '支持格式:视频文件',
'audio': '支持格式:.mp3/.wav/.aac 音频文件'
}
})
return tipsMap[activeType.value] || ''
}
/**
* 获取任务详情
* @param {string} month - 月份
*/
const getTaskDetail = async (month) => {
const { code, data } = await getTaskDetailAPI({ i: route.query.id, month });
const { code, data } = await getTaskDetailAPI({ i: route.query.id, month })
if (code) {
taskDetail.value = data;
taskDetail.value = data
}
}
......@@ -178,20 +231,23 @@ const getTaskDetail = async (month) => {
*/
onMounted(async () => {
// 获取任务详情
getTaskDetail(dayjs().format('YYYY-MM'));
getTaskDetail(dayjs().format('YYYY-MM'))
// 获取作品类型数据
try {
const { code, data } = await getTeacherFindSettingsAPI();
const { code, data } = await getTeacherFindSettingsAPI()
if (code && data.task_attachment_type) {
attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({
key,
value
}));
}))
}
} catch (error) {
console.error('获取作品类型数据失败:', error);
console.error('获取作品类型数据失败:', error)
}
// 初始化编辑数据
await initEditData()
})
</script>
......@@ -199,6 +255,7 @@ onMounted(async () => {
.checkin-detail-page {
min-height: 100vh;
background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
padding-bottom: 100px;
}
.page-content {
......@@ -238,31 +295,42 @@ onMounted(async () => {
padding: 2rem 0;
}
.class-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.text-input-area {
margin-bottom: 1.5rem;
.status-text {
font-size: 0.85rem;
color: #666;
.van-field {
border-radius: 8px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
}
}
.checkin-types {
.checkin-tabs {
.tabs-header {
margin-bottom: 1rem;
}
.tab-title {
font-size: 1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.8rem;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.checkin-type-item {
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
padding: 0.8rem 0.5rem;
border: 2px solid #e8f5e8;
border-radius: 12px;
border-radius: 8px;
background-color: #fafffe;
cursor: pointer;
transition: all 0.3s ease;
......@@ -270,20 +338,105 @@ onMounted(async () => {
&:hover {
border-color: #4caf50;
background-color: #f0fdf4;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.15);
}
&:active {
transform: translateY(0);
&.active {
border-color: #4caf50;
background-color: #f0fdf4;
.van-icon {
color: #4caf50;
}
.tab-text {
color: #4caf50;
font-weight: 600;
}
}
}
.tab-text {
margin-top: 0.3rem;
font-size: 0.8rem;
color: #666;
text-align: center;
}
}
.type-text {
margin-top: 0.5rem;
.upload-area {
margin-top: 1rem;
.van-uploader {
margin-bottom: 1rem;
}
}
.file-list {
margin: 1rem 0;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 0.5rem;
.file-info {
display: flex;
align-items: center;
flex: 1;
gap: 0.5rem;
}
.file-name {
flex: 1;
font-size: 0.9rem;
color: #4caf50;
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
&.uploading {
color: #1890ff;
background-color: #e6f7ff;
}
&.done {
color: #52c41a;
background-color: #f6ffed;
}
&.failed {
color: #ff4d4f;
background-color: #fff2f0;
}
}
.delete-icon {
color: #999;
cursor: pointer;
&:hover {
color: #ff4d4f;
}
}
}
.upload-tips {
.tip-text {
font-size: 0.8rem;
color: #999;
margin-bottom: 0.3rem;
}
}
.finished-notice {
......@@ -301,4 +454,22 @@ onMounted(async () => {
color: #4caf50;
font-weight: 600;
}
.submit-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background-color: #fff;
border-top: 1px solid #f0f0f0;
z-index: 100;
}
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-30 17:02:29
* @LastEditTime: 2025-09-30 17:58:09
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
......@@ -57,19 +57,6 @@
</div>
</div>
<!-- 我要打卡按钮 -->
<div v-if="!taskDetail.is_finish" class="text-wrapper" style="padding-bottom: 0;">
<van-button
type="primary"
block
round
@click="goToCheckinDetailPage"
class="checkin-action-button"
>
<van-icon name="edit" size="1.2rem" />
<span style="margin-left: 0.5rem;">我要打卡</span>
</van-button>
</div>
<div class="text-wrapper">
<div class="text-header">打卡动态</div>
......@@ -162,13 +149,26 @@
<van-back-top right="5vw" bottom="10vh" />
</div>
<div style="height: 5rem;"></div>
<div style="height: 10rem;"></div>
</div> <!-- 闭合 scrollable-content -->
</van-config-provider>
<van-dialog v-model:show="dialog_show" title="标题" show-cancel-button></van-dialog>
<van-back-top right="5vw" bottom="15vh" />
<!-- 底部悬浮打卡按钮 -->
<div v-if="!taskDetail.is_finish" class="floating-checkin-button">
<van-button
type="primary"
round
@click="goToCheckinDetailPage"
class="checkin-action-button"
>
<van-icon name="edit" size="1.2rem" />
<span style="margin-left: 0.5rem;">我要打卡</span>
</van-button>
</div>
</AppLayout>
</template>
......@@ -606,34 +606,15 @@ const handLike = async (post) => {
}
const editCheckin = (post) => {
if (post.file_type === 'image') {
// 统一跳转到CheckinDetailPage页面处理所有类型的编辑
router.push({
path: '/checkin/image',
query: {
post_id: post.id,
type: 'image',
status: 'edit',
}
})
} else if (post.file_type === 'video') {
router.push({
path: '/checkin/video',
query: {
post_id: post.id,
type: 'video',
status: 'edit',
}
})
} else if (post.file_type === 'audio') {
router.push({
path: '/checkin/audio',
path: '/checkin/detail',
query: {
post_id: post.id,
type: 'audio',
type: post.file_type,
status: 'edit',
}
})
}
}
const delCheckin = (post) => {
......@@ -651,6 +632,13 @@ const delCheckin = (post) => {
// router.go(0);
// 删除post_id相应的数据
checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id);
// 检查是否还可以打卡
const current_date = route.query.date;
if (current_date) {
getTaskDetail(dayjs(current_date).format('YYYY-MM'));
} else {
getTaskDetail(dayjs().format('YYYY-MM'));
}
} else {
showErrorToast('删除失败');
}
......@@ -957,6 +945,25 @@ const formatData = (data) => {
</style>
<style scoped>
/* 底部悬浮打卡按钮样式 */
.floating-checkin-button {
position: fixed;
bottom: 6rem;
left: 1rem;
right: 1rem;
z-index: 1000;
padding: 0 1rem;
}
.floating-checkin-button .checkin-action-button {
width: 100%;
height: 3rem;
font-size: 1rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: none;
}
:deep(.van-calendar__footer) {
display: none;
}
......