hookehuyr

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

- 将打卡按钮改为底部悬浮样式
- 统一编辑跳转逻辑到CheckinDetailPage
- 提取打卡相关逻辑到useCheckin composable
- 优化打卡详情页的UI和交互
1 +import { ref, computed } from 'vue'
2 +import { useRoute, useRouter } from 'vue-router'
3 +import { showToast, showLoadingToast } from 'vant'
4 +import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
5 +import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"
6 +import BMF from 'browser-md5-file'
7 +import { useAuth } from '@/contexts/auth'
8 +
9 +/**
10 + * 打卡功能的composable
11 + * @returns {Object} 打卡相关的状态和方法
12 + */
13 +export function useCheckin() {
14 + const route = useRoute()
15 + const router = useRouter()
16 + const { currentUser } = useAuth()
17 +
18 + // 基础状态
19 + const uploading = ref(false)
20 + const loading = ref(false)
21 + const message = ref('')
22 + const fileList = ref([])
23 + const activeType = ref('text') // 当前选中的打卡类型
24 + const maxCount = ref(5)
25 +
26 + /**
27 + * 是否可以提交
28 + */
29 + const canSubmit = computed(() => {
30 + if (activeType.value === 'text') {
31 + return message.value.trim() !== '' && message.value.trim().length >= 10
32 + } else {
33 + return fileList.value.length > 0 && message.value.trim() !== ''
34 + }
35 + })
36 +
37 + /**
38 + * 获取文件MD5值
39 + * @param {File} file - 文件对象
40 + * @returns {Promise<string>} MD5值
41 + */
42 + const getFileMD5 = (file) => {
43 + return new Promise((resolve, reject) => {
44 + const bmf = new BMF()
45 + bmf.md5(file, (err, md5) => {
46 + if (err) {
47 + reject(err)
48 + return
49 + }
50 + resolve(md5)
51 + })
52 + })
53 + }
54 +
55 + /**
56 + * 上传文件到七牛云
57 + * @param {File} file - 文件对象
58 + * @param {string} token - 七牛云token
59 + * @param {string} fileName - 文件名
60 + * @returns {Promise<Object>} 上传结果
61 + */
62 + const uploadToQiniu = async (file, token, fileName) => {
63 + const formData = new FormData()
64 + formData.append('file', file)
65 + formData.append('token', token)
66 + formData.append('key', fileName)
67 +
68 + const config = {
69 + headers: { 'Content-Type': 'multipart/form-data' }
70 + }
71 +
72 + // 根据协议选择上传地址
73 + const qiniuUploadUrl = window.location.protocol === 'https:'
74 + ? 'https://up.qbox.me'
75 + : 'http://upload.qiniu.com'
76 +
77 + return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
78 + }
79 +
80 + /**
81 + * 处理单个文件上传
82 + * @param {Object} file - 文件对象
83 + * @returns {Promise<Object|null>} 上传结果
84 + */
85 + const handleUpload = async (file) => {
86 + loading.value = true
87 + try {
88 + // 获取MD5值
89 + const md5 = await getFileMD5(file.file)
90 +
91 + // 获取七牛token
92 + const tokenResult = await qiniuTokenAPI({
93 + name: file.file.name,
94 + hash: md5
95 + })
96 +
97 + // 文件已存在,直接返回
98 + if (tokenResult.data) {
99 + return tokenResult.data
100 + }
101 +
102 + // 新文件上传
103 + if (tokenResult.token) {
104 + const suffix = /.[^.]+$/.exec(file.file.name) || ''
105 + let fileName = ''
106 +
107 + if (activeType.value === 'image') {
108 + fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
109 + } else {
110 + fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
111 + }
112 +
113 + const uploadResult = await uploadToQiniu(
114 + file.file,
115 + tokenResult.token,
116 + fileName
117 + )
118 +
119 + if (uploadResult.filekey) {
120 + // 保存文件信息
121 + const saveData = {
122 + name: file.file.name,
123 + filekey: uploadResult.filekey,
124 + hash: md5
125 + }
126 +
127 + // 图片类型需要保存尺寸信息
128 + if (activeType.value === 'image' && uploadResult.image_info) {
129 + saveData.height = uploadResult.image_info.height
130 + saveData.width = uploadResult.image_info.width
131 + }
132 +
133 + const { data } = await saveFileAPI(saveData)
134 + return data
135 + }
136 + }
137 + return null
138 + } catch (error) {
139 + console.error('Upload error:', error)
140 + return null
141 + } finally {
142 + loading.value = false
143 + }
144 + }
145 +
146 + /**
147 + * 文件上传前的校验
148 + * @param {File|File[]} file - 文件或文件数组
149 + * @returns {boolean} 是否通过校验
150 + */
151 + const beforeRead = (file) => {
152 + let flag = true
153 + const files = Array.isArray(file) ? file : [file]
154 +
155 + // 检查文件数量
156 + if (fileList.value.length + files.length > maxCount.value) {
157 + flag = false
158 + showToast(`最大上传数量为${maxCount.value}个`)
159 + return flag
160 + }
161 +
162 + // 检查文件类型和大小
163 + for (const item of files) {
164 + const fileType = item.type.toLowerCase()
165 +
166 + // 文件大小检查
167 + if ((item.size / 1024 / 1024).toFixed(2) > 20) {
168 + flag = false
169 + showToast('最大文件体积为20MB')
170 + break
171 + }
172 +
173 + // 文件类型检查
174 + if (activeType.value === 'image') {
175 + const imageTypes = ['jpg', 'jpeg', 'png']
176 + const validImageTypes = imageTypes.map(type => `image/${type}`)
177 + if (!validImageTypes.some(type => fileType.includes(type.split('/')[1]))) {
178 + flag = false
179 + showToast('请上传指定格式图片')
180 + break
181 + }
182 + } else if (activeType.value === 'video') {
183 + if (!fileType.startsWith('video/')) {
184 + flag = false
185 + showToast('请上传视频文件')
186 + break
187 + }
188 + } else if (activeType.value === 'audio') {
189 + if (!fileType.startsWith('audio/')) {
190 + flag = false
191 + showToast('请上传音频文件')
192 + break
193 + }
194 + }
195 + }
196 +
197 + return flag
198 + }
199 +
200 + /**
201 + * 文件读取后的处理
202 + * @param {File|File[]} file - 文件或文件数组
203 + */
204 + const afterRead = async (file) => {
205 + const files = Array.isArray(file) ? file : [file]
206 +
207 + for (const item of files) {
208 + item.status = 'uploading'
209 + item.message = '上传中...'
210 +
211 + const result = await handleUpload(item)
212 + if (result) {
213 + item.status = 'done'
214 + item.message = '上传成功'
215 + item.url = result.url
216 + item.meta_id = result.meta_id
217 + item.name = result.name || item.file.name
218 + } else {
219 + item.status = 'failed'
220 + item.message = '上传失败'
221 + showToast('上传失败,请重试')
222 + }
223 + }
224 + }
225 +
226 + /**
227 + * 删除文件
228 + * @param {Object} file - 要删除的文件对象
229 + */
230 + const onDelete = (file) => {
231 + const index = fileList.value.findIndex(item => item === file)
232 + if (index > -1) {
233 + fileList.value.splice(index, 1)
234 + }
235 + }
236 +
237 + /**
238 + * 删除文件项
239 + * @param {Object} item - 要删除的文件项
240 + */
241 + const delItem = (item) => {
242 + const index = fileList.value.findIndex(file => file === item)
243 + if (index > -1) {
244 + fileList.value.splice(index, 1)
245 + }
246 + }
247 +
248 + /**
249 + * 提交打卡
250 + */
251 + const onSubmit = async () => {
252 + if (uploading.value) return
253 +
254 + // 表单验证
255 + if (activeType.value === 'text') {
256 + if (message.value.trim().length < 10) {
257 + showToast('打卡内容至少需要10个字符')
258 + return
259 + }
260 + } else {
261 + if (fileList.value.length === 0) {
262 + showToast('请先上传文件')
263 + return
264 + }
265 + if (message.value.trim() === '') {
266 + showToast('请输入打卡留言')
267 + return
268 + }
269 + }
270 +
271 + uploading.value = true
272 + showLoadingToast({
273 + message: '提交中...',
274 + forbidClick: true,
275 + })
276 +
277 + try {
278 + // 准备提交数据
279 + const submitData = {
280 + task_id: route.query.id,
281 + note: message.value,
282 + file_type: activeType.value,
283 + meta_id: []
284 + }
285 +
286 + // 如果有文件,添加文件ID
287 + if (fileList.value.length > 0) {
288 + submitData.meta_id = fileList.value
289 + .filter(item => item.status === 'done' && item.meta_id)
290 + .map(item => item.meta_id)
291 + }
292 +
293 + let result
294 + if (route.query.status === 'edit') {
295 + // 编辑打卡
296 + result = await editUploadTaskInfoAPI({
297 + i: route.query.post_id,
298 + note: submitData.note,
299 + meta_id: submitData.meta_id,
300 + file_type: submitData.file_type,
301 + })
302 + } else {
303 + // 新增打卡
304 + result = await addUploadTaskAPI(submitData)
305 + }
306 +
307 + if (result.code) {
308 + showToast('提交成功')
309 + router.back()
310 + }
311 + } catch (error) {
312 + showToast('提交失败,请重试')
313 + } finally {
314 + uploading.value = false
315 + }
316 + }
317 +
318 + /**
319 + * 切换打卡类型
320 + * @param {string} type - 打卡类型
321 + */
322 + const switchType = (type) => {
323 + if (activeType.value !== type) {
324 + activeType.value = type
325 + // 切换类型时清空文件列表
326 + fileList.value = []
327 + }
328 + }
329 +
330 + /**
331 + * 重置表单
332 + */
333 + const resetForm = () => {
334 + message.value = ''
335 + fileList.value = []
336 + activeType.value = 'text'
337 + uploading.value = false
338 + loading.value = false
339 + }
340 +
341 + /**
342 + * 初始化编辑数据
343 + */
344 + const initEditData = async () => {
345 + if (route.query.status === 'edit') {
346 + try {
347 + const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id })
348 + if (code) {
349 + message.value = data.note || ''
350 + activeType.value = data.file_type || 'text'
351 +
352 + // 如果有文件数据,初始化文件列表 - 使用data.files而不是data.meta
353 + if (data.files && data.files.length > 0) {
354 + fileList.value = data.files.map(item => {
355 + const fileItem = {
356 + url: item.value,
357 + status: 'done',
358 + message: '已上传',
359 + meta_id: item.meta_id,
360 + name: item.name || ''
361 + }
362 +
363 + // 对于图片类型,添加isImage标记确保正确显示
364 + if (activeType.value === 'image') {
365 + fileItem.isImage = true
366 + }
367 +
368 + // 为了支持文件名显示,创建一个File对象
369 + if (item.name) {
370 + fileItem.file = new File([], item.name, { type: item.type || '' })
371 + }
372 +
373 + return fileItem
374 + })
375 + }
376 + }
377 + } catch (error) {
378 + console.error('初始化编辑数据失败:', error)
379 + }
380 + }
381 + }
382 +
383 + return {
384 + // 状态
385 + uploading,
386 + loading,
387 + message,
388 + fileList,
389 + activeType,
390 + maxCount,
391 + canSubmit,
392 +
393 + // 方法
394 + beforeRead,
395 + afterRead,
396 + onDelete,
397 + delItem,
398 + onSubmit,
399 + switchType,
400 + resetForm,
401 + initEditData
402 + }
403 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -15,56 +15,144 @@ ...@@ -15,56 +15,144 @@
15 </div> 15 </div>
16 </div> 16 </div>
17 17
18 - <!-- 打卡类型选择 --> 18 + <!-- 打卡内容区域 -->
19 - <div v-if="!taskDetail.is_finish" class="section-wrapper"> 19 + <div class="section-wrapper">
20 - <div class="section-title">选择打卡类型</div> 20 + <div class="section-title">打卡内容</div>
21 <div class="section-content"> 21 <div class="section-content">
22 - <div class="checkin-types"> 22 + <!-- 文本输入区域 -->
23 - <div 23 + <div class="text-input-area">
24 - v-for="option in attachmentTypeOptions" 24 + <van-field
25 - :key="option.key" 25 + v-model="message"
26 - @click="handleCheckinTypeClick(option.key)" 26 + rows="6"
27 - class="checkin-type-item" 27 + autosize
28 - > 28 + type="textarea"
29 - <van-icon 29 + :placeholder="activeType === 'text' ? '请输入打卡内容,至少需要10个字符' : '请输入打卡留言'"
30 - :name="getIconName(option.key)" 30 + :maxlength="activeType === 'text' ? 500 : 200"
31 - size="2rem" 31 + show-word-limit
32 - color="#4caf50" 32 + />
33 + </div>
34 +
35 + <!-- 打卡类型选项卡 -->
36 + <div class="checkin-tabs">
37 + <div class="tabs-header">
38 + <div class="tab-title">选择打卡类型</div>
39 + <div class="tabs-nav">
40 + <div
41 + v-for="option in attachmentTypeOptions"
42 + :key="option.key"
43 + @click="switchType(option.key)"
44 + :class="['tab-item', { active: activeType === option.key }]"
45 + >
46 + <van-icon
47 + :name="getIconName(option.key)"
48 + size="1.2rem"
49 + />
50 + <span class="tab-text">{{ option.value }}</span>
51 + </div>
52 + </div>
53 + </div>
54 +
55 + <!-- 文件上传区域 -->
56 + <div v-if="activeType !== 'text'" class="upload-area">
57 + <van-uploader
58 + v-model="fileList"
59 + :max-count="maxCount"
60 + :max-size="20 * 1024 * 1024"
61 + :before-read="beforeRead"
62 + :after-read="afterRead"
63 + @delete="onDelete"
64 + multiple
65 + :accept="getAcceptType()"
66 + result-type="file"
67 + :deletable="false"
33 /> 68 />
34 - <span class="type-text">{{ option.value }}</span> 69 +
70 + <!-- 文件列表显示 -->
71 + <div v-if="fileList.length > 0" class="file-list">
72 + <div v-for="(item, index) in fileList" :key="index" class="file-item">
73 + <div class="file-info">
74 + <van-icon :name="getFileIcon()" size="1rem" />
75 + <span class="file-name">{{ item.name || item.file?.name }}</span>
76 + <span class="file-status" :class="item.status">{{ item.message }}</span>
77 + </div>
78 + <van-icon
79 + name="clear"
80 + size="1rem"
81 + @click="delItem(item)"
82 + class="delete-icon"
83 + />
84 + </div>
85 + </div>
86 +
87 + <div class="upload-tips">
88 + <div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过20M</div>
89 + <div class="tip-text">{{ getUploadTips() }}</div>
90 + </div>
35 </div> 91 </div>
36 </div> 92 </div>
37 </div> 93 </div>
38 </div> 94 </div>
39 95
40 - <!-- 已完成提示 --> 96 + <!-- 提交按钮 -->
41 - <div v-else class="section-wrapper"> 97 + <div v-if="!taskDetail.is_finish || route.query.status === 'edit'" class="submit-area">
42 - <div class="finished-notice"> 98 + <van-button
43 - <van-icon name="success" size="3rem" color="#4caf50" /> 99 + type="primary"
44 - <div class="finished-text">作业已完成</div> 100 + block
45 - </div> 101 + size="large"
102 + :loading="uploading"
103 + :disabled="!canSubmit"
104 + @click="onSubmit"
105 + >
106 + {{ route.query.status === 'edit' ? '保存修改' : '提交打卡' }}
107 + </van-button>
46 </div> 108 </div>
47 </div> 109 </div>
110 +
111 + <!-- 上传加载遮罩 -->
112 + <van-overlay :show="loading">
113 + <div class="loading-wrapper" @click.stop>
114 + <van-loading vertical color="#FFFFFF">上传中...</van-loading>
115 + </div>
116 + </van-overlay>
48 </div> 117 </div>
49 </template> 118 </template>
50 119
51 <script setup> 120 <script setup>
52 import { ref, computed, onMounted } from 'vue' 121 import { ref, computed, onMounted } from 'vue'
53 import { useRoute, useRouter } from 'vue-router' 122 import { useRoute, useRouter } from 'vue-router'
54 -import { getTaskDetailAPI } from "@/api/checkin"; 123 +import { getTaskDetailAPI } from "@/api/checkin"
55 import { getTeacherFindSettingsAPI } from '@/api/teacher' 124 import { getTeacherFindSettingsAPI } from '@/api/teacher'
56 -import { useTitle } from '@vueuse/core'; 125 +import { useTitle } from '@vueuse/core'
126 +import { useCheckin } from '@/composables/useCheckin'
57 import dayjs from 'dayjs' 127 import dayjs from 'dayjs'
58 128
59 const route = useRoute() 129 const route = useRoute()
60 const router = useRouter() 130 const router = useRouter()
61 -useTitle('打卡详情'); 131 +useTitle('打卡详情')
132 +
133 +// 使用打卡composable
134 +const {
135 + uploading,
136 + loading,
137 + message,
138 + fileList,
139 + activeType,
140 + maxCount,
141 + canSubmit,
142 + beforeRead,
143 + afterRead,
144 + onDelete,
145 + delItem,
146 + onSubmit,
147 + switchType,
148 + initEditData
149 +} = useCheckin()
62 150
63 // 任务详情数据 151 // 任务详情数据
64 -const taskDetail = ref({}); 152 +const taskDetail = ref({})
65 153
66 // 作品类型选项 154 // 作品类型选项
67 -const attachmentTypeOptions = ref([]); 155 +const attachmentTypeOptions = ref([])
68 156
69 /** 157 /**
70 * 返回上一页 158 * 返回上一页
...@@ -84,92 +172,57 @@ const getIconName = (type) => { ...@@ -84,92 +172,57 @@ const getIconName = (type) => {
84 'image': 'photo', 172 'image': 'photo',
85 'video': 'video', 173 'video': 'video',
86 'audio': 'music' 174 'audio': 'music'
87 - };
88 - return iconMap[type] || 'edit';
89 -};
90 -
91 -/**
92 - * 处理打卡类型点击事件
93 - * @param {string} type - 打卡类型
94 - */
95 -const handleCheckinTypeClick = (type) => {
96 - switch (type) {
97 - case 'text':
98 - goToCheckinTextPage();
99 - break;
100 - case 'image':
101 - goToCheckinImagePage();
102 - break;
103 - case 'video':
104 - goToCheckinVideoPage();
105 - break;
106 - case 'audio':
107 - goToCheckinAudioPage();
108 - break;
109 - default:
110 - console.warn('未知的打卡类型:', type);
111 } 175 }
112 -}; 176 + return iconMap[type] || 'edit'
113 -
114 -/**
115 - * 跳转到文本打卡页面
116 - */
117 -const goToCheckinTextPage = () => {
118 - router.push({
119 - path: '/checkin/text',
120 - query: {
121 - id: route.query.id,
122 - type: 'text'
123 - }
124 - })
125 } 177 }
126 178
127 /** 179 /**
128 - * 跳转到图片打卡页面 180 + * 获取文件图标
181 + * @returns {string} 文件图标名称
129 */ 182 */
130 -const goToCheckinImagePage = () => { 183 +const getFileIcon = () => {
131 - router.push({ 184 + const iconMap = {
132 - path: '/checkin/image', 185 + 'image': 'photo',
133 - query: { 186 + 'video': 'video',
134 - id: route.query.id, 187 + 'audio': 'music'
135 - type: 'image' 188 + }
136 - } 189 + return iconMap[activeType.value] || 'description'
137 - })
138 } 190 }
139 191
140 /** 192 /**
141 - * 跳转到视频打卡页面 193 + * 获取上传文件类型
194 + * @returns {string} accept属性值
142 */ 195 */
143 -const goToCheckinVideoPage = () => { 196 +const getAcceptType = () => {
144 - router.push({ 197 + const acceptMap = {
145 - path: '/checkin/video', 198 + 'image': 'image/*',
146 - query: { 199 + 'video': 'video/*',
147 - id: route.query.id, 200 + 'audio': '.mp3,.wav,.aac'
148 - type: 'video', 201 + }
149 - } 202 + return acceptMap[activeType.value] || '*'
150 - })
151 } 203 }
152 204
153 /** 205 /**
154 - * 跳转到音频打卡页面 206 + * 获取上传提示文本
207 + * @returns {string} 提示文本
155 */ 208 */
156 -const goToCheckinAudioPage = () => { 209 +const getUploadTips = () => {
157 - router.push({ 210 + const tipsMap = {
158 - path: '/checkin/audio', 211 + 'image': '支持格式:jpg/jpeg/png',
159 - query: { 212 + 'video': '支持格式:视频文件',
160 - id: route.query.id, 213 + 'audio': '支持格式:.mp3/.wav/.aac 音频文件'
161 - type: 'audio', 214 + }
162 - } 215 + return tipsMap[activeType.value] || ''
163 - })
164 } 216 }
165 217
166 /** 218 /**
167 * 获取任务详情 219 * 获取任务详情
220 + * @param {string} month - 月份
168 */ 221 */
169 const getTaskDetail = async (month) => { 222 const getTaskDetail = async (month) => {
170 - const { code, data } = await getTaskDetailAPI({ i: route.query.id, month }); 223 + const { code, data } = await getTaskDetailAPI({ i: route.query.id, month })
171 if (code) { 224 if (code) {
172 - taskDetail.value = data; 225 + taskDetail.value = data
173 } 226 }
174 } 227 }
175 228
...@@ -178,20 +231,23 @@ const getTaskDetail = async (month) => { ...@@ -178,20 +231,23 @@ const getTaskDetail = async (month) => {
178 */ 231 */
179 onMounted(async () => { 232 onMounted(async () => {
180 // 获取任务详情 233 // 获取任务详情
181 - getTaskDetail(dayjs().format('YYYY-MM')); 234 + getTaskDetail(dayjs().format('YYYY-MM'))
182 235
183 // 获取作品类型数据 236 // 获取作品类型数据
184 try { 237 try {
185 - const { code, data } = await getTeacherFindSettingsAPI(); 238 + const { code, data } = await getTeacherFindSettingsAPI()
186 if (code && data.task_attachment_type) { 239 if (code && data.task_attachment_type) {
187 attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({ 240 attachmentTypeOptions.value = Object.entries(data.task_attachment_type).map(([key, value]) => ({
188 key, 241 key,
189 value 242 value
190 - })); 243 + }))
191 } 244 }
192 } catch (error) { 245 } catch (error) {
193 - console.error('获取作品类型数据失败:', error); 246 + console.error('获取作品类型数据失败:', error)
194 } 247 }
248 +
249 + // 初始化编辑数据
250 + await initEditData()
195 }) 251 })
196 </script> 252 </script>
197 253
...@@ -199,6 +255,7 @@ onMounted(async () => { ...@@ -199,6 +255,7 @@ onMounted(async () => {
199 .checkin-detail-page { 255 .checkin-detail-page {
200 min-height: 100vh; 256 min-height: 100vh;
201 background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff); 257 background: linear-gradient(to bottom right, #f0fdf4, #f0fdfa, #eff6ff);
258 + padding-bottom: 100px;
202 } 259 }
203 260
204 .page-content { 261 .page-content {
...@@ -238,52 +295,148 @@ onMounted(async () => { ...@@ -238,52 +295,148 @@ onMounted(async () => {
238 padding: 2rem 0; 295 padding: 2rem 0;
239 } 296 }
240 297
241 -.class-status { 298 +.text-input-area {
242 - display: flex; 299 + margin-bottom: 1.5rem;
243 - align-items: center; 300 +
244 - gap: 0.5rem; 301 + .van-field {
302 + border-radius: 8px;
303 + background-color: #f8f9fa;
304 + border: 1px solid #e9ecef;
305 + }
245 } 306 }
246 307
247 -.status-text { 308 +.checkin-tabs {
248 - font-size: 0.85rem; 309 + .tabs-header {
249 - color: #666; 310 + margin-bottom: 1rem;
311 + }
312 +
313 + .tab-title {
314 + font-size: 1rem;
315 + font-weight: 600;
316 + color: #333;
317 + margin-bottom: 0.8rem;
318 + }
319 +
320 + .tabs-nav {
321 + display: grid;
322 + grid-template-columns: repeat(4, 1fr);
323 + gap: 0.5rem;
324 + }
325 +
326 + .tab-item {
327 + display: flex;
328 + flex-direction: column;
329 + align-items: center;
330 + justify-content: center;
331 + padding: 0.8rem 0.5rem;
332 + border: 2px solid #e8f5e8;
333 + border-radius: 8px;
334 + background-color: #fafffe;
335 + cursor: pointer;
336 + transition: all 0.3s ease;
337 +
338 + &:hover {
339 + border-color: #4caf50;
340 + background-color: #f0fdf4;
341 + }
342 +
343 + &.active {
344 + border-color: #4caf50;
345 + background-color: #f0fdf4;
346 +
347 + .van-icon {
348 + color: #4caf50;
349 + }
350 +
351 + .tab-text {
352 + color: #4caf50;
353 + font-weight: 600;
354 + }
355 + }
356 + }
357 +
358 + .tab-text {
359 + margin-top: 0.3rem;
360 + font-size: 0.8rem;
361 + color: #666;
362 + text-align: center;
363 + }
364 +}
365 +
366 +.upload-area {
367 + margin-top: 1rem;
368 +
369 + .van-uploader {
370 + margin-bottom: 1rem;
371 + }
250 } 372 }
251 373
252 -.checkin-types { 374 +.file-list {
253 - display: grid; 375 + margin: 1rem 0;
254 - grid-template-columns: repeat(2, 1fr);
255 - gap: 1rem;
256 } 376 }
257 377
258 -.checkin-type-item { 378 +.file-item {
259 display: flex; 379 display: flex;
260 - flex-direction: column;
261 align-items: center; 380 align-items: center;
262 - justify-content: center; 381 + justify-content: space-between;
263 - padding: 1.5rem 1rem; 382 + padding: 0.8rem;
264 - border: 2px solid #e8f5e8; 383 + background-color: #f8f9fa;
265 - border-radius: 12px; 384 + border-radius: 8px;
266 - background-color: #fafffe; 385 + margin-bottom: 0.5rem;
267 - cursor: pointer; 386 +
268 - transition: all 0.3s ease; 387 + .file-info {
269 - 388 + display: flex;
270 - &:hover { 389 + align-items: center;
271 - border-color: #4caf50; 390 + flex: 1;
272 - background-color: #f0fdf4; 391 + gap: 0.5rem;
273 - transform: translateY(-2px);
274 - box-shadow: 0 4px 12px rgba(76, 175, 80, 0.15);
275 } 392 }
276 393
277 - &:active { 394 + .file-name {
278 - transform: translateY(0); 395 + flex: 1;
396 + font-size: 0.9rem;
397 + color: #333;
398 + overflow: hidden;
399 + text-overflow: ellipsis;
400 + white-space: nowrap;
401 + }
402 +
403 + .file-status {
404 + font-size: 0.8rem;
405 + padding: 0.2rem 0.5rem;
406 + border-radius: 4px;
407 +
408 + &.uploading {
409 + color: #1890ff;
410 + background-color: #e6f7ff;
411 + }
412 +
413 + &.done {
414 + color: #52c41a;
415 + background-color: #f6ffed;
416 + }
417 +
418 + &.failed {
419 + color: #ff4d4f;
420 + background-color: #fff2f0;
421 + }
422 + }
423 +
424 + .delete-icon {
425 + color: #999;
426 + cursor: pointer;
427 +
428 + &:hover {
429 + color: #ff4d4f;
430 + }
279 } 431 }
280 } 432 }
281 433
282 -.type-text { 434 +.upload-tips {
283 - margin-top: 0.5rem; 435 + .tip-text {
284 - font-size: 0.9rem; 436 + font-size: 0.8rem;
285 - color: #4caf50; 437 + color: #999;
286 - font-weight: 500; 438 + margin-bottom: 0.3rem;
439 + }
287 } 440 }
288 441
289 .finished-notice { 442 .finished-notice {
...@@ -301,4 +454,22 @@ onMounted(async () => { ...@@ -301,4 +454,22 @@ onMounted(async () => {
301 color: #4caf50; 454 color: #4caf50;
302 font-weight: 600; 455 font-weight: 600;
303 } 456 }
457 +
458 +.submit-area {
459 + position: fixed;
460 + bottom: 0;
461 + left: 0;
462 + right: 0;
463 + padding: 1rem;
464 + background-color: #fff;
465 + border-top: 1px solid #f0f0f0;
466 + z-index: 100;
467 +}
468 +
469 +.loading-wrapper {
470 + display: flex;
471 + align-items: center;
472 + justify-content: center;
473 + height: 100%;
474 +}
304 </style> 475 </style>
......
1 <!-- 1 <!--
2 * @Date: 2025-05-29 15:34:17 2 * @Date: 2025-05-29 15:34:17
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-09-30 17:02:29 4 + * @LastEditTime: 2025-09-30 17:58:09
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -57,19 +57,6 @@ ...@@ -57,19 +57,6 @@
57 </div> 57 </div>
58 </div> 58 </div>
59 59
60 - <!-- 我要打卡按钮 -->
61 - <div v-if="!taskDetail.is_finish" class="text-wrapper" style="padding-bottom: 0;">
62 - <van-button
63 - type="primary"
64 - block
65 - round
66 - @click="goToCheckinDetailPage"
67 - class="checkin-action-button"
68 - >
69 - <van-icon name="edit" size="1.2rem" />
70 - <span style="margin-left: 0.5rem;">我要打卡</span>
71 - </van-button>
72 - </div>
73 60
74 <div class="text-wrapper"> 61 <div class="text-wrapper">
75 <div class="text-header">打卡动态</div> 62 <div class="text-header">打卡动态</div>
...@@ -162,13 +149,26 @@ ...@@ -162,13 +149,26 @@
162 <van-back-top right="5vw" bottom="10vh" /> 149 <van-back-top right="5vw" bottom="10vh" />
163 </div> 150 </div>
164 151
165 - <div style="height: 5rem;"></div> 152 + <div style="height: 10rem;"></div>
166 </div> <!-- 闭合 scrollable-content --> 153 </div> <!-- 闭合 scrollable-content -->
167 </van-config-provider> 154 </van-config-provider>
168 155
169 <van-dialog v-model:show="dialog_show" title="标题" show-cancel-button></van-dialog> 156 <van-dialog v-model:show="dialog_show" title="标题" show-cancel-button></van-dialog>
170 157
171 <van-back-top right="5vw" bottom="15vh" /> 158 <van-back-top right="5vw" bottom="15vh" />
159 +
160 + <!-- 底部悬浮打卡按钮 -->
161 + <div v-if="!taskDetail.is_finish" class="floating-checkin-button">
162 + <van-button
163 + type="primary"
164 + round
165 + @click="goToCheckinDetailPage"
166 + class="checkin-action-button"
167 + >
168 + <van-icon name="edit" size="1.2rem" />
169 + <span style="margin-left: 0.5rem;">我要打卡</span>
170 + </van-button>
171 + </div>
172 </AppLayout> 172 </AppLayout>
173 </template> 173 </template>
174 174
...@@ -606,34 +606,15 @@ const handLike = async (post) => { ...@@ -606,34 +606,15 @@ const handLike = async (post) => {
606 } 606 }
607 607
608 const editCheckin = (post) => { 608 const editCheckin = (post) => {
609 - if (post.file_type === 'image') { 609 + // 统一跳转到CheckinDetailPage页面处理所有类型的编辑
610 - router.push({ 610 + router.push({
611 - path: '/checkin/image', 611 + path: '/checkin/detail',
612 - query: { 612 + query: {
613 - post_id: post.id, 613 + post_id: post.id,
614 - type: 'image', 614 + type: post.file_type,
615 - status: 'edit', 615 + status: 'edit',
616 - } 616 + }
617 - }) 617 + })
618 - } else if (post.file_type === 'video') {
619 - router.push({
620 - path: '/checkin/video',
621 - query: {
622 - post_id: post.id,
623 - type: 'video',
624 - status: 'edit',
625 - }
626 - })
627 - } else if (post.file_type === 'audio') {
628 - router.push({
629 - path: '/checkin/audio',
630 - query: {
631 - post_id: post.id,
632 - type: 'audio',
633 - status: 'edit',
634 - }
635 - })
636 - }
637 } 618 }
638 619
639 const delCheckin = (post) => { 620 const delCheckin = (post) => {
...@@ -651,6 +632,13 @@ const delCheckin = (post) => { ...@@ -651,6 +632,13 @@ const delCheckin = (post) => {
651 // router.go(0); 632 // router.go(0);
652 // 删除post_id相应的数据 633 // 删除post_id相应的数据
653 checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id); 634 checkinDataList.value = checkinDataList.value.filter(item => item.id !== post.id);
635 + // 检查是否还可以打卡
636 + const current_date = route.query.date;
637 + if (current_date) {
638 + getTaskDetail(dayjs(current_date).format('YYYY-MM'));
639 + } else {
640 + getTaskDetail(dayjs().format('YYYY-MM'));
641 + }
654 } else { 642 } else {
655 showErrorToast('删除失败'); 643 showErrorToast('删除失败');
656 } 644 }
...@@ -957,6 +945,25 @@ const formatData = (data) => { ...@@ -957,6 +945,25 @@ const formatData = (data) => {
957 </style> 945 </style>
958 946
959 <style scoped> 947 <style scoped>
948 +/* 底部悬浮打卡按钮样式 */
949 +.floating-checkin-button {
950 + position: fixed;
951 + bottom: 6rem;
952 + left: 1rem;
953 + right: 1rem;
954 + z-index: 1000;
955 + padding: 0 1rem;
956 +}
957 +
958 +.floating-checkin-button .checkin-action-button {
959 + width: 100%;
960 + height: 3rem;
961 + font-size: 1rem;
962 + font-weight: 600;
963 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
964 + border: none;
965 +}
966 +
960 :deep(.van-calendar__footer) { 967 :deep(.van-calendar__footer) {
961 display: none; 968 display: none;
962 } 969 }
......